Pretext:无 DOM 的多行文本测量与排版库

15 阅读12分钟

一、背景:文本测量的老大难问题

1.1 为什么需要精确的文本高度?

以下场景都依赖对文本高度的精确预测:

  • 虚拟列表:渲染 10 万条动态高度的消息,需要提前知道每条的像素高度
  • 瀑布流布局(Masonry):把内容塞进最短的那列,高度算错就乱
  • 自定义排版引擎:Canvas 渲染、WebGL UI、PDF 生成——没有 DOM 可查
  • AI 生成 UI:服务端生成界面,不可能在浏览器里 "渲染一遍再量"

1.2 传统方案的痛点

方案 A:插入 DOM 再查询

const el = document.createElement('div')
el.style.cssText = `font: 16px Inter; width: 320px; visibility: hidden`
el.textContent = text
document.body.appendChild(el)
const height = el.getBoundingClientRect().height  // 强制触发 Layout Reflow!
document.body.removeChild(el)

问题:

  • 每次调用都触发一次完整的 Layout Reflow(浏览器重新计算所有元素位置)
  • 批量测量 500 条文本 → 500 次 Reflow → 主线程卡死
  • 无法在 Node.js / Worker 中运行
  • 时序问题:字体未加载完成时结果不准

方案 B:Canvas measureText(初步改进)

const ctx = canvas.getContext('2d')
ctx.font = '16px Inter'
const metrics = ctx.measureText(text)
// 返回的是单行宽度,无法直接得到多行高度

问题:

  • 只能测单行宽度
  • 不处理换行、多语言、Bidi
  • 自己实现换行逻辑 = 重新造一个排版引擎

二、认识 Pretext

2.1 是什么

Pretext 是一个纯 TypeScript 的多行文本测量与排版库,完全在 DOM 之外运行。

npm install @chenglou/pretext
  • GitHub:github.com/chenglou/pr… — 31k Stars
  • 作者:Cheng Lou(React Motion 作者、ReScript 核心成员、前 Meta/Midjourney)
  • 首发:2026 年初,Hacker News 380 分,国内 Juejin/Zhihu 广泛讨论

2.2 一句话解释它做了什么

你给它一段文字 + 一个字体 + 一个宽度  →  它告诉你会占多少行、多高
完全不碰 DOM,不触发 Reflow,可在 Node.js / Worker / WebGL 中运行

三、核心原理:两阶段架构

这是 Pretext 最关键的设计,理解这个就理解了它为什么快。

3.1 整体架构

┌────────────────────────────────────────────────────────────────┐
│  阶段 1prepare(text, font)                                    │
│                                                                │
│  文本  →  [Unicode 分词][Canvas.measureText 缓存]         │
│       →  [空白规范化]    →  [Emoji 宽度修正]                   │
│       →  PreparedText(不透明对象,内含缓存数据)               │
│                                  ↑                             │
│                          一次性,约 19ms/500条                  │
└────────────────────────────────────────────────────────────────┘
           ↓  PreparedText 可复用,多次 layout
┌────────────────────────────────────────────────────────────────┐
│  阶段 2layout(prepared, maxWidth, lineHeight)                 │
│                                                                │
│  PreparedText + 宽度  →  纯算术  →  { lineCount, height }      │
│                                                                │
│  零 DOM / 零 Canvas API / 零内存分配,约 0.09ms/次              │
└────────────────────────────────────────────────────────────────┘

3.2 阶段 1 细节:prepare() 做了什么

源码路径:src/layout.ts + src/analysis.ts + src/measurement.ts

第一步:空白规范化(对应 CSS white-space: normal

const collapsibleWhitespaceRunRe = /[ \t\n\r\f]+/g
const needsWhitespaceNormalizationRe = /[\t\n\r\f]| {2,}|^ | $/
// src/analysis.ts
export function normalizeWhitespaceNormal(text: string): string {
  // 1. 将所有空白字符(\t \n \r 等)替换为空格
  // 2. 合并连续空格为单个空格
  // 3. 去除首尾空格
}

第二步:Unicode 分词Intl.Segmenter + 7 步后处理流水线)

