Pretext 初识——零 DOM 测量的文本布局引擎

给你一段文本和一个容器宽度,怎么知道它会占几行?

传统做法是创建 DOM 元素 → 设置文本内容 → 渲染 → 调用 getBoundingClientRect() 读取高度。这个流程会触发layout reflow ------浏览器渲染管线里面性能消耗最大的操作之一。

DOM 测量的代价

你可能见过浏览器控制台的这个警告:

csharp 复制代码
[Violation] Forced reflow while executing JavaScript took 47ms

这就是强制回流:JavaScript 查询几何属性(offsetWidthclientHeight)时,如果 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 倍。

两阶段设计:

  1. prepare(): 一次性重工作(文本分析 + Canvas 测量 + 缓存)
  2. 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

实际意义:

  1. 实时 resize 不再卡顿:0.09ms vs 43.50ms,流畅度提升 483 倍
  2. 虚拟列表可以预知高度,无需渲染即可测量
  3. Canvas 动态布局成为可能,游戏 UI、图表标签不再是瓶颈
  4. 瀑布流可以实时计算,不需要预先渲染

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 也选了贪婪算法,原因有三:

  1. 性能:O(n) vs O(n²),快几个数量级
  2. 浏览器一致性:与 CSS 行为对齐
  3. 实用性:大多数场景下贪婪算法的质量足够好

实际对比: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 的解决方案:

  1. 检测修正量:对比 Canvas vs DOM 测量,计算差值
  2. 缓存修正量:每个字号只检测一次
  3. 应用修正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 这项目确实有点意思,以上都是我一些初步的探索和了解,如有错误,欢迎指正

参考文献

  1. DebugBear. (2022). How To Fix Forced Reflows And Layout Thrashing . debugbear.com/blog/forced...
  2. web.dev. Avoid large, complex layouts and layout thrashing . web.dev/articles/av...
  3. react-virtualized Issue #610. (2018). github.com/bvaughn/rea...
  4. vue-virtual-scroller Issue #767. (2021). github.com/Akryum/vue-...
  5. Recharts Issue #3983. (2021). github.com/recharts/re...
  6. W3C CSSWG Issue #3756. Float positioning "as high as possible" prohibits non-greedy line-breaking . github.com/w3c/csswg-d...
  7. tldraw Issue #7377. (2023). Batch measurement optimization . github.com/tldraw/tldr...
  8. bramstein/typeset. TeX line breaking algorithm in JavaScript . github.com/bramstein/t...
  9. Figma Blog. (2021). How we figured out canvas virtualization . www.figma.com/blog/how-we...
  10. GitHub Issue #427. (2024). Canvas text rendering and metrics (2024 edition) . github.com/web-platfor...

项目地址github.com/chenglou/pr...

相关推荐
xw-busy-code2 小时前
npm 包管理笔记整理
前端·笔记·npm
踩着两条虫2 小时前
AI驱动的Vue3应用开发平台 深入探究(十六):扩展与定制之自定义组件与设计器面板
前端·vue.js·人工智能·开源·ai编程
棋鬼王3 小时前
Cesium(十) 动态修改白模颜色、白模渐变色、白模光圈特效、白模动态扫描光效、白模着色器
前端·javascript·vue.js·智慧城市·数字孪生·cesium
酉鬼女又兒3 小时前
零基础快速入门前端蓝桥杯Web备考:BOM与定时器核心知识点详解(可用于备赛蓝桥杯Web应用开发)
开发语言·前端·javascript·职场和发展·蓝桥杯
ThridTianFuStreet小貂蝉3 小时前
面试题1:请系统讲讲 Vue2 与 Vue3 的核心差异(响应式、API 设计、性能与编译器)。
前端·javascript·vue.js
俊劫3 小时前
AI Harness - 2026 AI 工程新范式
前端·openai·ai编程
前端付豪3 小时前
Prompt Playground(实现提示词工作台)
前端·人工智能·后端
竹林8183 小时前
在NFT项目中集成IPFS:从Pinata上传到前端展示的完整实战与踩坑
前端·javascript
取名不易3 小时前
canves实现画布
前端