pretext实现余力深度解析

12 阅读4分钟

Pretext 实现原理深度分析

Pretext 是一个纯 JavaScript/TypeScript 的多行文本测量和布局库,能够不依赖 DOM 回流就计算出文本高度。

核心价值

问题: 传统的 getBoundingClientRectoffsetHeight 会触发同步布局回流,当页面有 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行

换行点由什么决定?

  1. 空格 - kinds[i] === 'space' 后可换行
  2. CJK 字符 - 中文每个字符都是独立分段,随时可换
  3. 软连字符 - kinds[i] === 'soft-hyphen' 可断开并加 -
  4. overflow-wrap - 单词太长时按 breakableWidths 断开
  5. 硬换行 - \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])
}

使用场景

  1. 虚拟列表 - 知道文本高度才能做虚拟滚动
  2. Canvas 渲染 - 游戏/WebGL 中的文本布局
  3. 自定义布局 - 瀑布流、自适应宽度
  4. 服务端渲染 - 不需要浏览器就能算布局
  5. 开发时验证 - 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 中剥离出来:

  1. prepare() - 一次性做昂贵操作(分段 + Canvas 测量)
  2. layout() - 变成纯算术(数组遍历 + 加法)

从而实现高性能的文本布局计算,特别适合虚拟列表、Canvas 渲染等场景。