首页>前端>正文

Pretext文本排版引擎:绕过DOM测量的精确布局方案

Feng 41 阅读 前端

浏览器里量一段文字有多高,这件事听起来无聊,做起来要命。Pretext 想干的就是把它变成一次纯算术。
Pretext文本排版引擎:绕过DOM测量的精确布局方案
这个用 TypeScript 写的库不碰 DOM,不触发 reflow,却声称能匹配浏览器字体引擎在 CJK、阿拉伯文、emoji、混合 bidi 下的真实换行行为。作者 Cheng Lou,React 和 ReasonML 的老面孔,后来在 Midjourney 做视觉生成。他说这是爬过地狱才搞出来的东西。我信,因为文本测量踩的全是 Unicode 规范和各家浏览器实现差异的坑。

问题不在测量,在测量之后的重排

动态文本场景——聊天流、虚拟列表、自动换行输入框、AI 流式输出——都绕不开一个问题:这段文字塞进 300px 宽的容器,到底占多高。
传统做法是扔进 DOM,读 offsetHeight,再写回样式。读一次写一次,浏览器就得重算整个页面的流式布局。长列表滚动、窗口 resize、token 逐个吐出的流式渲染,这套操作直接把帧率打穿。框架们的应对是批量测量、缓存估算、牺牲精度,代价是滚动抖动、CLS 偏移、虚拟化高度猜不准、Canvas 文字和 CSS 文字对不齐。
Pretext 的思路是把测量前置。prepare 阶段做一次重活,layout 阶段只剩加减法。

两套 API,按控制粒度选

简单场景只要高度和行数:

import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')
const { height, lineCount } = layout(prepared, 300, 24)

prepare 内部做空白规范化、语义分段、换行规则应用、Canvas 逐段测宽、结果缓存,返回一个不透明句柄。layout 接收最大宽度和行高,纯计算返回高度与行数。font 字符串必须和 CSS 里的 font 声明逐字符一致,行高也要对齐,否则精度会飘。
textarea 这类需要保留空格和硬换行的场景,加选项:

const prepared = prepare(textareaValue, '16px Inter', { whiteSpace: 'pre-wrap' })

官方基准数据:500 条混合文本 prepare 约 19ms,同批次 layout 约 0.09ms。组件 mount 时 prepare 一次,resize 时只跑 layout,这个分工是性能优势的来源。
需要逐行控制时换高级 API:

import { prepareWithSegments, layoutWithLines, walkLineRanges, layoutNextLine } from '@chenglou/pretext'
const prepared = prepareWithSegments(text, '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 400, 28)
for (const line of lines) {
 ctx.fillText(line.text, 0, y)
 y += 28
}

layoutWithLines 返回每行的完整文本和宽度。walkLineRanges 更底层,只给宽度和光标位置,不拼字符串,适合二分搜索最优容器宽度——聊天气泡的 shrinkwrap 就靠它。layoutNextLine 是迭代器,能处理变宽场景,文字绕图、多栏杂志流、浮动元素都归它管:

let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
while (true) {
 const width = (y < imageBottom) ? columnWidth - imageWidth : columnWidth
 const line = layoutNextLine(prepared, cursor, width)
 if (!line) break
 // 渲染 line.text
 cursor = line.end
 y += lineHeight
}

精确性从哪来

Pretext 打包后只有几 KB,源码几乎没有冗余。核心策略是把浏览器自己的 Canvas 当作测量基准:prepare 阶段用 Intl.Segmenter 按 grapheme 切分文本(emoji 和组合字符必须当一个单元),处理 Unicode 空白规则和 bidi 方向,然后对每段调 Canvas.measureText 缓存宽度。布局阶段用纯 JS 做贪心加必要回溯的换行,处理连字符、标点禁则、CJK 不换行。
这里有个容易忽略的精度细节:Canvas 的 measureText 默认受 fontKerning 影响,而 DOM 渲染的字距调整行为在部分浏览器版本里和 Canvas 不完全同步。如果你的字体启用了 kerning 或连字,prepare 时传入的 font 字符串需要和实际渲染保持一致,必要时在测量用的离屏 canvas 上显式设置 ctx.fontKerning = ‘normal’,否则长文本累积下来会出现一到两像素的行宽偏差。作者用 AI 对着浏览器真实渲染结果迭代了几周,逐宽度比对换行行为,这部分工作量才是库可信的基础。
另一个部署边界在 Intl.Segmenter。Chrome 87+、Firefox 125+、Safari 14.1+ 才支持,更老的 Safari 上直接抛错。降级方案是 grapheme-splitter 或手写正则,但降级后组合 emoji(比如带肤色修饰的字符)可能被拆成多个测量单元,宽度累加出现偏差。目标用户含 iOS 14 以下设备时,这条要提前验证。

缓存与清理

所有测量结果共享缓存,相同文本加相同字体多次 prepare 直接复用。频繁切换字体的应用可以调 clearCache() 手动清理,避免缓存膨胀。layout 之所以能到 0.09ms,本质就是数组索引加加减法,没有字符串拼接,没有对象分配。

它改变的是哪些场景

变高虚拟列表是最直接的受益者。以前要么估高要么 DOM 测量后强制 rerender,滚动时闪。现在所有高度提前算好,occlusion culling 退化成线性扫描,Masonry 不规则网格也能稳在 120fps。
CLS 问题从根上消失。新文本加载时高度已知,scroll position 直接算,不用等 DOM 渲染再调锚点。
Canvas、SVG、WebGL 和 DOM 的文字终于能对齐。同一份 prepared 数据喂给两边,渲染结果像素级一致。游戏 UI、数据可视化、设计工具都吃这个红利。
响应式排版里那些以前要一堆 hack 的效果——气泡 shrinkwrap、最优宽度二分、多栏动态流、文字绕图——现在是一次 layout 调用。社区已经有人基于它实现 Knuth-Plass 断行、drop cap、自动平衡文本。
编辑器与 AI 流式输出场景,测量循环不再是瓶颈。API 极简且结果可预测,AI 生成 UI 组件时可以直接调 Pretext 验证文本不溢出,不用猜。

边界在哪

Pretext 不取代 CSS。CSS 仍然负责最终绘制,Pretext 只管测量和布局决策。Tailwind、Flex、Grid 照用,只是文本高度从黑盒变成可控变量。
它不是万能药。selection 样式不直接支持,演示里靠透明 canvas 叠加画选区绕过。full justification 的高级微调还在社区扩展阶段。服务端渲染在 TODO 里,尚未实现。
还有一类不适用场景值得说清楚:依赖浏览器原生 text-wrap: balance 或 text-wrap: pretty 的布局,Pretext 的换行算法是独立实现的,不会自动跟随这些 CSS 新特性的行为。混用两者会导致测量结果和实际渲染不一致。要么全走 Pretext 的布局决策,要么全交给 CSS,别各管一半。
对工程师来说,它的价值在于把文本从渲染后才知道变成生成前就知道。列表更稳,编辑器更顺,排版创意有了可编程的底层。几 KB 的库,换掉的是 DOM reflow 这个背了十几年的包袱。

ID:6930

Feng
这位作者很神秘,还没有填写简介。