Pretext 实现原理深度分析
Pretext 是一个纯 JavaScript/TypeScript 的多行文本测量和布局库,能够不依赖 DOM 回流就计算出文本高度。
核心价值
问题: 传统的 getBoundingClientRect、offsetHeight 会触发同步布局回流,当页面有 500 个文本块时,每帧可能要花 30ms+ 在测量上。
解决方案: Pretext 使用 Canvas API + Intl.Segmenter 实现纯 JS 测量,避免了 DOM 回流。
prepare() → 一次性预计算(~19ms/500文本)
layout() → 纯算术计算高度(~0.09ms/500文本)
核心架构:两阶段测量
prepare(text, font) → 预计算(一次性)
↓
layout(prepared, width, lineHeight) → 纯算术(每次 resize)
1. prepare() 做什么?
输入: 文本 + 字体配置
输出: 预计算的数据结构
const text = "Hello 世界!"
const prepared = prepare(text, "16px Inter")
内部处理流程:
原始文本: "Hello 世界!"
↓ 1. 分段(Intl.Segmenter)
分段结果: ["Hello", " ", "世", "界", "!"]
分段类型: ["text", "space", "text", "text", "text"]
↓ 2. 测量宽度(Canvas measureText)
宽度数据: [42.5, 4.4, 16.0, 16.0, 5.2]
2. layout() 做什么?
输入: prepared 对象 + 容器宽度 + 行高
输出: { height, lineCount }
const { height, lineCount } = layout(prepared, 100, 20)
// height = lineCount * lineHeight
关键:layout() 是纯算术,不调用任何测量 API!
// 简化版 layout 逻辑
function layout(prepared, maxWidth, lineHeight) {
let lineWidth = 0
let lineCount = 1
for (let i = 0; i < prepared.widths.length; i++) {
const segWidth = prepared.widths[i] // 直接从数组读
if (lineWidth + segWidth > maxWidth) {
lineCount++
lineWidth = segWidth
} else {
lineWidth += segWidth
}
}
return { height: lineCount * lineHeight, lineCount }
}
PreparedText 数据结构详解
type PreparedCore = {
// === 核心数据(每个分段一个值)===
widths: number[] // 每个分段的宽度(像素)
kinds: SegmentBreakKind[] // 每个分段的类型(决定能否换行)
lineEndFitAdvances: number[] // 行尾时的宽度贡献
lineEndPaintAdvances: number[] // 行尾时的绘制宽度
// === 可断词的额外数据 ===
breakableWidths: (number[] | null)[] // 每个字符的宽度(用于 overflow-wrap)
breakablePrefixWidths: (number[] | null)[] // 累计宽度(二分查找用)
// === 特殊情况 ===
discretionaryHyphenWidth: number // 软连字符 "-" 的宽度
tabStopAdvance: number // Tab 停止位间隔
// === 分块(遇到 \n 分开)===
chunks: PreparedLineChunk[] // 预编译的硬换行块
// === 优化标记 ===
simpleLineWalkFastPath: boolean // 普通文本可用简化算法
// === 双向文本(阿拉伯语等)===
segLevels: Int8Array | null // Bidi 元数据
}
// 分段类型
type SegmentBreakKind =
| 'text' // 普通文本
| 'space' // 可折叠空格
| 'preserved-space' // pre-wrap 保留空格
| 'tab' // 制表符
| 'glue' // 粘连标点
| 'zero-width-break' // 零宽断点
| 'soft-hyphen' // 软连字符
| 'hard-break' // 强制换行(\n)
具体例子
const text = "Hello 世界! How are\nyou?"
const prepared = prepare(text, "16px Inter")
分段结果
原文: "Hello 世界! How are\nyou?"
↓
分段: ["Hello", " ", "世", "界", "!", " ", "How", " ", "are", "\n", "you", "?"]
索引: 0 1 2 3 4 5 6 7 8 9 10 11
prepared 内部数据
{
widths: [
42.5, // "Hello"
4.4, // " "
16.0, // "世"
16.0, // "界"
5.2, // "!"
4.4, // " "
28.8, // "How"
4.4, // " "
22.4, // "are"
0, // "\n" - 硬换行
28.8, // "you"
5.2 // "?"
],
kinds: [
'text', // "Hello"
'space', // " "
'text', // "世"
'text', // "界"
'text', // "!"
'space', // " "
'text', // "How"
'space', // " "
'text', // "are"
'hard-break', // "\n"
'text', // "you"
'text' // "?"
],
breakableWidths: [
null, // "Hello" - 英文单词
null,
[16.0, 16.0], // "世界" - 中文每个字符可断
null,
// ...
],
chunks: [
{ startSegmentIndex: 0, endSegmentIndex: 9 },
{ startSegmentIndex: 10, endSegmentIndex: 12 }
]
}
换行算法
换行不是靠换行符,而是靠累加宽度判断!
文本: "Hello world test"
宽度: [42.5, 4.4, 37.2, 4.4, 28.0]
maxWidth = 80
第1步: lineWidth = 0 + 42.5 = 42.5 (Hello) ✓
第2步: lineWidth = 42.5 + 4.4 = 46.9 (空格) ✓
第3步: lineWidth = 46.9 + 37.2 = 84.1 > 80 ❌ 换行!
lineCount = 2, lineWidth = 37.2 (world)
第4步: lineWidth = 37.2 + 4.4 = 41.6 (空格) ✓
第5步: lineWidth = 41.6 + 28.0 = 69.6 (test) ✓
结果: 2行
换行点由什么决定?
- 空格 -
kinds[i] === 'space'后可换行 - CJK 字符 - 中文每个字符都是独立分段,随时可换
- 软连字符 -
kinds[i] === 'soft-hyphen'可断开并加- - overflow-wrap - 单词太长时按
breakableWidths断开 - 硬换行 -
\n强制换行
多语言支持
分段(Intl.Segmenter)
浏览器原生分段器自动处理所有语言:
- 中文/日文/韩文 → 按字符分段
- 英语 → 按单词分段
- 泰语 → 按词分段(泰语没有空格)
- 阿拉伯语 → 正确处理双向文本
- Emoji → 识别为单个 grapheme
const segmenter = new Intl.Segmenter(locale, { granularity: 'word' })
for (const segment of segmenter.segment(text)) {
// 自动处理各种语言
}
Emoji 修正
Chrome/Firefox 在字号 <24px 时,Canvas 测量的 emoji 比实际 DOM 宽:
if (textMayContainEmoji(seg)) {
const canvasWidth = ctx.measureText(seg).width
const domWidth = measureDOM(seg) // 一次性 DOM 读取
const correction = domWidth - canvasWidth
// 缓存修正值,后续只用 Canvas
}
渲染方式
Pretext 不负责渲染,只告诉你高度!
1. DOM 渲染
const { height } = layout(prepared, 300, 24)
const div = document.createElement('div')
div.style.width = '300px'
div.style.height = `${height}px` // ← Pretext 告诉你高度
div.style.lineHeight = '24px'
div.style.font = '16px Inter'
div.textContent = text
2. Canvas 渲染
const prepared = prepareWithSegments("Hello world", "16px Inter")
const { lines } = layoutWithLines(prepared, 100, 20)
lines.forEach((line, i) => {
ctx.fillText(line.text, 0, i * 20 + 16)
})
3. 虚拟列表
const items = data.map(text => {
const prepared = prepare(text, "16px Inter")
const { height } = layout(prepared, containerWidth, 20)
return { text, prepared, height }
})
// 总高度
const totalHeight = items.reduce((sum, item) => sum + item.height, 0)
// 只渲染可见项
for (let i = visibleStart; i < visibleEnd; i++) {
renderItem(items[i])
}
使用场景
- 虚拟列表 - 知道文本高度才能做虚拟滚动
- Canvas 渲染 - 游戏/WebGL 中的文本布局
- 自定义布局 - 瀑布流、自适应宽度
- 服务端渲染 - 不需要浏览器就能算布局
- 开发时验证 - AI 生成代码时检查文本是否溢出
完整流程图
┌─────────────────────────────────────────────────────────────┐
│ Pretext 流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. prepare() ← 文本 + 字体 │
│ ↓ │
│ [widths, kinds, ...] ← 预计算的分段数据 │
│ │
│ 2. layout() ← prepared + 容器宽度 + 行高 │
│ ↓ │
│ { height, lineCount } ← 纯算术,瞬间完成 │
│ │
│ 3. 你自己渲染 │
│ ↓ │
│ DOM: <div style="height: 120px">文本...</div> │
│ Canvas: ctx.fillText(line.text, x, y) │
│ SVG: <text y="20">每行文本</text> │
│ │
└─────────────────────────────────────────────────────────────┘
总结
Pretext 的核心创新是把文本测量从 DOM 中剥离出来:
- prepare() - 一次性做昂贵操作(分段 + Canvas 测量)
- layout() - 变成纯算术(数组遍历 + 加法)
从而实现高性能的文本布局计算,特别适合虚拟列表、Canvas 渲染等场景。