Read OSS

Pretext 架构解析:为什么两阶段设计优于 DOM 测量

中级

前置知识

  • Web 开发基础与 DOM 概念
  • CSS 文本布局模型(white-space、overflow-wrap)
  • TypeScript 类型系统基本认知

Pretext 架构解析:为什么两阶段设计优于 DOM 测量

如果你曾在渲染聊天界面时,看到 Chrome DevTools 被"Forced reflow"警告刷成一片紫色,那么你已经亲历了 Pretext 要解决的问题。Chenglou 开发的 @chenglou/pretext 是一个纯 JavaScript 库,能在不触碰 DOM 的情况下完成多行文本的测量与布局——每次 layout 调用的耗时约为 0.0002ms。它的秘诀不在于用更聪明的算法解决同一个问题,而在于从结构上将问题分解,让昂贵的操作彻底从热路径中消失。

本文是六篇深度解析系列的第一篇。我们将从架构骨架入手,依次介绍两阶段模型、模块依赖图、不透明句柄模式,以及让换行引擎如此高效的并行数组数据模型。

问题根源:DOM 测量交错与布局抖动

layout.ts 开头的注释块直接点明了这个问题:

src/layout.ts#L1-L33

当多个 UI 组件各自通过 getBoundingClientRect()offsetHeight 独立测量文本高度时,每次读操作都可能触发同步布局重排。如果这些读操作与 DOM 写操作交错进行——比如先设置容器的 style.width,再读取文本高度——浏览器就不得不在每个读写循环中对整个文档重新布局。

sequenceDiagram
    participant App as Application
    participant DOM as Browser DOM
    Note over App,DOM: Layout Thrashing Pattern
    App->>DOM: Set container width (WRITE)
    App->>DOM: Read text height (READ) — forces reflow
    App->>DOM: Set another container width (WRITE)
    App->>DOM: Read text height (READ) — forces reflow again
    Note over DOM: Each read invalidates the layout,<br/>costing 30ms+ for 500 text blocks

对于一个显示 500 条消息的聊天应用,这种模式每帧可能耗费 30ms 以上——远超单帧预算。通常的建议是批量处理读写操作,但这要求跨组件协调,破坏了封装性。

两阶段方案:prepare() 与 layout()

Pretext 的解法是将文本测量拆分成两个阶段,两者有着截然不同的性能特征:

阶段一:prepare(text, font) — 在文本首次出现时调用一次。使用 Intl.Segmenter 对文本进行分段,通过 Canvas 的 measureText() 测量每个片段的宽度,并将结果缓存。这一步开销较大(涉及字体引擎调用、分段处理、Unicode 分析),但对每块文本只执行一次。

阶段二:layout(prepared, maxWidth, lineHeight) — 在每次尺寸变化时调用。遍历已缓存的宽度数据,通过纯算术计算行数与高度。没有 Canvas 调用,没有 DOM 读取,没有字符串操作,也不产生内存分配。

src/layout.ts#L458-L500

flowchart LR
    subgraph "Phase 1 — prepare() [once]"
        A[Raw text] --> B[Whitespace normalize]
        B --> C[Intl.Segmenter]
        C --> D[Merge cascade]
        D --> E[Canvas measureText]
        E --> F[PreparedText handle]
    end
    subgraph "Phase 2 — layout() [every resize]"
        F --> G[Walk cached widths]
        G --> H[Pure arithmetic]
        H --> I["{lineCount, height}"]
    end

prepare() 的返回结果与宽度无关——同一个 PreparedText 句柄可以在任意 maxWidthlineHeight 下复用。这正是核心所在:开销昂贵的工作(分段、测量、Unicode 分析)与容器宽度无关,只有开销轻微的工作(换行计算)才依赖宽度。

layout() 的注释明确写出了性能目标:

// ~0.0002ms per text block. Call on every resize.

200 纳秒——每毫秒可以处理 5000 个文本块。

提示: 如果你的应用中文本内容变化不频繁、但容器尺寸频繁变化(聊天消息、评论、社交信息流),两阶段模型几乎可以让 resize 时的重新布局"零成本"。在消息到达时调用一次 prepare(),然后在动画的每一帧调用 layout() 即可。

