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 开头的注释块直接点明了这个问题:
当多个 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 读取,没有字符串操作,也不产生内存分配。
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 句柄可以在任意 maxWidth 和 lineHeight 下复用。这正是核心所在:开销昂贵的工作(分段、测量、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.ts 从 analysis.ts 导入类型、从 measurement.ts 导入引擎 profile,measurement.ts 则从 analysis.ts 导入 isCJK 辅助函数。整个图中不存在循环依赖。
这种设计有两个重要意义。其一是 tree-shaking:如果只需要不透明路径,打包工具可以精确判断所需的代码范围。其二是认知负担:阅读任意模块时,只需连同其导入一起理解,完全不用担心跨模块的状态突变。
PreparedText 不透明句柄模式
Pretext 使用 TypeScript 品牌类型(branded types)来稳定公共 API,同时保留内部表示自由演化的空间。
公共类型是一个空的品牌接口:
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
}
这个模式非常值得借鉴,适用于任何内部数据结构可能迭代演进的库。品牌类型防止调用方依赖随时可能变更的字段名,而内部类型转换在运行时是零开销的。
两层公共 API:快速路径与富文本路径
Pretext 对外暴露两层功能:
第一层:不透明快速路径 — prepare() + layout()。仅返回行数和高度,不含分段数据、双向文本元数据或字符串物化。这是 resize 热路径的专属通道。
第二层:富文本感知路径 — prepareWithSegments() + layoutWithLines() / walkLineRanges() / layoutNextLine()。暴露分段文本、行边界、每行宽度以及双向文本层级,供自定义渲染使用。
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):
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 阶段计算好的快速路径开关。当文本只包含 text、space 和 zero-width-break 片段——没有硬换行、软连字符、制表符或粘合元素——换行引擎就可以使用更简单、更快速的循环分支。绝大多数真实场景下的文本都满足这一条件。
展望
本文建立了系统的结构性基础:让 resize 变得廉价的两阶段拆分、保护内部实现的不透明句柄,以及让换行引擎保持高效的并行数组。但我们还没有触及系统中最复杂的部分——将原始字符串转化为这些并行数组的文本分析流水线。
在第二篇中,我们将深入 analysis.ts,探究空白符规范化、Intl.Segmenter 以及一套精密的多轮合并级联,如何处理 CJK 字形拆分、阿拉伯语无空格字符集群、禁则处理、URL 合并等半打国际化难题——而这一切都发生在测量第一个像素之前。