给你一段文本和一个容器宽度,怎么知道它会占几行?
传统做法是创建 DOM 元素 → 设置文本内容 → 渲染 → 调用 getBoundingClientRect() 读取高度。这个流程会触发layout reflow ------浏览器渲染管线里面性能消耗最大的操作之一。
DOM 测量的代价
你可能见过浏览器控制台的这个警告:
csharp
[Violation] Forced reflow while executing JavaScript took 47ms
这就是强制回流:JavaScript 查询几何属性(offsetWidth、clientHeight)时,如果 DOM 状态已经改了,浏览器被迫同步重算样式和布局。
而且布局几乎总是作用于整个文档,元素多了确定位置和尺寸就很慢。有测试显示每帧花超过 28ms 在布局上,而流畅动画要求 16ms 内完成一帧。
在循环里频繁读写 DOM,浏览器会反复计算整个页面的布局,这就是布局抖动(layout thrashing)。
虚拟列表的"鸡生蛋"问题
react-virtualized 有个 issue:动态高度虚拟列表在滚动到最底部后往上滚,单元格会"跳跃"。
原因是列表从底部加载新内容时,之前渲染的项目高度变了,但滚动位置没跟着调。维护者的回应让人意外:
"I don't know a way to avoid this"
一个几万 star 的库,直接说"不知道怎么避免"。不过这倒不是开发者技术不行,而是根本性的架构问题:虚拟列表需要预先知道每个项目的高度才能正确渲染,但文本内容的高度只有渲染后才能测量。经典的"鸡生蛋"问题。
现在的选择就是:要么预先渲染所有项(卡顿),要么估算高度(不准确,滚动条跳动)。
Canvas 测量也不是银弹
既然 DOM 测量这么慢,用 Canvas 的 measureText() 行不行?
Recharts 也遇到过这个问题------刻度太多时性能下降,原因是计算刻度可见性时频繁调用 Canvas 测量。最后的优化方案是减少 DOM 测量,实现了 1.8 倍加速,Canvas 文本测量也不是银弹。
Ejecta 项目甚至直接开了个 issue 叫 "measureText is slow",开发者说只能靠缓存已测量的字符串结果来绕过。
Pretext 的思路:用计算换 I/O
Pretext 的核心思路是用纯算术计算代替 DOM 测量,文本布局完全不碰 DOM,性能提升 50-100 倍。
两阶段设计:
- prepare(): 一次性重工作(文本分析 + Canvas 测量 + 缓存)
- layout(): 纯算术换行计算(零 DOM、零分配、零 I/O)
性能数据:
prepare(): 18.85ms / 500 文本(一次性)layout(): 0.09ms / 500 文本(快 210 倍)- DOM batch: 4.05ms(慢 45 倍)
- DOM interleaved: 43.50ms(慢 483 倍)
核心设计
两阶段分离
Pretext 的核心思路是把繁重的预处理和轻量的布局计算分开:
typescript
// 一次性预处理
const prepared = prepare('这是一段文本', '16px Inter')
// resize 时反复调用,零 DOM 访问
const { height, lineCount } = layout(prepared, 300, 20)
const newResult = layout(prepared, 400, 20) // 改变宽度
prepare() 做什么?
- 空白归一化(CSS
white-space语义) - 智能分词(使用
Intl.Segmenter) - 合并规则(标点附着、URL 保持完整等)
- Canvas 测量每个片段的宽度
- 三级缓存(字体 → 片段 → 字素)
layout() 做什么?
- 纯算术计算:累加宽度,判断换行
- 零 I/O:不读 DOM,不调 Canvas
- 零分配:不创建字符串、数组、对象
- 零回溯:贪婪算法 + pending break 机制
性能对比
| 操作 | 耗时 | 相对速度 | 说明 |
|---|---|---|---|
prepare() |
18.85ms | 1x | 一次性预处理(500 文本) |
layout() |
0.09ms | 210x | 纯算术计算(500 文本) |
| DOM batch | 4.05ms | 45x 慢 | 批量 DOM 测量 |
| DOM interleaved | 43.50ms | 483x 慢 | 交错读写 DOM |
实际意义:
- 实时 resize 不再卡顿:0.09ms vs 43.50ms,流畅度提升 483 倍
- 虚拟列表可以预知高度,无需渲染即可测量
- Canvas 动态布局成为可能,游戏 UI、图表标签不再是瓶颈
- 瀑布流可以实时计算,不需要预先渲染
Pretext 的牛逼之处
Pretext 本质上是用 JavaScript 把浏览器的文本布局引擎重新实现了一遍,但只暴露必要的接口。核心不是"更快",而是"更聪明"------预处理阶段完成所有复杂工作(分词、测量、缓存),布局阶段只做纯数值计算,再利用三级缓存避免重复测量。
与现有方案对比
| 方案 | 准确性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| DOM 测量 | ✅ 高 | ❌ 慢 | ✅ 简单 | 静态页面 |
| Canvas 测量 | ✅ 高 | ⚠️ 中 | ⚠️ 中 | 图表、游戏 |
| 预估高度 | ❌ 低 | ✅ 快 | ✅ 简单 | 虚拟列表(妥协) |
| Pretext | ✅ 高 | ✅ 极快 | ⚠️ 高 | 通用 |
Pretext 既准确又快,代价是实现更复杂。对于频繁 resize、虚拟列表、Canvas 渲染这些场景,这个代价是值得的。
技术细节
文本分析:从字符到片段
Pretext 的文本分析阶段比较复杂,包括:
1. 空白归一化
根据 CSS white-space 模式:
normal: 合并连续空格、Tab、换行为单个空格pre-wrap: 保留空格、Tab、硬换行
2. 智能分词
使用 Intl.Segmenter 进行语言感知的分词:
- 英文:按词分(
"Hello world"→["Hello", " ", "world"]) - 中文:按字分(
"你好世界"→["你", "好", "世", "界"]) - 泰语:按词边界分(泰语没有空格,需要词典分词)
3. 高级合并规则
这些规则是 Pretext 准确性的关键:
| 规则 | 输入 | 输出 | 原因 |
|---|---|---|---|
| 标点附着 | "word" + "." |
"word." |
避免句号前换行 |
| URL 合并 | "https:" + "/" + "/" |
"https://..." |
保持 URL 完整 |
| 数字序列 | "7" + ":" + "00" |
"7:00" |
时间/日期保持完整 |
| NBSP 粘合 | "word" + NBSP |
"word" + glue |
防止在 NBSP 处换行 |
4. CJK 禁则处理
中日韩语言有特殊的排版规则------行首禁则(逗号、句号、感叹号不能出现在行首)和行尾禁则(左括号、引号不能出现在行尾),确保文本换行符合东亚排版习惯。
换行算法:为什么不用 Knuth-Plass
Knuth-Plass 算法是 TeX 排版系统用的最优换行算法,能产生最均匀的词间距。但浏览器不用它,原因不是性能。
CSS 规范要求浮动元素必须"尽可能高"定位,而 Knuth 换行算法可能导致某个词不是尽可能高的。唯一能满足这个规范的算法是贪婪算法。浏览器用贪婪算法不是性能问题,而是规范约束。
Pretext 也选了贪婪算法,原因有三:
- 性能:O(n) vs O(n²),快几个数量级
- 浏览器一致性:与 CSS 行为对齐
- 实用性:大多数场景下贪婪算法的质量足够好
实际对比:Knuth-Plass 9 行 vs 浏览器贪婪 10 行,质量差异不大,性能差异很大。
快速路径优化
Pretext 内部有个 simpleLineWalkFastPath 标志:
typescript
if (prepared.simpleLineWalkFastPath) {
// 简单算法:只处理 text + space
return countPreparedLinesSimple(prepared, maxWidth)
}
// 完整算法:处理软连字符、Tab、Glue、硬换行等
return walkPreparedLines(prepared, maxWidth, onLine)
满足这些条件时走快速路径:不含 soft-hyphen、tab、glue(NBSP 等)、hard-break(\n)。简单文本直接跳过复杂逻辑,提升性能。
游标系统:精确定位与流式布局
游标系统是 Pretext 最有创新性的设计之一:
typescript
type LayoutCursor = {
segmentIndex: number // 第几个片段
graphemeIndex: number // 片段内的第几个字素
}
为什么需要两层索引?单层索引无法定位到片段内部的字符。比如一个包含 10 个汉字的片段,没法指定第 5 个字,两层索引解决了这个问题。
游标系统支持流式布局和变宽布局:
typescript
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
while (true) {
// 每行可以有不同的宽度
const width = y < imageHeight ?
columnWidth - imageWidth : columnWidth
const line = layoutNextLine(prepared, cursor, width)
if (!line) break
renderLine(line, y)
cursor = line.end
y += lineHeight
}
这段代码实现了文本绕图流动:图片上方行宽较小,图片下方恢复全宽。传统 CSS 实现需要复杂的布局技巧,Pretext 几行代码搞定。
Emoji 修正:浏览器 bug 的工程解法
不同浏览器的 Canvas 文本测量结果有差异,尤其是 Emoji:字号小于 24px 时 Canvas 测量的 emoji 宽度比 DOM 宽,原因是 Apple Color Emoji 字体的 Canvas 测量 bug。
Pretext 的解决方案:
- 检测修正量:对比 Canvas vs DOM 测量,计算差值
- 缓存修正量:每个字号只检测一次
- 应用修正 :
correctedWidth = canvasWidth - emojiCount × correction
关键发现:修正量只跟字号相关,跟字体无关,修正量可以缓存复用。
零分配热路径
layout() 的设计目标:
- ❌ 不创建字符串、数组、对象
- ❌ 不读取 DOM
- ❌ 不调用 Canvas
- ✅ 纯数值计算
并行数组 vs 对象数组:
typescript
// ✅ 并行数组(缓存友好)
widths: number[] // [42.5, 4.4, 37.2]
kinds: SegmentBreakKind[] // ['text', 'space', 'text']
// ❌ 对象数组(指针追踪)
segments: { width: number, kind: SegmentBreakKind }[]
并行数组的内存布局是连续的,CPU 缓存命中率高,V8 的隐藏类优化更好,这是 Pretext 性能的底层保障。
解锁新的 UI 可能性
高性能虚拟列表
传统方案先渲染再测量(卡顿),估算方案不准确(滚动条跳动),react-virtualized 维护者直接说 "I don't know a way to avoid this"。
Pretext 方案:
typescript
// 列表渲染前,预先测量所有项
const items = data.map(item => ({
...item,
prepared: prepare(item.text, '14px Inter')
}))
// 滚动时,实时计算可见范围
function onScroll() {
const visibleItems = items.filter(item => {
const { height } = layout(item.prepared, containerWidth, 20)
return isItemVisible(item, height)
})
// 只渲染可见项,零强制回流
}
1000 项的长列表,滚动时每帧只需 0.09ms。
流式布局:变宽与绕图
图文混排,文字绕着图片流动,每行宽度不同:
typescript
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
while (cursor.segmentIndex < prepared.widths.length) {
const lineWidth = y < imageHeight ?
columnWidth - imageWidth : columnWidth
const line = layoutNextLine(prepared, cursor, lineWidth)
if (!line) break
renderLine(line, y)
cursor = line.end
y += lineHeight
}
类似 Notion、Figma 的复杂布局,Web 原生就能实现。
Canvas 富文本渲染
Recharts 刻度性能问题的根源是频繁调用 measureText(),加上 Chromium 的字体切换成本也高。
Pretext 的方案:
typescript
// 预处理阶段:批量测量,利用缓存
const prepared = prepareWithSegments(longText, '16px Inter')
// 渲染阶段:零 Canvas 测量
const { lines } = layoutWithLines(prepared, canvasWidth, 20)
lines.forEach((line, i) => {
ctx.fillText(line.text, 0, i * 20) // 直接绘制
})
图表、游戏、可视化应用的性能瓶颈可以被打破。Recharts 如果用 Pretext,性能至少提升 1.8 倍。
紧凑布局(Shrinkwrap)
聊天气泡、标签、卡片需要找到最紧凑的容器宽度:
typescript
// 二分搜索最优宽度
let min = 100, max = 500
while (max - min > 1) {
const mid = (min + max) / 2
const { lineCount } = layout(prepared, mid, 20)
if (lineCount <= 2) max = mid // 最多 2 行
else min = mid
}
const optimalWidth = max // 最紧凑的宽度
WhatsApp、WeChat 的聊天气泡就是典型应用。
双栏流式排版
报纸、杂志、电子书的连续流动排版:
typescript
const columns = [
{ x: 0, width: 300 },
{ x: 320, width: 300 }
]
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let currentCol = 0
while (cursor.segmentIndex < prepared.widths.length) {
const col = columns[currentCol]
const line = layoutNextLine(prepared, cursor, col.width)
if (!line) break
if (line.end.segmentIndex >= prepared.widths.length * 0.5 && currentCol === 0) {
currentCol = 1
}
renderLine(line, col.x)
cursor = line.end
}
Flow ePub 阅读器就是这种需求。
性能与权衡
性能数据
Chrome 基准测试(500 文本批次):
| 操作 | 耗时 | 说明 |
|---|---|---|
prepare() |
18.85ms | 一次性预处理 |
layout() |
0.09ms | 纯算术,快 210 倍 |
| DOM batch | 4.05ms | 慢 45 倍 |
| DOM interleaved | 43.50ms | 慢 483 倍 |
不同语言的性能差异:
| 语言 | prepare() 耗时 | 片段数 | 原因 |
|---|---|---|---|
| 中文 | 6.10ms | 5,433 → 7,949 | 更多片段 |
| 泰语 | 13.50ms | 10,281 | 无空格分词 |
| 阿拉伯语 | 63.50ms | 37,603 | RTL + 连字 |
设计权衡
贪婪算法 vs 最优断行 --- 0.09ms vs 数百 ms,9 行 vs 10 行,差异不大,选择性能优先。
预处理成本 vs 布局速度 --- 一次重 19ms,多次轻 0.09ms。适合 resize 频繁、虚拟列表、Canvas 渲染的场景,不适合一次性渲染的静态页面。
并行数组 vs 对象数组 --- 内存布局优化、缓存友好,代价是代码可读性下降,需要索引对应。
当前限制
不支持的 CSS 配置:
- ❌
word-break: break-all/keep-all - ❌ 自动连字符(hyphenation,仅支持软连字符)
- ❌
line-break: strict/loose/anywhere - ❌
overflow-wrap: anywhere
已知问题:
- ⚠️
system-ui字体的 macOS 陷阱(Canvas 和 DOM 解析不同) - ⚠️ 需要
Intl.Segmenter支持(现代浏览器都支持)
最后
pretext 这项目确实有点意思,以上都是我一些初步的探索和了解,如有错误,欢迎指正
参考文献:
- DebugBear. (2022). How To Fix Forced Reflows And Layout Thrashing . debugbear.com/blog/forced...
- web.dev. Avoid large, complex layouts and layout thrashing . web.dev/articles/av...
- react-virtualized Issue #610. (2018). github.com/bvaughn/rea...
- vue-virtual-scroller Issue #767. (2021). github.com/Akryum/vue-...
- Recharts Issue #3983. (2021). github.com/recharts/re...
- W3C CSSWG Issue #3756. Float positioning "as high as possible" prohibits non-greedy line-breaking . github.com/w3c/csswg-d...
- tldraw Issue #7377. (2023). Batch measurement optimization . github.com/tldraw/tldr...
- bramstein/typeset. TeX line breaking algorithm in JavaScript . github.com/bramstein/t...
- Figma Blog. (2021). How we figured out canvas virtualization . www.figma.com/blog/how-we...
- GitHub Issue #427. (2024). Canvas text rendering and metrics (2024 edition) . github.com/web-platfor...