模块依赖架构

Pretext 由五个源码模块组成,形成一个清晰的有向无环依赖图:

模块 职责 行数
layout.ts 公共 API 编排、测量桥接、行物化 ~718
analysis.ts 空白符规范化、分段处理、合并级联 ~1020
measurement.ts Canvas 测量、emoji 修正、引擎 profile、缓存 ~232
line-break.ts 换行引擎(简单路径 + 完整路径) ~1059
bidi.ts 用于富文本渲染的简化版 UAX #9 双向文本层级 ~174
graph TD
    layout["layout.ts<br/>(Public API)"]
    analysis["analysis.ts<br/>(Text Analysis)"]
    measurement["measurement.ts<br/>(Canvas Measurement)"]
    linebreak["line-break.ts<br/>(Line Walker)"]
    bidi["bidi.ts<br/>(Bidi Metadata)"]

    layout --> analysis
    layout --> measurement
    layout --> linebreak
    layout --> bidi
    linebreak --> analysis
    linebreak --> measurement
    measurement --> analysis

依赖关系严格单向流动:layout.ts 导入所有四个模块,line-break.tsanalysis.ts 导入类型、从 measurement.ts 导入引擎 profile,measurement.ts 则从 analysis.ts 导入 isCJK 辅助函数。整个图中不存在循环依赖。

这种设计有两个重要意义。其一是 tree-shaking:如果只需要不透明路径,打包工具可以精确判断所需的代码范围。其二是认知负担:阅读任意模块时,只需连同其导入一起理解,完全不用担心跨模块的状态突变。

src/layout.ts#L35-L65

PreparedText 不透明句柄模式

Pretext 使用 TypeScript 品牌类型(branded types)来稳定公共 API,同时保留内部表示自由演化的空间。

src/layout.ts#L79-L109

公共类型是一个空的品牌接口:

declare const preparedTextBrand: unique symbol

export type PreparedText = {
  readonly [preparedTextBrand]: true
}

在内部,实际数据存储于 InternalPreparedText,它同时继承品牌类型和包含并行数组的 PreparedCore 类型。外部代码无法访问任何内部字段——品牌是用 declare 声明的 unique symbol(从未被赋值),因此无法在外部构造或解构。

classDiagram
    class PreparedText {
        <<branded>>
        +[preparedTextBrand]: true
    }
    class PreparedCore {
        +widths: number[]
        +kinds: SegmentBreakKind[]
        +lineEndFitAdvances: number[]
        +lineEndPaintAdvances: number[]
        +breakableWidths: (number[] | null)[]
        +breakablePrefixWidths: (number[] | null)[]
        +simpleLineWalkFastPath: boolean
        +chunks: PreparedLineChunk[]
        +discretionaryHyphenWidth: number
        +tabStopAdvance: number
        +segLevels: Int8Array | null
    }
    class InternalPreparedText {
        <<internal>>
    }
    class PreparedTextWithSegments {
        +segments: string[]
    }
    PreparedText <|-- InternalPreparedText
    PreparedCore <|-- InternalPreparedText
    InternalPreparedText <|-- PreparedTextWithSegments

公共类型与内部类型之间的转换只需一个函数:

function getInternalPrepared(prepared: PreparedText): InternalPreparedText {
  return prepared as InternalPreparedText
}

src/layout.ts#L482-L484

这个模式非常值得借鉴,适用于任何内部数据结构可能迭代演进的库。品牌类型防止调用方依赖随时可能变更的字段名,而内部类型转换在运行时是零开销的。

两层公共 API:快速路径与富文本路径

Pretext 对外暴露两层功能:

第一层:不透明快速路径prepare() + layout()。仅返回行数和高度,不含分段数据、双向文本元数据或字符串物化。这是 resize 热路径的专属通道。

第二层:富文本感知路径prepareWithSegments() + layoutWithLines() / walkLineRanges() / layoutNextLine()。暴露分段文本、行边界、每行宽度以及双向文本层级,供自定义渲染使用。