分词分两个阶段:先用浏览器原生 API 做初始切分,再经过一条修正流水线。

2a. Intl.Segmenter 初始切分,兼容各种语言

// 全局单例,setLocale() 会重置它
let sharedWordSegmenter: Intl.Segmenter | null = nullfunction getSharedWordSegmenter(): Intl.Segmenter {
  if (sharedWordSegmenter === null) {
    sharedWordSegmenter = new Intl.Segmenter(segmenterLocale, { granularity: 'word' })
  }
  return sharedWordSegmenter
}

granularity: 'word' 模式下,浏览器按语言规则切词,并标记每段是否 isWordLike

"Hello, 世界!" 的初始输出:

"Hello"  isWordLike: true
","      isWordLike: false
" "      isWordLike: false
"世"     isWordLike: true
"界"     isWordLike: true
"!"      isWordLike: false

2b. 打上 SegmentBreakKind 标签

每个片段被分类为 8 种类型,决定断行逻辑如何对待它:

type SegmentBreakKind =
  | 'text'             // 普通词
  | 'space'            // 可折叠空格(行末丢弃)
  | 'preserved-space'  // pre-wrap 保留空格
  | 'tab'              // Tab,触发制表位对齐
  | 'glue'             // 不换行空格 \u00A0,粘住两侧词
  | 'zero-width-break' // 零宽换行机会 \u200B(建议可断但不显示)
  | 'soft-hyphen'      // 软连字符 \u00AD(断行时显示 -)
  | 'hard-break'       // 强制换行 \n(pre-wrap 模式)

2c. 7 步合并/拆分流水线

Intl.Segmenter 的结果不完全符合视觉断行规则,需要修正:

Pass 1 & 2:URL 保持完整

"https://example.com/path?a=1"
Intl 会在 / ? = 处切开 → mergeUrlLikeRuns + mergeUrlQueryRuns 合并回一个单元

Pass 3 & 4:数字处理

"1,234.56"  Intl 切开  → mergeNumericRuns 合并为一个词(不可断)
"1234-5678" Intl 合并  → splitHyphenatedNumericRuns 在 - 处拆开(允许断行)

Pass 5:ASCII 标点吸附到前一个词

"Hello,"Intl 切成 "Hello" + ","
          → mergeAsciiPunctuationChains 合并为 "Hello,"

理由:标点和前一个词视觉上是整体,不应在标点前换行。

Pass 6:CJK 引号后的进位(禁则规则)

kinsokuEnd(不能出现在行尾)和 kinsokuStart(不能出现在行首)字符集:

