一、背景:文本测量的老大难问题
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 整体架构
┌────────────────────────────────────────────────────────────────┐
│ 阶段 1:prepare(text, font) │
│ │
│ 文本 → [Unicode 分词] → [Canvas.measureText 缓存] │
│ → [空白规范化] → [Emoji 宽度修正] │
│ → PreparedText(不透明对象,内含缓存数据) │
│ ↑ │
│ 一次性,约 19ms/500条 │
└────────────────────────────────────────────────────────────────┘
↓ PreparedText 可复用,多次 layout
┌────────────────────────────────────────────────────────────────┐
│ 阶段 2:layout(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 = null
function 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 字段时要跳过 text、isWordLike、start:
[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 |
词宽超过容器、需要词内强制断行时 |
graphemePrefixWidths |
Safari 下二分查找断行点时(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() 阶段 → 只读缓存,做纯算术,0 次 Canvas/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 手动拼接)
九、与现有方案对比
| 方案 |
是否触发 Reflow |
Node.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。 建议:非核心链路、或新开发的模块可以直接用;存量代码做好隔离封装,留出升级空间。
参考资料