src/layout.ts#L472-L480

flowchart TD
    subgraph "Tier 1: Fast Path"
        P1[prepare] --> L1[layout]
        L1 --> R1["{lineCount, height}"]
    end
    subgraph "Tier 2: Rich Path"
        P2[prepareWithSegments] --> L2[layoutWithLines]
        P2 --> L3[walkLineRanges]
        P2 --> L4[layoutNextLine]
        L2 --> R2["{lines: LayoutLine[]}"]
        L3 --> R3["onLine callback"]
        L4 --> R4["LayoutLine | null"]
    end

两层在 prepare 阶段的关键区别在于:prepareWithSegments() 会保留分段文本字符串和双向文本层级,而不透明的 prepare() 会跳过这些内容。在 layout 阶段,layoutWithLines() 负责物化行文本(包括可选连字符的插入),walkLineRanges() 在不物化字符串的情况下提供行几何信息,layoutNextLine() 则支持迭代器风格的可变宽度布局。

提示: 用不透明路径(prepare + layout)来确定容器尺寸,只有在需要自行渲染文本时——比如绘制到 Canvas 或构建自定义选区矩形——才切换到富文本路径。

并行数组数据模型

PreparedCore 类型采用结构体数组(struct-of-arrays)的布局,而非数组结构体(array-of-structs):

src/layout.ts#L83-L95

type PreparedCore = {
  widths: number[]               // Segment widths
  lineEndFitAdvances: number[]   // Width contribution for line-fit decisions
  lineEndPaintAdvances: number[] // Width contribution for visible painting
  kinds: SegmentBreakKind[]      // Break behavior per segment
  breakableWidths: (number[] | null)[]      // Per-grapheme widths for overflow-wrap
  breakablePrefixWidths: (number[] | null)[] // Cumulative prefix widths
  simpleLineWalkFastPath: boolean
  // ...
}

每个数组都以片段位置为索引,widths[i]kinds[i]lineEndFitAdvances[i] 描述的是同一个片段。这让换行引擎的热循环保持紧凑——它顺序访问数组位置,对缓存友好,避免了对象指针追踪带来的开销。

erDiagram
    SEGMENT_INDEX ||--|| WIDTHS : "widths[i]"
    SEGMENT_INDEX ||--|| KINDS : "kinds[i]"
    SEGMENT_INDEX ||--|| FIT_ADVANCES : "lineEndFitAdvances[i]"
    SEGMENT_INDEX ||--|| PAINT_ADVANCES : "lineEndPaintAdvances[i]"
    SEGMENT_INDEX ||--o| BREAKABLE_WIDTHS : "breakableWidths[i]"
    SEGMENT_INDEX ||--o| BREAKABLE_PREFIX : "breakablePrefixWidths[i]"

breakableWidths 数组值得单独说明。对于类词且含多个字形(grapheme)的片段,它存储每个字形的宽度,从而支持字符级别的换行(overflow-wrap: break-word);对于单字形或不可断行的片段,则为 null,不产生额外内存分配。其配套数组 breakablePrefixWidths 存储累积前缀宽度,作为针对 Safari 的浏览器兼容补丁,我们将在第四篇中详细介绍。

simpleLineWalkFastPath 布尔值是一个在 prepare 阶段计算好的快速路径开关。当文本只包含 textspacezero-width-break 片段——没有硬换行、软连字符、制表符或粘合元素——换行引擎就可以使用更简单、更快速的循环分支。绝大多数真实场景下的文本都满足这一条件。

展望

本文建立了系统的结构性基础:让 resize 变得廉价的两阶段拆分、保护内部实现的不透明句柄,以及让换行引擎保持高效的并行数组。但我们还没有触及系统中最复杂的部分——将原始字符串转化为这些并行数组的文本分析流水线。

在第二篇中,我们将深入 analysis.ts,探究空白符规范化、Intl.Segmenter 以及一套精密的多轮合并级联,如何处理 CJK 字形拆分、阿拉伯语无空格字符集群、禁则处理、URL 合并等半打国际化难题——而这一切都发生在测量第一个像素之前。