2.1w Star 的 pretext 火在哪?

0 阅读8分钟

我以前一直把“文本测量”当成前端里的脏活:临时挂一个隐藏节点,读一次高度,删掉节点,继续写业务。 直到有天聊天列表在真机上连续掉帧,我才意识到:我们不是在测文字,我们在反复打断浏览器的布局流水线。

pretext 的爆火,不是因为它又造了一个“测高函数”。 它真正击中的,是前端一个长期被忽略的高频痛点:把文本布局这件事,从 DOM 读写里剥离出来,变成可缓存、可复用、可预测的纯计算。

金句:性能问题的本质,常常不是“算太慢”,而是“算错了地方”。

01 为什么 pretext 会突然爆:它动的是浏览器最贵的一段路径

在传统方案里,我们为了拿到多行文本高度,通常会走这条链路:

  • 写入文本到 DOM
  • 触发布局计算
  • 读取 offsetHeight 或 getBoundingClientRect
  • 宽度变化后再来一遍

这条路的问题不是“不能用”,而是它在高频场景里代价陡增。比如虚拟列表、聊天会话流、AI 实时生成文案预览,只要宽度、字体、内容有变化,就会不断触发 reflow。你以为只是读了一个高度,实际上让浏览器把前后文的布局账单都结了一次。

pretext 的思路是反着来:尽量在计算层解决问题,不把浏览器渲染引擎拉进每一次循环。

Before:每次测量都依赖 DOM 状态。 After:先做一次准备,后续只做纯计算。

金句:把高频路径从“读布局”改成“算布局”,才是前端性能优化的分水岭。

02 它到底做了什么:prepare 一次,layout 多次

pretext 的核心设计是双阶段:

  • prepare:一次性做文本分段、空白规则处理、测量并缓存
  • layout:在给定宽度和行高下,快速计算高度与行数

官方 README 给出的基准(500 条文本批次)是:

  • prepare 约 19ms
  • layout 约 0.09ms

这组数字的意义不在“绝对快到离谱”,而在“冷热路径拆分成功”:

  • 冷路径允许稍重,因为执行次数少
  • 热路径必须极轻,因为会反复触发

这和我们在前端做的图片解码缓存、列表虚拟化、请求去重,本质是同一种工程思维:一次准备,多次消费。

金句:真正的优化,不是把每一步都做快,而是让最常走的那一步足够便宜。

03 这不是玩具库:它正好命中四类高价值场景

场景A:虚拟列表动态高度

你最怕的是估高不准导致滚动跳动。pretext 可以在渲染前预测高度,减少“先猜再纠正”的抖动链路。

场景B:聊天与评论流

消息内容、语言、emoji 混排复杂,传统隐藏节点测量会放大性能抖动。pretext 对多语言和 mixed-bidi 处理更稳,适合高并发文本流。

场景C:Canvas 或 SVG 或 WebGL 文本布局

这些场景本来就不依赖 DOM 文本节点。pretext 提供了按行输出和逐行推进能力,天然适配自绘渲染。

场景D:AI 生成 UI 的预校验

在“先生成后渲染”的工作流里,你可以先做离线布局校验,提前发现按钮文案换行、卡片标题溢出等问题,减少回归成本。

金句:当业务进入“文本即数据流”阶段,布局必须从渲染副作用里独立出来。

04 实战接入:用在 React 列表里,别把收益写没了

下面这段是一个可直接改造的思路(关键在缓存 prepared):

import { prepare, layout } from "@chenglou/pretext";

const preparedCache = new Map();

function getPrepared(text, font) {
  const key = font + "::" + text;
  if (!preparedCache.has(key)) {
    preparedCache.set(key, prepare(text, font));
  }
  return preparedCache.get(key);
}

export function measureMessageHeight(text, width) {
  const font = "14px PingFang SC";
  const lineHeight = 22;
  const prepared = getPrepared(text, font);
  const result = layout(prepared, width, lineHeight);
  return result.height;
}

落地时有三个动作不能省:

  • 字体声明必须和真实渲染一致(字号、字重、字族)
  • lineHeight 必须和 CSS 保持一致
  • resize 时优先重跑 layout,不要反复重跑 prepare

金句:你以为在优化库,真正优化的是“调用方式”。

05 先把边界看清:哪些坑会让你误判 pretext

pretext 不是完整字体引擎,它有明确边界,理解边界比盲目神化更重要:

  • 默认目标接近 white-space normal、word-break normal、overflow-wrap break-word 这组常见网页配置
  • 需要保留空格、制表符、换行时,要显式开 whiteSpace pre-wrap
  • macOS 上 system-ui 对精度不安全,建议使用命名字体
  • 极窄宽度下会在字素边界内断词,这是默认换行策略带来的可预期行为

