> *"如果你不能用简单的语言解释一件事,说明你还没真正理解它。"* —— 理查德·费曼
---
## 第一章:一个看似简单的问题
让我们从一个看似再简单不过的问题开始:测一段文字有多高。
想象一下,你在纸上写下一段话。你不需要任何复杂的工具——只要看一眼,就能大致知道这段文字会占多少空间。它有多宽、有多高,几乎是一种直觉。
但把这个场景搬到浏览器里,事情就变得诡异起来。
你可能会想:"这不就是获取一个 DOM 元素的高度吗?用 `offsetHeight` 或者 `getBoundingClientRect()` 不就行了?"
问题就出在这里。当你调用这些方法时,浏览器内部发生了什么?答案是:**一场灾难**。
---
## 第二章:Reflow 的本质——浏览器里的蝴蝶效应
### 2.1 那场昂贵的同步对话
让我用一个比喻来解释 Reflow 是什么。
想象你走进一家繁忙的餐厅。服务员正在根据客人的位置、桌子的摆放、过道的宽度来安排座位。突然,你走到餐厅中央,大声问:"请问第三桌到厨房的距离是多少?"
服务员必须立刻停下手中所有的工作,重新测量整个餐厅的布局,才能回答你这个问题。更糟的是,当你得到答案后,服务员必须从头开始重新安排之前被打断的工作。
这就是 **Forced Synchronous Layout(强制同步布局)** 的本质。
当你调用 `getBoundingClientRect()` 时,浏览器必须:
1. 暂停 JavaScript 的执行
2. 刷新所有待处理的样式变更
3. 重新计算整个文档的布局(即使你的改动只影响了一个小角落)
4. 返回你想要的那个数字
5. 允许 JavaScript 继续执行
### 2.2 性能地狱的真实数字
让我们看看这到底有多贵。
假设你有一个虚拟滚动列表,里面有 500 个文本块。每个块都需要知道自己的高度。传统的做法是:
```javascript
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
总宽度 = 8+7+3+3+8+4+11+8+6+3+8 = 69px
如果你的容器宽度是 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(布局)**
```
layout(prepared, maxWidth, lineHeight) → { height, lineCount }
```
这个阶段只做纯算术:
- 遍历缓存的宽度
- 累加直到超过 maxWidth
- 统计行数
- 返回高度
关键洞察是:**你可以对同一个 PreparedText 调用 layout() 任意多次,用于不同的宽度。每次调用都只是一堆加法和比较,没有 Canvas 调用,没有 DOM 读取,没有字符串操作,甚至没有内存分配。**
这就像你先花点时间把食材都切好准备好(Prepare),之后炒菜的时候只需要简单的翻炒(Layout),而不是每次都从头开始处理食材。
---
## 第四章:技术深潜——走进 Pretext 的内部
### 4.1 Intl.Segmenter:分词的魔法
现在让我们深入技术细节。第一个问题是:如何把一段文本分割成可以独立测量的片段?
对于英语,这似乎很简单——按空格分割单词就行了。但对于其他语言呢?
- **中文/日文/韩文(CJK)**:没有空格分隔,你需要知道在哪里可以断行
- **泰语**:没有空格,而且需要词典来正确分词
- **阿拉伯语**:从右到左书写,字母在词首、词中、词尾有不同的形态
- **Emoji**:👨👩👧👦 看起来是一个字符,实际上是由多个 Unicode 码点组成的
**Intl.Segmenter** 是 JavaScript 内置的国际化 API,它能理解所有这些复杂规则。
```javascript
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 理解这些规则,并确保断行位置符合传统排版规范。
此外,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 基础用法:测量段落高度
最简单的情况——你只需要知道一段文字在给定宽度下有多高:
```typescript
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'`:
```typescript
const prepared = prepare(
'第一行\n第二行 多个空格',
'16px Inter',
{ whiteSpace: 'pre-wrap' }
)
```
### 5.3 手动布局:获取每一行
如果你需要把文字渲染到 Canvas、SVG 或 WebGL,你需要知道每一行的具体内容:
```typescript
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 流式布局:变宽场景
有时候每一行的可用宽度是不同的——比如文字环绕图片的情况:
```typescript
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` 子模块:
```typescript
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:
```typescript
// 预先计算所有项目的高度
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 中同步计算这些高度:
```typescript
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)
当新内容加载时,如果你事先知道它的高度,你可以预先分配空间,避免内容"跳"起来:
```typescript
// 新消息到达前
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 约束。比如,确保按钮标签不会溢出:
```typescript
// 测试:所有按钮标签都应该在一行内
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 文本块) |
**数量级提升:从 30ms 降到 0.09ms,大约 333 倍的提升。**
在 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 #费曼风格
登录后可参与表态
讨论回复
0 条回复还没有人回复,快来发表你的看法吧!