告别 getBoundingClientRect 重排地狱:Cheng Lou 开源 Pretext,DOM-Free 文本测量与排版引擎拆解

告别 getBoundingClientRect 重排地狱:Cheng Lou 开源 Pretext,DOM-Free 文本测量与排版引擎拆解

告别 getBoundingClientRect 重排地狱:Cheng Lou 开源 Pretext,DOM-Free 文本测量与排版引擎拆解

前端做「自适应气泡」「虚拟列表」「杂志栏流排版」时,最刺手的往往不是组件框架,而是多行文本到底有多高。传统做法几乎都会落到 getBoundingClientRect / offsetHeight:先把节点塞进 DOM,再读尺寸。问题是——读布局会强制同步重排(layout reflow)。几百段评论各自量一次,帧预算就被读/写交错吃光。

前 React 核心成员、现 Midjourney 工程师 Cheng Lou 开源的 Pretext(@chenglou/pretext,MIT)把这件事拆成两阶段:prepare 一次量宽,layout 纯算术反复换行。官方演示站在 chenglou.me/pretext。本文按官方 README、源码注释与社区讨论深度改写——源微信页常遇验证码时,以 GitHub 文档为准,不臆造 API。

封面:DOM-Free 文本排版


一、为什么「量一下文字高度」会拖垮整页?

浏览器布局是全局问题:你读一个节点的高度,引擎往往要先算完当前文档树的几何。虚拟列表若对每条消息都 getBoundingClientRect,就会出现源码注释里描述的典型病症——读/写交错,500 段文本可轻松吃掉 30ms+ 单帧。

社区(Hacker News)把 Pretext 的价值说得很直白:难的不是 canvas.measureText,而是完整复刻浏览器换行规则(连字符、emoji、CJK、尾随空白悬挂等),再用缓存宽度做反复排版。若只量一次、永不再流,收益有限;若窗口缩放、列宽变化、遮挡裁剪要反复重排,热路径才会拉开数量级差距。

方案 触发重排 缩放/多宽反复计算 多语言/复杂脚本 典型痛点
DOM 实测 getBoundingClientRect 每次必触发 昂贵 浏览器原生最准 虚拟列表卡顿、布局抖动
粗估字数 × 行高 无 便宜但不准 差 中英混排、emoji 全翻车
Pretext prepare + layout 准备阶段用 Canvas,热路径零 DOM 极便宜 Intl.Segmenter + 自研换行 需对齐 CSS font / line-height

📌 洞见:Pretext 不是「完全不碰浏览器字体引擎」,而是把字体真相留在 Canvas 测量里,把换行决策搬到用户态纯算术——既保真,又避开整页 reflow。


二、双阶段架构:prepare 一次,layout 无数次

Pretext 双阶段流水线

核心心智模型只有两步:

import { prepare, layout } from '@chenglou/pretext'

// 一次性:分词、胶粘规则、Canvas 量宽,返回不透明句柄
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')

// 热路径:给定最大宽度与行高,纯算术算高度与行数——无 DOM、无 Canvas、无字符串分配
const { height, lineCount } = layout(prepared, 320, 20)

1. prepare():预处理与缓存

官方契约:

  • 规范化空白、用 Intl.Segmenter 切段;
  • 用 Canvas measureText 量每段宽度并缓存;
  • 必要时对 emoji 做一次按字体缓存的 DOM 校准读;
  • 返回与宽度无关的 PreparedText,同一句柄可在任意 maxWidth 上 layout。

基准量级(官方文档快照):约 500 段文本的 prepare 批处理约 19ms;之后单次 layout 可到亚毫秒乃至微秒级(源码注释写到约 0.0002ms/块 量级,视环境而定)。

致命误用:在 resize 回调里反复 prepare——等于每次都付 Canvas 成本,把优势打回原形。正确姿势是:文本/字体变了才 prepare;宽变了只 layout。

2. layout():纯算术热路径

layout(prepared, maxWidth, lineHeight) => { height, lineCount }
  • lineHeight 必须与 CSS line-height 对齐;
  • font 字符串必须与 Canvas/CSS font 简写一致(字重、字号、族名);
  • 空字符串返回 { lineCount: 0, height: 0 };若要模拟浏览器空块仍占一行,自行 Math.max(1, lineCount) * lineHeight。

可选:prepare(..., { whiteSpace: 'pre-wrap' }) 保留空格/制表/硬换行;{ wordBreak: 'keep-all' }、{ letterSpacing: n }(按 CSS px)对齐常见排版开关。


三、进阶 API:自己拿行、绕图流排、富文本碎片

当高度不够,还要「逐行绘制 / 逐行改宽 / shrink-wrap」时,切换到带段信息的 API:

import {
prepareWithSegments,
layoutWithLines,
measureLineStats,
walkLineRanges,
layoutNextLineRange,
materializeLineRange,
} from '@chenglou/pretext'

const prepared = prepareWithSegments('AGI 春天到了. بدأت الرحلة 🚀', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i].text, 0, i * 26)
}

变宽流排(绕浮动图)

let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
while (true) {
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
}

这正是杂志栏、聊天气泡「贴合文字的最紧宽度」、以及遮挡/虚拟化需要的能力:measureLineStats / walkLineRanges 可在不分配行字符串的前提下探宽探高。