如果你的业务是高级排版软件级需求,仍要做更重的排版系统;但如果你是互联网产品中的高频文本布局,这个边界已经覆盖绝大多数核心场景。

金句:工程价值从不等于“全能”,而在于“对主战场足够强”。

06 我对这波 pretext 的判断:它是方法论信号,不只是新库红利

我更在意的,不是“又多了一个 npm 包”,而是它提醒前端团队一件事: 我们习惯把布局问题交给浏览器兜底,但在高频业务里,布局本身就是业务性能的一部分。

pretext 给出的答案是:

  • 把高频测量从渲染副作用中抽离
  • 把一次性成本前置
  • 把热路径压到可忽略级别

这套方法不会只停在文本领域。它会继续影响我们处理图片裁切、卡片排布、可视化标注、甚至 AI 驱动 UI 生成的方式。

如果你正被列表抖动、消息流掉帧、布局回流困住,pretext 值得你今天就开一个分支实测。 别先问“它能不能替代一切”,先问“它能不能救你最贵的那段路径”。


07 实现原理拆解:pretext 怎么把“排版”变成“计算”

pretext 的核心不是一个“测高函数”,而是一条四层流水线:

输入文本 -> 文本分析(analysis)-> 宽度测量(measurement)-> 断行决策(line-break)-> 输出行数/高度

1)analysis:先把自然语言变成可计算 token

prepare() 的前半段先做结构化,而不是直接测宽:

  • white-space 规则归一化(normal / pre-wrap
  • Intl.Segmenter 切分(覆盖 CJK、泰语、阿拉伯语)
  • 给每段打上 break kind:text / space / tab / hard-break / soft-hyphen / zero-width-break / glue
  • 做浏览器行为导向的合并:URL 连段、数字串、标点链、CJK 禁则、阿拉伯/缅文粘连规则

这一层决定了“断行质量上限”。analysis 对,后面才有可能又快又准。

金句:性能优化的第一步,不是算得更快,而是先把问题描述正确。

2)measurement:把 token 变成宽度数组(并缓存)

prepare() 后半段用 canvas 测量,不碰 DOM 布局树:

  • OffscreenCanvas / canvas.measureText 测宽
  • (font, segment) 做缓存,避免重复测量
  • 对可断长词预计算 grapheme 宽度(breakableWidths
  • 维护两套行尾宽度:lineEndFitAdvances(判定能否放入)与 lineEndPaintAdvances(最终绘制宽度)

关键细节是 emoji 校正:在 Chromium/Firefox 的某些字体尺寸下,canvas 对 emoji 可能偏宽,pretext 会做一次 canvas 与隐藏 DOM span 的校准,把差值缓存成 emojiCorrection

金句:不是所有误差都要消灭,但高频误差必须被驯服。

3)line-break:状态机断行(layout() 的核心)

layout() 本质是跑一个轻量状态机,核心状态很少:

  • 当前行宽 lineW
  • 最近合法断点 pendingBreak
  • 当前游标 (segmentIndex, graphemeIndex)

决策顺序大致是:

  1. 先尝试整段放入当前行
  2. 超宽优先回退到最近合法断点
  3. 无断点且为长词时,按 grapheme 粒度硬断(overflow-wrap: break-word
  4. 命中 soft-hyphen 时补上可见 - 的宽度
  5. tabhard-break、行尾空白走专门分支

此外还有 EngineProfile 做浏览器差异收敛(Safari/Chromium 的 epsilon 与策略差异)。它不是理想化排版器,而是贴近真实浏览器行为的工程实现。

金句:断行不是数学题,而是带浏览器个性的工程博弈。

4)为什么它能快:冷热路径拆分

  • prepare(text, font):重活前置(分析、测量、缓存)
  • layout(prepared, maxWidth, lineHeight):热路径只做数组遍历与加减比较

所以 resize 时只重复 layout,不重测文字、不触发 DOM reflow。性能收益来自“重活前置 + 热路径极简”。

5)一段伪代码看完整链路

const prepared = prepare(text, font)
// 内部:analysis -> measurement -> cache arrays

const { lineCount, height } = layout(prepared, width, lineHeight)
// 内部:line-break state machine only

// width 变化时:
const next = layout(prepared, nextWidth, lineHeight)
// 不重新测量,不碰 DOM

6)工程启发

pretext 的方法论可以迁移到更多前端场景:先把“渲染副作用问题”改写为“可缓存的数据问题”,再让高频路径退化成纯计算。

收束金句:当布局从副作用变成数据流,性能就从玄学变成工程。

参考链接:

收束金句: 把重复测量交给预计算,把浏览器主线程还给真正的交互。