export const kinsokuStart = new Set([
  '\uFF0C', '\uFF0E', '\uFF01', '\uFF1A', '\uFF1B', '\uFF1F',  // ,。!:;?
  '\u3001', '\u3002', '\u30FB', '\uFF09', '\u3015', ...         // 、。・)〕…
])
export const kinsokuEnd = new Set([
  '"', '(', '[', '{', '"', ''', '«',
  '\uFF08', '\u3014', '\u3008', '\u300A', '\u300C', ...         // (〔《「…
])

carryTrailingForwardStickyAcrossCJKBoundary 处理引号跨越 CJK 边界的进位, 此处 Safari 和 Chromium 行为不同(EngineProfile.carryCJKAfterClosingQuote)。

Pass 7:不换行空格粘连

"foo\u00A0bar"  → glue 类型的 \u00A0 把 foo 和 bar 粘在一起
               → mergeGlueConnectedTextRuns 合并为一个单元,不允许在此换行

2d. 最终产物:MergedSegmentation

7 步流水线结束后,得到四个平行数组(数组的结构体,缓存友好):

type MergedSegmentation = {
  len: number
  texts: string[]           // ["Hello", ",", " ", "世", "界", "!"]
  isWordLike: boolean[]     // [true, false, false, true, true, false]
  kinds: SegmentBreakKind[] // ['text', 'text', 'space', 'text', 'text', 'text']
  starts: number[]          // [0, 5, 6, 7, 8, 9]  ← 在原始字符串中的偏移
}

这四个数组就是 PreparedText 的内部骨架,后续 layout() 只操作这些数据,不再碰原始字符串。

为什么用平行数组而不是对象数组?— CPU 缓存

CPU 计算时数据必须先进寄存器,但寄存器极小(几十个),数据平时住在内存里。直接从内存读到寄存器太慢(~60ns),所以 CPU 和内存之间有三级缓存(L1/L2/L3)作为中转:

内存(慢,~60ns)→ L1/L2/L3 缓存(快,~1–10ns)→ 寄存器(极快,~0.3ns)→ 计算

CPU 读数据时不是按字节搬,而是一次从内存载入 64 字节(一个缓存行)到缓存,再从缓存送入寄存器:

你访问了 kinds[2]
CPU 一次性把 kinds[0]~kinds[63] 载入缓存
后续访问 kinds[3][4][5]... 缓存命中,直接送寄存器,不用再等内存

对象数组的内存布局是每个对象连续,访问 kind 字段时要跳过 textisWordLikestart

[text|isWordLike|kind|start] [text|isWordLike|kind|start] ...
← 每个对象 ~50 字节,一个缓存行只能装 1 个 →

平行数组的 kinds 是连续序列:

kinds: [k0][k1][k2][k3]...[k63]
     ← 一个缓存行装 64 个,循环后续 64 次全部命中 →

layout() 的断行循环要遍历几百个分词,每次只看 kinds[i]widths[i],平行数组让这两个热路径数组的缓存命中率接近 100%——这是 layout() 跑到 0.09ms 的原因之一。

第三步:Canvas 测量 + 缓存src/measurement.ts

3a. 获取 Canvas 上下文

let measureContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null

export function getMeasureContext() {
  if (measureContext !== null) return measureContext

  if (typeof OffscreenCanvas !== 'undefined') {
    // 优先用 OffscreenCanvas:不依赖 DOM,可在 Web Worker 中运行
    measureContext = new OffscreenCanvas(1, 1).getContext('2d')!
    return measureContext
  }

  if (typeof document !== 'undefined') {
    // 浏览器主线程回退
    measureContext = document.createElement('canvas').getContext('2d')!
    return measureContext
  }

  throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.')
}

Canvas 大小是 1×1——不需要真正绘制,只是借用 measureText() 这个 API。

3b. 两级缓存结构

// 外层:font 字符串 → 内层 Map
// 内层:segment 文字 → SegmentMetrics
const segmentMetricCaches = new Map<string, Map<string, SegmentMetrics>>()

缓存 key 是 (font, segment) 组合,例如 ("16px Inter", "Hello")。 同一段文字 + 同一字体只调用一次 measureText(),之后永久命中缓存。

3c. 核心测量函数

export function getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): SegmentMetrics {
  let metrics = cache.get(seg)
  if (metrics === undefined) {
    const ctx = getMeasureContext()
    metrics = {
      width: ctx.measureText(seg).width,  // 唯一一次 Canvas API 调用
      containsCJK: isCJK(seg),            // 标记是否含 CJK,影响断行策略
    }
    cache.set(seg, metrics)
  }
  return metrics
}

SegmentMetrics 是按需填充的,其余字段只在需要时才计算:

type SegmentMetrics = {
  width: number                          // measureText() 返回的原始宽度
  containsCJK: boolean                   // 是否含 CJK 字符
  emojiCount?: number                    // Emoji 个数(用于修正宽度)
  graphemeWidths?: number[] | null       // 每个字形的宽度(overflow-wrap 断词用)
  graphemePrefixWidths?: number[] | null // 字形宽度前缀和(二分查找用)
}
字段何时计算
width / containsCJK首次 measureText() 时立即填充
emojiCount需要 Emoji 宽度修正时(含 Emoji 的 segment)
graphemeWidths词宽超过容器、需要词内强制断行时
graphemePrefixWidthsSafari 下二分查找断行点时(preferPrefixWidthsForBreakableRuns

这种懒加载设计保证了大多数普通文本只付出最低开销:每个 segment 仅调用一次 measureText(),字形级宽度仅在真正需要断词时才触发。

3d. graphemeWidths 与 graphemePrefixWidths 的计算

当一个词超出容器宽度、需要在词内强制断行时,才会触发字形级宽度的计算:

export function getSegmentGraphemeWidths(seg, metrics, cache, emojiCorrection): number[] | null {
  if (metrics.graphemeWidths !== undefined) return metrics.graphemeWidths  // 缓存命中

  const widths: number[] = []
  const graphemeSegmenter = getSharedGraphemeSegmenter()  // Intl.Segmenter granularity:'grapheme'
  for (const gs of graphemeSegmenter.segment(seg)) {
    const graphemeMetrics = getSegmentMetrics(gs.segment, cache)
    widths.push(getCorrectedSegmentWidth(gs.segment, graphemeMetrics, emojiCorrection))
  }

  // 单字符词不需要存(没有"词内断行"的意义)
  metrics.graphemeWidths = widths.length > 1 ? widths : null
  return metrics.graphemeWidths
}

前缀和数组用同样的方式构建,区别在于每次测量累积前缀字符串而非单个字形:

export function getSegmentGraphemePrefixWidths(seg, metrics, cache, emojiCorrection): number[] | null {
  if (metrics.graphemePrefixWidths !== undefined) return metrics.graphemePrefixWidths

  const prefixWidths: number[] = []
  let prefix = ''
  for (const gs of graphemeSegmenter.segment(seg)) {
    prefix += gs.segment
    const prefixMetrics = getSegmentMetrics(prefix, cache)  // 测量 "H", "He", "Hel"...
    prefixWidths.push(getCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection))
  }

  metrics.graphemePrefixWidths = prefixWidths.length > 1 ? prefixWidths : null
  return metrics.graphemePrefixWidths
}

为什么测前缀字符串而不是直接累加单字形宽度? 因为字体有字距调整(kerning)"Te" 的实际渲染宽度可能小于 width("T") + width("e"), 测整体前缀可以拿到和浏览器完全一致的累积宽度。

第四步:Emoji 宽度修正

macOS 上 Canvas measureText() 对 Emoji 的宽度虚报(比实际 DOM 渲染更宽), Pretext 在每个字体大小上做一次一次性校正:

function getEmojiCorrection(font: string, fontSize: number): number {
  let correction = emojiCorrectionCache.get(font)
  if (correction !== undefined) return correction  // 已校正过,直接返回

  const ctx = getMeasureContext()
  ctx.font = font
  const canvasW = ctx.measureText('\u{1F600}').width  // Canvas 测量值

  correction = 0
  if (canvasW > fontSize + 0.5 && typeof document !== 'undefined') {
    // 插入不可见 <span>,拿到 DOM 实际渲染宽度
    const span = document.createElement('span')
    span.style.cssText = `font:${font};display:inline-block;visibility:hidden;position:absolute`
    span.textContent = '\u{1F600}'
    document.body.appendChild(span)
    const domW = span.getBoundingClientRect().width
    document.body.removeChild(span)

    if (canvasW - domW > 0.5) correction = canvasW - domW  // 记录差值
  }

  emojiCorrectionCache.set(font, correction)
  return correction
}

校正时从 segment 宽度中减去 emojiCount × correction

export function getCorrectedSegmentWidth(seg, metrics, emojiCorrection): number {
  if (emojiCorrection === 0) return metrics.width
  return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection
}

注意这里故意插了一次 DOM——但只插一次,之后所有 Emoji 宽度计算都用这个缓存的校正值。

Canvas 缓存小结

prepare() 阶段  →  measureText() 结果写入两级 Map  →  永久缓存
layout()  阶段  →  只读缓存,做纯算术,0Canvas/DOM API 调用

(font, segment) 二元组作为缓存 key,保证跨多次 layout() 调用时不重复测量。 字形级宽度和 Emoji 校正值也都只计算一次,后续全部命中缓存——这是 layout() 跑到 0.09ms 的核心原因。

第五步:浏览器差异适配

export function getEngineProfile(): EngineProfile {
  // Node.js 环境没有 navigator,返回保守默认值
  if (typeof navigator === 'undefined') {
    return { lineFitEpsilon: 0.005, carryCJKAfterClosingQuote: false, ... }
  }

  const ua = navigator.userAgent
  const isSafari = navigator.vendor === 'Apple Computer, Inc.' &&
    ua.includes('Safari/') && !ua.includes('Chrome/') && ...
  const isChromium = ua.includes('Chrome/') || ua.includes('Chromium/') || ...

  return {
    lineFitEpsilon: isSafari ? 1 / 64 : 0.005,
    //  ↑ 判断一行文字是否"刚好放得下"时的浮点容差
    //    Safari 字宽计算精度是 1/64px,Chromium 是 0.005px

    carryCJKAfterClosingQuote: isChromium,
    //  ↑ 引号后紧跟 CJK 字符时的禁则进位行为,两个引擎不一致

    preferPrefixWidthsForBreakableRuns: isSafari,
    //  ↑ Safari 的 kerning 使单字形累加不准,需要用前缀宽度做二分

    preferEarlySoftHyphenBreak: isSafari,
    //  ↑ 软连字符的断行时机,Safari 偏好更早断
  }
}

这个 profile 只检测一次,结果缓存在 cachedEngineProfile,后续所有 layout() 调用共用。

3.3 阶段 2 细节:layout() 做了什么

源码路径:src/line-break.ts

准备阶段生成了 PreparedLineBreakData

// src/line-break.ts
type PreparedLineBreakData = {
  widths: number[]                         // 每个分词的宽度
  lineEndFitAdvances: number[]             // 行末 fit 宽度(不含尾部空格)
  lineEndPaintAdvances: number[]           // 行末 paint 宽度(含 overflow)
  kinds: SegmentBreakKind[]                // 每段的类型
  simpleLineWalkFastPath: boolean          // 是否走快速路径
  breakableWidths: (number[] | null)[]     // 可断词的字形宽度
  breakablePrefixWidths: (number[] | null)[]
  discretionaryHyphenWidth: number         // 可选连字符宽度
  tabStopAdvance: number                   // Tab 制表位间距
  chunks: { ... }[]                        // 强制换行分块
}

关键优化:simpleLineWalkFastPath

对于大多数普通文本(无 Tab、无软连字符、无强制换行),走一条更简单的代码路径:

// src/line-break.ts
if (prepared.simpleLineWalkFastPath) {
  return walkPreparedLinesSimple(prepared, maxWidth, onLine)  // 快速路径
} else {
  return walkPreparedLinesFull(prepared, maxWidth, onLine)    // 完整路径
}

layout 阶段不调用任何浏览器 API,只做加减法和数组索引——这是 0.09ms 的秘密。


四、API 全貌

4.1 四个层次,按需取用

import {
  prepare, prepareWithSegments,
  layout, layoutWithLines,
  walkLineRanges,
  layoutNextLine,
  clearCache, setLocale,
} from '@chenglou/pretext'

4.2 第一层:快速高度测量

适用场景: 虚拟列表行高预测、判断文本是否需要截断

const prepared = prepare('AGI 春天到了', '16px Inter')
const { height, lineCount } = layout(prepared, 320, 20)
// height: 像素高度(lineCount * lineHeight)
// lineCount: 折行后的行数

prepare() 返回的是不透明类型(Branded Type),内部结构不暴露——你只需要把它传给 layout()

4.3 第二层:获取每行文字(自定义渲染)

适用场景: Canvas 绘制文字、SVG 文字排版、WebGL 渲染

const prepared = prepareWithSegments('Hello world', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)

// 在 Canvas 上逐行绘制
const ctx = canvas.getContext('2d')
ctx.font = '18px "Helvetica Neue"'
for (let i = 0; i < lines.length; i++) {
  ctx.fillText(lines[i].text, 0, i * 26)
}

LayoutLine 包含:

type LayoutLine = {
  text: string    // 这一行的文字内容
  width: number   // 这一行的实际渲染宽度
  start: LayoutCursor
  end: LayoutCursor
}
type LayoutCursor = { segmentIndex: number; graphemeIndex: number }

4.4 第三层:零分配的游标遍历

适用场景: 对性能极度敏感,需要避免所有字符串分配

// 不构造字符串,直接操作游标索引
const lineCount = walkLineRanges(prepared, maxWidth, (startCursor, endCursor, lineWidth) => {
  // startCursor / endCursor 是 { segmentIndex, graphemeIndex }
  // 你可以用它们直接索引原始 segments 数组,而不创建子字符串
})

4.5 第四层:动态列宽(文字绕图)

适用场景: 文字绕图排列,类似 CSS float;或每行宽度动态变化的布局

// 例:文字绕过右侧图片区域
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0

while (true) {
  // 根据当前 y 坐标决定这行的可用宽度
  const width = (y < imageBottom) ? columnWidth - imageWidth : columnWidth
  const line = layoutNextLine(prepared, cursor, width)
  if (line === null) break

  ctx.fillText(line.text, 0, y)
  cursor = line.end
  y += lineHeight
}

4.6 其他实用 API

// 字体加载完成后清除缓存,确保新字体参与测量
clearCache()

// 设置语言区域,影响断行规则(例如区分简/繁体中文的标点行为)
setLocale('zh-CN')

// 支持 pre-wrap 模式(类似 textarea:保留空格、换行符生效)
const prepared = prepare(text, font, { whiteSpace: 'pre-wrap' })

// 诊断工具:分析各阶段耗时
const profile = profilePrepare(text, font)

五、语言与 Unicode 支持

5.1 Bidi(双向文字)

源码:src/bidi.ts,从 pdf.js 移植,实现完整的 Unicode Bidi Algorithm。

// 对每个字符进行 Bidi 分类
type BidiType = 'L' | 'R' | 'AL' | 'AN' | 'EN' | 'WS' | 'ON' | ...

// 计算字符级别的方向 levels(偶数=LTR,奇数=RTL)
computeBidiLevels(str: string): number[]

// 将字符级 level 映射到 prepare() 的分词边界上
computeSegmentLevels(normalized, segStarts): number[]

对于 prepareWithSegments() 返回的 lines,每个 LayoutLine 会附带 bidi levels, 供自定义渲染器按正确方向绘制混合文字。

5.2 CJK 禁则规则

src/analysis.ts 中维护了完整的 Unicode 禁则字符集:

// 不能出现在行首的字符(如:),。、】)
const kinsokuStart: Set<string>

// 不能出现在行尾的字符(如:(【『「)
const kinsokuEnd: Set<string>

// 左侧粘连标点(不和右侧内容分离)
const leftStickyPunctuation: Set<string>

// 引号字符集(影响 CJK 引号后的换行进位行为)
const closingQuoteChars: Set<string>

5.3 中/日/韩文字符逐字符断行

CJK 每个字符都可以独立换行,不需要词边界。Pretext 在 prepare() 阶段检测 CJK 字符后, 自动将其拆分为单字符粒度参与宽度缓存:

// src/analysis.ts
export function isCJK(s: string): boolean  // 检测 Unicode CJK/Hiragana/Katakana/Hangul 范围

六、Demo 演示

以下 Demo 均来自官方:chenglou.me/pretext

Demo 1:Accordion(折叠面板)

展示点: 精确预测折叠/展开后的高度,驱动 CSS transition——不展开就知道目标高度。

// 在点击"展开"时,用 prepare + layout 提前计算展开后高度
// 直接设置 max-height: ${height}px 配合 transition
const { height } = layout(prepare(content, font), containerWidth, lineHeight)
el.style.maxHeight = `${height}px`

Demo 2:Masonry(瀑布流)

展示点: 渲染前批量测量所有卡片高度,精确分配到最短列,避免渲染后的二次调整。

const prepared = cards.map(text => prepare(text, '14px Inter'))
// layout 极快,可在同步代码中批量计算
const heights = prepared.map(p => layout(p, cardWidth, 20).height)

Demo 3:Editorial Engine(编辑排版引擎)

展示点: 多栏杂志式排版,文字实时 60fps 重排,标题自动绕过装饰元素。 使用 layoutNextLine API,每列列宽可动态变化。

Demo 4:Justification Compared(断行算法对比)

展示点: 并排对比三种断行策略:

策略说明效果
CSS 默认(贪心算法)每行尽量塞满行尾参差不齐
加连字符(hyphenation)单词末尾加 - 拆分略好
Knuth-Plass 最优算法全局最小化行间距差异最均匀,LaTeX 同款

Pretext 通过暴露游标级 API,让用户自行实现 Knuth-Plass 算法,而不耦合具体排版策略。


七、实际应用场景

场景 1:虚拟列表精确行高

// 预计算所有行高(一次性 prepare,多次 layout)
const prepared = messages.map(msg => prepare(msg.text, '14px Inter'))
const rowHeights = prepared.map(p => layout(p, listWidth, 20).height + PADDING)

// 虚拟列表滚动时直接查表,不再触碰 DOM
function getItemHeight(index: number) { return rowHeights[index] }

场景 2:AI 生成 UI 的服务端布局验证

// Node.js 环境(需要 Canvas 实现,如 node-canvas)
import { createCanvas } from 'canvas'
// Pretext 可在 Node.js 中使用,以验证 LLM 生成的 UI 不会溢出
const { lineCount } = layout(prepare(aiGeneratedText, font), containerWidth, lineHeight)
if (lineCount > maxLines) trimText(aiGeneratedText)

场景 3:防止 CLS(累积布局偏移)

服务端渲染时提前计算高度,在 HTML 中写入 style="height: Xpx", 浏览器渲染时不发生跳动,Google Core Web Vitals 得分不受影响。

场景 4:Canvas / WebGL 渲染引擎

// Canvas 游戏 UI、数据大屏、PDF 生成
const { lines } = layoutWithLines(prepare(text, font), boxWidth, lineHeight)
lines.forEach((line, i) => {
  ctx.fillText(line.text, x, y + i * lineHeight)
})

八、局限性与注意事项

8.1 已知精度问题

问题场景原因
macOS system-ui 精度略低使用系统默认字体时macOS OS 级字形渲染与 Canvas 测量存在差异
极窄容器触发字形级断行容器宽度小于单个词回退到逐字符(grapheme)断行
字体未加载时结果不准自定义 Web Font需确保字体加载完成后再调用 prepare()

8.2 使用注意

// 字体更换后记得清缓存
document.fonts.ready.then(() => {
  clearCache()
  // 重新 prepare
})

// Pretext 专注排版逻辑,不负责字形绘制
// 它告诉你文字在哪儿,具体画出来仍然需要 Canvas / SVG / DOM

8.3 不支持的 CSS 特性(当前版本 0.0.4)

  • word-break: keep-all(中文不断行)
  • writing-mode: vertical-*(竖排文字)
  • letter-spacing / word-spacing
  • 复杂 CSS 嵌套行内元素(需用 prepareWithSegments 手动拼接)

九、与现有方案对比

方案是否触发 ReflowNode.js 可用多语言完整支持多行支持备注
DOM getBoundingClientRect最常见,最慢
Canvas measureText(手写换行)部分手写需自行处理 Unicode
opentype.js / fontkit手写需加载字体文件
Pretext完整内置需要浏览器 Canvas 或 node-canvas

关键差异:

  • opentype.js:操作字体文件字形,精度最高,但需下载字体文件,适合 PDF 生成
  • Pretext:借助 Canvas 的字体渲染,和浏览器显示高度一致,适合 Web UI 场景

十、总结

Pretext 的核心价值

prepare() 一次  →  canvas 测量结果永久缓存
layout()  N次   →  纯数学,任何环境,极致性能

它解决的问题:

  • 消除批量文本测量引发的 Layout Reflow
  • 让"在渲染前知道文本高度"变得真正可行
  • 把精确的文字排版能力带出浏览器(Node.js、Worker、WebGL)

值得关注的信号:

  • 作者 Cheng Lou 在 React 生态有极强的工程判断力
  • 31k Stars + HN 380 分——社区已经验证了痛点的真实性
  • 发布时间短,API 仍在演进,可保持关注

我们的项目有哪些场景可以用?

开放讨论:我们的消息列表、Feed 流、或 Canvas 报表有哪些地方受益?


十一、Q & A

Q:缓存会不会无限增长?有没有 LRU / TTL?

没有。缓存是一个朴素的 Map,不会自动淘汰。 设计前提是:一个应用中使用的 (font, segment) 组合数量是有限且收敛的——普通文本里词汇高度重复,缓存命中率极高,总条目数通常不大。 极端场景(如代码编辑器、每帧随机文字)可能导致缓存膨胀,需要手动定期调用 clearCache() 来重置。这是当前版本的已知权衡。


Q:和浏览器真实渲染差多少?号称 pixel-perfect 有依据吗?

测量数据来自同一个 Canvas 渲染引擎,理论上和浏览器"看到"的字宽一致。已知精度问题有两处:

  • macOS system-ui:OS 级字形渲染与 Canvas 存在亚像素差异,行数预测可能偏差 1 行
  • 浮点容差:Safari 精度是 1/64px,Chromium 是 0.005px,lineFitEpsilon 按引擎分别配置

Emoji 的偏差通过一次性 DOM 校正消除。对常规正文字体,实测误差可以做到 0 像素差。


Q:为什么不直接用 TextMetrics.actualBoundingBoxAscent/Descent

measureText() 返回的 TextMetrics 给的是单行文字的 bounding box,无法处理换行。 你仍然需要自己实现完整的断行算法(空白规范化、Unicode 分词、CJK 禁则、soft-hyphen……),那正好就是 Pretext 本身。measureText() 是 Pretext 内部的一个工具,而不是替代品。


Q:字体还没加载完就调用 prepare(),结果会不会不准?

会。字体未加载时,Canvas 会回退到系统默认字体测量,结果偏差可能很大。 正确做法:

await document.fonts.ready  // 等所有已声明字体加载完
// 或针对特定字体:
await document.fonts.load('16px Inter')

clearCache()               // 清除旧测量结果
const prepared = prepare(text, '16px Inter')

动态加载新字体后同样需要 clearCache()


Q:版本 0.0.4,能上生产吗?

底层依赖的 Canvas.measureText() 是成熟稳定的浏览器 API,核心算法没有风险。 主要不确定因素是公开 API 可能仍在调整(函数签名、返回值结构),升级时需关注 changelog。 建议:非核心链路、或新开发的模块可以直接用;存量代码做好隔离封装,留出升级空间。


参考资料