富文本行内:@chenglou/pretext/rich-inline

Mention 芯片、代码 span、不同字重混排时:

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)
// fragments 保留源 item 索引、gapBefore、游标
})

注意:rich-inline 不是完整 CSS inline formatting 引擎,目前按 flat item 列表、white-space: normal 工作。


四、解锁的 UI 场景(官方用例映射)

虚拟列表与气泡贴合示意

场景 Pretext 怎么用 传统坑
虚拟列表 / 遮挡剔除 prepare 后按列宽 layout 得精确高度 猜行高 + 缓存失效 = 滚动跳动
用户态 masonry / 类 flex 反复 layout 试宽 改一点 padding 就整页 reflow
IM 气泡 shrink-wrap measureLineStats / walkLineRanges 找最紧宽 大量隐藏 DOM 探针
AI 生成 UI 防溢出 无浏览器也可在有 Canvas 的环境验标签是否换行 截图回归成本高
新文本加载后锚定滚动 预知高度变化,抵消 layout shift 先插再量,闪一下

连字符:在 prepare 前自行插入软连字符(soft hyphen);Pretext 不自动插。混合语言/UGC 宜保守、按 locale 插入。


五、本机 API 形状与运行时边界存证

在流水线机器上安装 @chenglou/pretext 后执行结构校验(无浏览器 Canvas 时 prepare 会失败——这是真实边界,不是废话):

exports: clearCache, layout, layoutNextLine, layoutNextLineRange, layoutWithLines,
materializeLineRange, measureLineStats, measureNaturalWidth, prepare,
prepareWithSegments, setLocale, walkLineRanges
[expected in headless Node] prepare/layout requires Canvas 2D + Intl.Segmenter:
Text measurement requires OffscreenCanvas or a DOM canvas context.

结论写进排坑清单:

1. 运行时硬依赖:Intl.Segmenter、Canvas 2D 文本测量、Unicode 属性转义 \p{...};缺特特性直接无法加载或抛错。

2. SSR / 纯 Node:默认拿不到 DOM Canvas;要么上 OffscreenCanvas polyfill/环境,要么把 prepare 挪到浏览器/Worker。

3. 字体陷阱:macOS 上 system-ui / -apple-system 不安全(Canvas 与 DOM 解析可能不一致)——用具名字体(Inter、Helvetica、Arial…)。

4. CSS 对齐:font-optical-sizing、font-feature-settings、非 canvas 字符串表达的可变字体轴、非默认 font-kerning/word-spacing 都可能导致「量的和画的不一致」。

5. 双向文本:Pretext 不算完整 bidi level;英阿混排若按行拆到 Canvas fillText,标点/数字在断行处可能顺序异常——官方建议整段交给 DOM 并设方向。

6. 最小字号:Chrome/Firefox 用户可设最小字体;页面实际绘制可能被抬高,而 Pretext 按你传入字号测量。

辅助 API:clearCache() 释放字体/文本变体缓存;setLocale(locale?) 影响后续 prepare(会清缓存,不改写已有句柄)。


六、安装、演示与工程接入

npm install @chenglou/pretext

本地演示(官方):

git clone https://github.com/chenglou/pretext.git
cd pretext && bun install && bun start
# 浏览器打开 /demos/index ;Windows 用 bun run start:windows

接入清单(建议贴进 Code Review):

1. 文本首次出现 → prepare / prepareWithSegments;

2. resize / 列宽动画 → 只调 layout*;

3. font、letterSpacing、lineHeight 与真实 CSS 同源生成;

告别 getBoundingClientRect 重排地狱:Cheng Lou 开源 Pretext,DOM-Free 文本测量与排版引擎拆解

4. 根 lang 变化后重新 prepare;

5. 字号尽量整像素(Firefox Canvas 会圆整字号)。

架构关系可概括为:

Mermaid Diagram


七、社区争论与边界预期(别当银弹)

HN 上也有「一次性量表场景未必更快」「拉丁表格行高另有更快专用实现」等声音。理性预期:

  • 强项:多宽反复重排、用户态复杂布局、多脚本换行保真、与 AI 迭代测量对齐浏览器真相;
  • 弱项:不是完整字体渲染引擎;CSS 覆盖面有限;准备阶段仍依赖 Canvas;SSR 需额外设计;
  • 历史渊源:Sebastian Markbage 早年 text-layout 的种子——Canvas 塑形、流式换行——在 Pretext 里被继续推到工程可用。

⚠️ 安全与合规提示:本文只讨论合法的开源文本排版库与性能工程,不涉及破解、攻击复现或绕过平台风控。


八、小结

Pretext 把「困扰前端二十年」的文本测高问题,收敛成一句工程纪律:贵的测量只做一次,便宜的换行随便做。若你在做聊天、Feed、杂志式栏流、或 AI 生成 UI 的防溢出校验,值得把 @chenglou/pretext 放进工具箱——记得对齐字体字符串,并避开 system-ui 这种「看起来省事、量出来跑偏」的坑。

仓库:github.com/chenglou/pretext · 演示:chenglou.me/pretext

💻 配套实训环境与动手练习

本文涉及的相关技术指令、开发环境与工具链已内置在边学边练在线实验室中,无需繁琐安装配置,随时在浏览器中实践体验:

文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有

最新文章

热门文章

本栏目文章