> *"如果你不能用简单的语言解释一件事,说明你还没真正理解它。"* —— 理查德·费曼
---
第一章:一个看似简单的问题
让我们从一个看似再简单不过的问题开始:测一段文字有多高。
想象一下,你在纸上写下一段话。你不需要任何复杂的工具——只要看一眼,就能大致知道这段文字会占多少空间。它有多宽、有多高,几乎是一种直觉。
但把这个场景搬到浏览器里,事情就变得诡异起来。
你可能会想:"这不就是获取一个 DOM 元素的高度吗?用 offsetHeight 或者 getBoundingClientRect() 不就行了?"
问题就出在这里。当你调用这些方法时,浏览器内部发生了什么?答案是:一场灾难。
---
第二章:Reflow 的本质——浏览器里的蝴蝶效应
2.1 那场昂贵的同步对话
让我用一个比喻来解释 Reflow 是什么。
想象你走进一家繁忙的餐厅。服务员正在根据客人的位置、桌子的摆放、过道的宽度来安排座位。突然,你走到餐厅中央,大声问:"请问第三桌到厨房的距离是多少?"
服务员必须立刻停下手中所有的工作,重新测量整个餐厅的布局,才能回答你这个问题。更糟的是,当你得到答案后,服务员必须从头开始重新安排之前被打断的工作。
这就是 Forced Synchronous Layout(强制同步布局) 的本质。
当你调用 getBoundingClientRect() 时,浏览器必须:
1. 暂停 JavaScript 的执行 2. 刷新所有待处理的样式变更 3. 重新计算整个文档的布局(即使你的改动只影响了一个小角落) 4. 返回你想要的那个数字 5. 允许 JavaScript 继续执行
2.2 性能地狱的真实数字
让我们看看这到底有多贵。
假设你有一个虚拟滚动列表,里面有 500 个文本块。每个块都需要知道自己的高度。传统的做法是:
items.forEach(item => {
const el = renderItem(item); // 写入 DOM
const height = el.offsetHeight; // 读取 → 触发 Reflow!
heights.push(height);
});
发生了什么?每一次读取都会强制浏览器重新计算布局。500 次读取意味着 500 次重排。在一个复杂的文档中,这可能消耗 30 毫秒或更多。
但等等——一帧只有 16.6 毫秒(60fps)。30 毫秒意味着你直接丢了两帧。用户看到的不是流畅的滚动,而是卡顿。
这就像你为了知道一本书的厚度,不得不把整间图书馆重新整理一遍。
2.3 为什么浏览器会这样?
这是一个合理的问题。为什么浏览器不能聪明一点,只计算你关心的那个元素?
答案是:CSS 的连锁依赖。在 CSS 中,一个元素的大小可能依赖于其父元素、兄弟元素,甚至子元素。当你改变或查询任何一个元素时,浏览器必须确保整个布局是一致的,否则返回给你的数字可能是错误的。
这就像你不能只计算一块积木的位置而不考虑整座积木塔的稳定性。
---
第三章:Cheng Lou 的洞察——算术的力量
3.1 那个关键问题
Cheng Lou(chenglou)是 React 核心团队的成员,也是 ReasonML 的作者。他在 Midjourney 工作时遇到了一个棘手的问题:AI 生成的内容以流式方式到达,UI 需要实时知道每个文本块的高度来调整滚动位置。
传统的 DOM 测量方法在这种场景下完全不可行。
于是 Cheng Lou 问了一个看似天真的问题:
> "我们真的需要浏览器来告诉我们文本有多高吗?"
3.2 算术的纯粹性
让我们回到那个朴素的想法:如果你知道每个字有多宽,你难道不能自己算出文本会占多少行吗?
假设你有一段文字:"Hello World",每个字母的宽度已知:
- H: 8px
- e: 7px
- l: 3px
- l: 3px
- o: 8px
- (空格): 4px
- W: 11px
- o: 8px
- r: 6px
- l: 3px
- d: 8px
如果你的容器宽度是 100px,这段文字显然只占一行。
如果你的容器宽度是 50px,你需要在合适的位置断行。"Hello" 的总宽度是 8+7+3+3+8 = 29px,加上空格 4px 是 33px,小于 50px。但加上 "W" 的 11px 就超过了,所以 "Hello" 在第一行,"World" 在第二行。
这就是 纯算术。
没有 DOM,没有 Reflow,没有浏览器介入。只有简单的加法和比较。
3.3 两阶段架构的诞生
但这个想法有一个问题:测量每个字符的宽度本身是一次性的、昂贵的操作。你不能每次都重新测量。
Cheng Lou 的天才之处在于将这个过程拆分为两个阶段:
第一阶段:Prepare(准备)
prepare(text, font) → PreparedText
这个阶段做所有昂贵的工作一次:
- 将文本分割成有意义的片段(单词、字素)
- 使用 Canvas 的
measureText()测量每个片段的宽度 - 缓存所有测量结果
layout(prepared, maxWidth, lineHeight) → { height, lineCount }
这个阶段只做纯算术:
- 遍历缓存的宽度
- 累加直到超过 maxWidth
- 统计行数
- 返回高度
这就像你先花点时间把食材都切好准备好(Prepare),之后炒菜的时候只需要简单的翻炒(Layout),而不是每次都从头开始处理食材。
---
第四章:技术深潜——走进 Pretext 的内部
4.1 Intl.Segmenter:分词的魔法
现在让我们深入技术细节。第一个问题是:如何把一段文本分割成可以独立测量的片段?
对于英语,这似乎很简单——按空格分割单词就行了。但对于其他语言呢?
- 中文/日文/韩文(CJK):没有空格分隔,你需要知道在哪里可以断行
- 泰语:没有空格,而且需要词典来正确分词
- 阿拉伯语:从右到左书写,字母在词首、词中、词尾有不同的形态
- Emoji:👨👩👧👦 看起来是一个字符,实际上是由多个 Unicode 码点组成的
const segmenter = new Intl.Segmenter('zh-CN', { granularity: 'word' });
const segments = Array.from(segmenter.segment('AGI 春天到了'));
// 结果: ['AGI', ' ', '春天', '到了']
Pretext 使用 Intl.Segmenter 来:
1. 识别字素边界(Grapheme):确保 👨👩👧👦 被当作一个整体,而不是四个独立字符
2. 识别单词边界:处理英语、CJK、泰语等不同语言
3. 识别断行机会点:知道哪里可以安全地断行
这就像请了一位精通 100 多种语言的翻译官,帮你理解文本的"呼吸节奏"。
4.2 行断算法:模拟 CSS 的大脑
分好词之后,下一步是决定在哪里断行。这听起来简单,但实际上需要匹配 CSS 的复杂行为:
规则 1:在非空格片段前断行(如果会溢出)
想象你在读一行文字,宽度快用完了。如果下一个单词是 "congratulations",它肯定放不下,你必须在放它之前断行。
规则 2:尾部空格悬挂
在 CSS 的 white-space: normal 模式下,行尾的连续空格不会触发断行,而是会"悬挂"在边缘。Pretext 必须精确模拟这种行为。
规则 3:超长单词的字素断行
如果有一个超长的 URL(比如 https://example.com/very/long/path),即使比整行还宽,也不能让它无限延伸。Pretext 会在字素边界处强制断行(CSS 的 overflow-wrap: break-word)。
规则 4:软连字符(Soft Hyphen)
是一个不可见的字符,告诉浏览器:"如果实在要断行,优先在这里断开,并显示一个连字符。"Pretext 正确处理这种情况——未断行时它不可见,断行时它变成可见的 "-"。
4.3 CJK 排版与禁则(Kinsoku)
CJK 排版有一个特殊挑战:禁则处理。
在中文和日文排版中,某些字符不能出现在行首或行尾:
- 不能出现在行首:
,。!?、;:」』)】」(标点符号、右括号) - 不能出现在行尾:
「『(【「(左括号)
此外,Pretext 还支持 word-break: keep-all,这对于 CJK 文本很有用——它意味着不在 CJK 字符之间自动断行(除非你明确指定了断行点)。
4.4 Emoji 校正:Canvas 与 DOM 的差异
这里有一个有趣的问题:在某些浏览器(Chrome 和 Firefox)和特定字体大小下,Canvas 测量的 emoji 宽度会比实际 DOM 渲染的宽度宽。这会导致 Pretext 计算的高度与实际渲染不符。
Pretext 的解决方案是:在 prepare 阶段自动检测并校正这种差异。它会对每个字体大小对比 Canvas 和实际 DOM 的 emoji 宽度,计算出校正因子,然后在后续计算中应用这个校正。
这就像为一个总是测量偏差的尺子做校准——一旦校准完成,后续的测量都是准确的。
4.5 双向文本(Bidi)支持
对于阿拉伯语和希伯来语这样的从右到左(RTL)语言,以及混合 LTR 和 RTL 的文本,Pretext 提供 segLevels 元数据供自定义渲染器使用。这让开发者可以实现复杂的双向文本布局。
---
第五章:API 实战——从入门到精通
5.1 基础用法:测量段落高度
最简单的情况——你只需要知道一段文字在给定宽度下有多高:
import { prepare, layout } from '@chenglou/pretext'
// 准备阶段(一次性)
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')
// 布局阶段(可重复调用)
const { height, lineCount } = layout(prepared, 320, 20) // 320px 宽度,20px 行高
console.log(height) // 输出: 40 (两行 × 20px)
console.log(lineCount) // 输出: 2
关键点:同一个 prepared 对象可以用于任意多次 layout() 调用,每次可以是不同的宽度。
5.2 保留空白模式
如果你需要测量 textarea 的内容(保留空格、制表符、硬换行),使用 whiteSpace: 'pre-wrap':
const prepared = prepare(
'第一行\n第二行 多个空格',
'16px Inter',
{ whiteSpace: 'pre-wrap' }
)
5.3 手动布局:获取每一行
如果你需要把文字渲染到 Canvas、SVG 或 WebGL,你需要知道每一行的具体内容:
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
const prepared = prepareWithSegments('AGI 春天到了. بدأت الرحلة 🚀', '18px Helvetica')
const { lines, height, lineCount } = layoutWithLines(prepared, 320, 26)
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
console.log(line.text) // 行的文本内容
console.log(line.width) // 行的实际宽度
// 渲染到 Canvas
ctx.fillText(line.text, 0, i * 26)
}
5.4 流式布局:变宽场景
有时候每一行的可用宽度是不同的——比如文字环绕图片的情况:
import {
layoutNextLineRange,
materializeLineRange,
prepareWithSegments,
type LayoutCursor
} from '@chenglou/pretext'
const prepared = prepareWithSegments(article, '16px Georgia')
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
// 图文混排:图片旁边的行较窄
while (true) {
// 根据当前 Y 坐标决定可用宽度
const width = y < image.bottom ? columnWidth - image.width : columnWidth
// 获取下一行的范围
const range = layoutNextLineRange(prepared, cursor, width)
if (range === null) break // 文本结束
// 将范围转换为实际文本
const line = materializeLineRange(prepared, range)
// 渲染
ctx.fillText(line.text, 0, y)
// 移动光标到下一行
cursor = range.end
y += 26
}
5.5 富文本行内流
对于更复杂的场景(比如不同样式的文本、标签、提及),Pretext 提供了 rich-inline 子模块:
import {
prepareRichInline,
walkRichInlineLineRanges,
materializeRichInlineLineRange
} from '@chenglou/pretext/rich-inline'
const prepared = prepareRichInline([
{ text: 'Ship ', font: '500 17px Inter' },
{ text: '@maya', font: '700 12px Inter', break: 'never', extraWidth: 22 },
{ text: "'s rich-note", font: '500 17px Inter' },
])
walkRichInlineLineRanges(prepared, 320, range => {
const line = materializeRichInlineLineRange(prepared, range)
// line.fragments 包含每个片段及其来源索引、文本切片、间距等
})
---
第六章:应用场景——为什么需要 Pretext
6.1 虚拟滚动 / 遮挡剔除(Virtualization)
在虚拟滚动列表中,你需要在渲染之前就知道每个项目的高度。传统方法要么猜测(导致跳跃),要么先渲染再测量(导致卡顿)。
使用 Pretext:
// 预先计算所有项目的高度
const itemHeights = items.map(item => {
const prepared = prepare(item.text, '14px Inter')
const { height } = layout(prepared, containerWidth, 20)
return height
})
现在你可以精确知道滚动位置和内容总高度,无需任何猜测。
6.2 Masonry 瀑布流布局
瀑布流布局需要知道每个项目的高度才能决定把它放在哪一列。使用 Pretext,你可以在 JavaScript 中同步计算这些高度:
const placeItem = (item) => {
const prepared = prepare(item.text, item.font)
const { height } = layout(prepared, columnWidth, item.lineHeight)
// 找到最短的列
const shortestColumn = columns.reduce((a, b) => a.height < b.height ? a : b)
// 放置项目
shortestColumn.height += height + gap
return { column: shortestColumn.index, height }
}
6.3 防止布局偏移(CLS)
当新内容加载时,如果你事先知道它的高度,你可以预先分配空间,避免内容"跳"起来:
// 新消息到达前
const prepared = prepare(newMessage.text, '16px Inter')
const { height } = layout(prepared, chatWidth, 24)
// 预先扩展容器,防止滚动位置漂移
container.style.height = container.offsetHeight + height + 'px'
// 锚定滚动位置
scrollContainer.scrollTop += height
// 现在安全地插入新内容
insertMessage(newMessage)
6.4 开发时验证
Pretext 的一个有趣用途是在 CI 或开发时验证 UI 约束。比如,确保按钮标签不会溢出:
// 测试:所有按钮标签都应该在一行内
buttonLabels.forEach(label => {
const prepared = prepare(label, buttonFont)
const { lineCount } = layout(prepared, buttonMaxWidth, buttonLineHeight)
if (lineCount > 1) {
throw new Error(`Button label "${label}" overflows to ${lineCount} lines`)
}
})
这不需要打开浏览器,纯 Node.js 就可以运行。
6.5 AI UI 生成
这是 Cheng Lou 特别提到的一个场景。当 AI 生成 UI 时,它面临一个根本问题:没有"空间视觉"。
AI 可以写出完美的 HTML 和 CSS,但它不知道一段文字在给定容器里会不会溢出,不知道一个按钮的标签会不会太长。它必须依赖硬编码的经验规则。
Pretext 改变了这一点。AI 可以使用 Pretext 推演空间极限,用纯算术验证布局假设。这是 AI 与 UI 结合的一个关键拼图。
---
第七章:性能——数字说话
让我们看看 Pretext 有多快:
| 操作 | 耗时 |
|---|---|
prepare() | ~0.1-1ms(一次性) |
layout() | ~0.0002ms(每文本块) |
复杂场景 layout() | ~0.09ms(500 文本块批量) |
| DOM 测量(对比) | 30ms+(每 500 文本块) |
在 60fps 的动画中,你有 16.6ms 的时间预算。使用 DOM 测量,500 个文本块直接把你拖垮到 30ms(不到 30fps)。使用 Pretext,同样的工作只需 0.09ms,还剩下 16.5ms 给其他事情。
---
第八章:哲学——Cheng Lou 的思考
Pretext 不仅仅是一个性能优化工具,它代表了 Cheng Lou 对 Web 布局未来的深刻思考。在项目的 thoughts.md 中,他写道:
8.1 CSS 的膨胀与性能悖论
> "如果深入挖掘,80% 的 CSS 规范本来是可以避免的——只要用户空间对文本有更好的控制权。"
CSS 的初衷是让声明式样式变得简单。但随着功能不断增加,它变得越来越复杂,性能却越来越差。这就像一把瑞士军刀,功能越来越多,但用起来越来越不顺手。
8.2 AI 正在改变游戏规则
> "AI 正在缓解硬编码 CSS 配置的需求。"
在 AI 时代,我们不再需要用一堆预定义的类名和断点来覆盖所有情况。AI 可以根据上下文动态生成合适的样式。但 AI 需要能够验证它的输出——而 Pretext 提供了这种验证能力。
8.3 规范本身的瓶颈
> "UI 性能和开发者体验不可能有数量级的提升,因为瓶颈在于规范本身。"
这是一个悲观的但准确的观察。浏览器的渲染管线是几十年前设计的,当时的假设和约束在今天已经不再适用。但只要规范存在,所有浏览器都必须遵循,性能天花板就在那里。
Pretext 的答案是:绕过规范,把能力还给用户空间。
8.4 可验证软件的成本趋近于零
> "任何可验证软件的成本将趋近于零。"
这是一个更广泛的哲学观点。当一个软件的行为是可以精确预测和验证的(比如 Pretext 的纯算术布局),它的价值不再来自于神秘感和不可预测性,而是来自于可靠性和性能。在 AI 时代,这种可验证性变得越来越重要。
---
结语:回归简单
Pretext 的故事是一个关于回归简单的故事。
我们习惯了复杂的解决方案。DOM、CSS、浏览器渲染管线——这些系统是为了解决通用问题而设计的,代价是复杂性和性能。
但有时候,最简单的解决方案反而是最好的。如果你只想知道一段文字有多高,为什么要触发整个文档的重排?加法和比较不就够了?
Cheng Lou 用 Pretext 证明了:文本布局本质上只是算术。 一旦你接受这个观点,整个问题就变得简单了。
这就是费曼所说的那种理解——当你能用简单的语言解释一件事,当你能看到问题本质上的简单性,你才真正理解了它。
Pretext 不是魔法。它只是让浏览器做了它最擅长的事情(字体渲染),然后把剩下的交给了纯粹的数学。而这,正是它如此快速的原因。
---
项目链接: https://github.com/chenglou/pretext
在线演示: https://chenglou.me/pretext/
---
*"测一段文字有多高"——这个看似简单的问题,引出了浏览器性能优化的深层思考,也展示了纯算术的力量。有时候,最好的工程不是增加复杂性,而是发现问题的本质简单性。*
#Pretext #ChengLou #文本布局 #前端性能 #Canvas #Unicode #费曼风格