Pretext 这个英文单词拆开看就是 pre-text,意思是“文本之前的预处理”;但它更常用的意思是“借口”——很多性能优化到最后做不下去,恰恰是因为没有一个说得过去的理由去改渲染架构。我们为了一个诡异的线上性能事故,把内部优化方案代号定为 Pretext:它要解决的核心问题,正是前端文本布局长期被忽视、又随数据量级增长而越来越明显的短板——大量文本在浏览器里被反复排版、重复测量、无意义地刷新。
这篇文章不准备讲空泛的“前端性能优化方法论”,而是围绕 Pretext 这组方案,完整记录文本布局为什么会成为瓶颈、我们怎么拆解它、以及三套真正落地能用的手段:测量缓存(Measure Cache)、自绘文本层(Canvas Text Layer) 和 异步布局调度(Async Layout Scheduling)。如果你正在做虚拟表格、日志流面板、富文本编辑器、数据大屏这类高强度文本渲染场景,这轮实验和教训应该能少走不少弯路。
1. 事故现场:100 列表格滚不动,问题居然不在业务 JS
1.1 一次八竿子打不着的性能排查
起因是客户环境里有个数据表格,大概 200 列乘 8000 行,用户每次过滤完数据后页面会白屏将近两秒,滚动时掉帧掉到基本没法用。这种问题在后台系统里太常见了,起初所有人包括我都觉得是“业务代码写得太烂,DOM 节点太多”。
我们按常规套路做了几件事:清理了列表组件里无意义的 setState、把所有事件监听改成事件委托、把表格列配置做了 memo、甚至给单元格加上了 React.memo。一顿操作下来性能有点改善,但离“流畅可用”还差得远。真正让我警觉的是后面一步——我随手把模拟数据量降到 500 行,问题依然会出现周期性卡顿;而把表格单元格里的文案从动态文本改成固定文本“test”,同样的滚动操作就变得丝滑了。
那一刻我突然意识到,卡顿和 JavaScript 业务代码关系不大,问题出在浏览器排版引擎或者文本渲染路径上。
1.2 性能面板里的“隐形大户”
用 Chrome DevTools Performance 面板录制了大概三秒滚动过程后,主线程火焰图让我看得很清楚:Scripting 加在一起不到 30ms,但 Layout 阶段一块一块地占满了帧时间,后面还跟着非常长的 Paint。点开具体 Layout 任务,有时能看到耗时几毫秒到几十毫秒不等的“布局子树重建”,而触发它们的根节点全是那些包含长文本的表格单元格。
换言之,当我们通过 JS 改了表格任意一个字段,React 会触发组件树更新,浏览器随后需要把对应 DOM 节点的文本重新放进排版管线里,完成一次从字符到盒模型的完整计算。问题是这个重算并不是只针对“改了的那个文本”——只要某个容器里的内联内容被标记为 dirty,同容器内的其他文本行也要跟着一起重新排版。
文本布局的本质是:把一串没有固定宽度的字符,按照字体度量切成一行一行,然后算出每个字符的坐标。 字体一旦加载完成、字号和容器不变化,这个计算结果本可以复用。现实是大部分框架和业务代码根本没做这件事,每次渲染都会让浏览器把同样的文本再从头排一遍。
1.3 Pretext 的定位:不是新框架,而是把“文本排版劳动”重排为三类可复用任务
尝试过几个现成性能库和虚拟滚动方案后,我们总结出了一个判断:在文本密集型场景里,真正能让性能起飞的地方不是虚拟滚动本身,而是“减少排版引擎的重复劳动”。
所以 Pretext 被定位成一套工程实践,不依赖某个框架,也不试图替代浏览器排版——它通过三个设计原则来拆掉文本布局的性能墙:
- 凡是被测量过的文本,就不要再测第二遍。把文本测量结果做成全局缓存,命中率足够高时,布局成本能降一个数量级。
- 凡是“只展示不交互”的文本,就把渲染路径从 DOM Layout 管线中分离出来。用 Canvas 或位图精灵技术,让大量文本绕过 DOM 布局这层昂贵的计算。
- 凡是必须留在 DOM 里的布局任务,改成“增量 + 空闲调度”,不让任何一次用户交互在同一帧内背上整片文本区域的布局包袱。
听起来不复杂,真正落地时每一步都有很多坑。下面我把这三板斧背后的原理和实现细节完整拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 文本布局的性能账本:从字符到像素到底经历了什么
2.1 浏览器排版文本的完整管线
先把文本变成“能上屏的像素”这个过程搞明白。假设你有一个 <div style="width: 600px; font: 16px/1.5 sans-serif;">,里面是一大段文字。排版引擎在内部做的事情大致是:
- 把文本拆成字符序列,并识别语言、断行机会点。
- 对每个字符做字体匹配,找出该用什么字体。这里会涉及 font-family 列表里的逐个回退尝试,以及系统字体 fallback。
- 把字符序列转成字形序列,这个步骤称为 shaping。不同字符之间可能要做连字、变音符号定位、阿拉伯文或印度文字的上下文替换。
- 根据容器宽度做断行。英文和中文断行规则不同,内联元素和
white-space属性的影响也在这里生效。 - 计算每个字形最终的位置、行高、基线。
- 生成内联布局结果,给 Paint 阶段输出绘制命令。
- 光栅化字形纹理,通过 GPU 合成上屏。
如果只是页面加载时跑一次,这套管线性能完全可以接受;但在高频更新的场景下,问题来了——每次文本 DOM 或样式变化,上面的链条都会在当前帧内完整执行一遍,尤其前四步都是纯 CPU 计算,非常昂贵。
2.2 三个最烧钱的“隐形点火器”
我把性能面板里看到的文本布局热点归成三类,你去观察自己的项目也会有同样发现:
第一,字体匹配与 fallback。 我们常用 font-family: PingFang SC, Microsoft YaHei, sans-serif 这种写法,中文字体的字符集合巨大,排版引擎为了找到一个能覆盖某个字符的字形,可能要查询多张字体表。如果文本里混入了生僻字、特殊符号、emoji,fallback 会一直查到系统字体层,这个行为的耗时远高于一般人想象。
第二,shaping 字符整形。 对英文来说,普通字符串字形比较简单;但对阿拉伯文、泰文、印度天城文这类文字,每个字符的形状会随上下文发生改变。浏览器必须先做复杂整形再断行,成本会高一个量级。中文和英文的手感其实也在 shaping 里,只是相对轻一些。
第三,断行计算。 文本在容器内部自动换行时,排版引擎需要根据每个单词/字符的宽度不断尝试换行点,寻找满足 word-break、line-break 等规则的位置。单行短文本还好说,一旦出现长段落、多列布局,断行过程会产生大量测量调用。
这里给一个不太严谨但好记的类比:浏览器排版文本,就像装修公司给每个字符单独量尺寸、单独分配一个隔间。你改动其中一个字符,本来只需要改它隔壁那一间,但浏览器经常会把整面墙所有隔间都推倒重排。文本量越大,这种“推倒重排”的浪费越明显。
2.3 如何自己复现一次“文本布局开销观测”
如果你的页面也卡,判断“文本布局是否是瓶颈”可以按这个步骤去测量:
- 打开 Chrome DevTools 的 Performance 面板,点击录制。
- 只做一个操作:让页面滚动 500 像素(不要在录制期间通过开发工具改样式)。
- 停止录制后,看 Summary 面板中 Layout 和 Paint 的占比。
- 在火焰图里寻找由
Layout触发的长任务。点击一个 Layout 块,右侧详细信息会显示“Layout root”对应的 DOM 节点;如果这个节点下是一个包含大量文本的表格或段落,那基本可以确认文本布局是元凶。 - 为了排除业务 JS 的影响,可以临时注释掉所有业务逻辑,只渲染一块静态文本,再用同样方式录制。如果静态文本滚动时 Layout 时间依然很高,说明瓶颈不在业务逻辑。
我们当时在客户那个 8000 行表格上录到的数据大致是这样:
| 阶段 | 总耗时比例 | 说明 |
|---|---|---|
| Scripting | 约 12% | React 更新、滚动事件处理 |
| Layout | 约 37% | 大量内联文本重新断行和高度计算 |
| Paint | 约 23% | 文本绘制指令提交 |
| Raster | 约 18% | 字形光栅化与贴图上传 |
| 其他 | 约 10% | 合成、GC 等 |
测试环境有差异,数字不通用,但只要 Layout 占比超过其他阶段,就有理由去改造文本渲染路径。Pretext 的三板斧就是冲着这份账本里的 Layout 和 Raster 去的。
3. 第一板斧:测量缓存,让 Line Layout 从“重新计算”退化成“查表”
3.1 为什么文本测量是最高频的重复劳动
前端很多逻辑都需要知道文本的真实宽高:单元格是否显示省略号、表格行高自适应、虚拟滚动需要预设行高、树形表要决定是否缩进换行。最常见的实现是用 DOM 把这些文字先渲染到隐藏容器里,再读取 offsetWidth / scrollWidth;有些人会用 Canvas 的 measureText()。无论哪种方式,本质上都在调用排版引擎做字形测量。
但这个测量结果对同一段文本是稳定的:字体、字号、字重、字间距都不变时,“Pretext” 这行字的宽度永远是确定的。业务里大量文本来自接口枚举和固定字段,高频滚动时真正变化的只是视口附近几十行,其他几千行文本的宽度都在一遍遍被重复测量。
我见过一些团队为了性能把文本塞进 canvas 自绘,但没做测量缓存,结果每次渲染还是调用 ctx.measureText。这个 API 单次调用大约几微秒到几十微秒,看起来不慢;可一万次单元格并排调用,主线程直接就卡死了。缓存的核心动机永远是:成本再小的操作,一旦高频重复也会成为瓶颈。
3.2 缓存键的四个层级
做测量缓存你可以有三档粒度,按性价比从高到低:
- 整行/整串缓存:key 是完整文本加样式组合,value 是测量后的宽度或行信息。命中率取决于业务里重复文本的比例。
- 单字符缓存:对中英文混排的文本,把所有字符的宽度缓存下来,需要算整行宽度时,只要把这些字符的宽度累加,再处理特殊情况(如连续空格、换行点)。
- 区间宽度缓存:类似前缀和,适合大量相同前缀、后缀不同的文本,典型场景是文件树里的路径。
我给 Pretext 第一版实现选的是“整行优先、字符宽度兜底”的两级结构。原因是业务字段大量来自接口枚举,完整字符串的命中率往往比预期高。
一个兼顾命中率和内存的方案是这样的:
| 缓存维度 | 影响结果的变量 | 遗漏后果 |
|---|---|---|
| 文本内容 | 字符序列本身 | 不同文本共用错误宽度 |
| 字体标识 | font-family 的计算结果 | 字体切换后宽度失效 |
| 字号与字重 | font-size、font-weight | 字号不是像素整数时会被缩放 |
| 字间距与行间距 | letter-spacing、line-height | 自动省略号宽度算错 |
3.3 一个可落地的 TextMeasureCache 实现
下面是一段精简但能直接抄走的实现,核心是用 Map 做 LRU,合成一个稳定字符串作为 key:
javascript复制class TextMeasureCache {
constructor(maxEntries = 10000) {
this.cache = new Map();
this.maxEntries = maxEntries;
}
normalizeKey(text, style) {
return [
text,
style.fontFamily,
style.fontSize,
style.fontWeight,
style.fontStyle,
style.letterSpacing,
style.lineHeight
].join('|');
}
get(text, style) {
const key = this.normalizeKey(text, style);
if (this.cache.has(key)) {
// 最近访问的放到 Map 末尾,方便按 LRU 淘汰
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
return null;
}
set(text, style, measuredWidth) {
const key = this.normalizeKey(text, style);
if (this.cache.size >= this.maxEntries) {
// Map 的 keys() 返回顺序是插入序,所以第一个就是最少使用的
this.cache.delete(this.cache.keys().next().value);
}
this.cache.set(key, measuredWidth);
}
}
// 使用示例
const fontStyle = {
fontFamily: '"Inter", "PingFang SC", sans-serif',
fontSize: '14px',
fontWeight: '400',
fontStyle: 'normal',
letterSpacing: '0px',
lineHeight: '20px'
};
function getMeasuredWidth(text, canvas, style, cache) {
const hit = cache.get(text, style);
if (hit !== null) return hit;
// 生产环境建议先用离屏 canvas,避免反复创建
canvas.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
const width = canvas.measureText(text).width;
cache.set(text, style, width);
return width;
}
这个小类在实际项目里非常有用。注意我把 lineHeight 也放进 key,是因为部分浏览器在不同 line-height 下,字符宽度本身不会变,但如果你要缓存的是省略号或者多行文本,行高会影响断行结果。
3.4 缓存失效与内存上限
缓存的败笔通常来自失效时机不对。文字宽度本身和容器没有关系,但和字体加载状态强相关。如果你使用了 font-display: swap,在字体没有加载完成时,浏览器会用 fallback 字体测量文本;等自定义字体加载完成后,所有宽度全部变化。如果你不清空缓存,后面的布局就全乱了。
我们的方案是在页面初始化时挂一个 document.fonts.ready.then(() => cache.clear()),自定义字体切换和主题字体变化的时候也主动 clear 一次。宁可错杀,不可留错值。
缓存上限我建议按条目控制,我用的是 10000 条。每条平均几个字节到大几十字节,一万条基本少于 5MB。对低端机和长期运行的单页应用,这是可以接受的。如果业务文本过于随机、命中率长期低于三成,那缓存本身的价值就会打骨折,这种情况下应该优先去改渲染路径而非扩大缓存。
3.5 缓存命中后的实际收益
在正文内容中加缓存后,我们做了一个小验证:在 8000 行树表里对一个展开节点做过滤,记录从点击到首帧绘制的时间。加缓存前大约 900ms,加缓存后降到 520ms,但其实还不够,因为文本仍然要完整走一遍 Layout。所以我们才继续做第二板斧——真正把文本从 DOM Layout 里拆出来。
4. 第二板斧:自绘文本层,让大表格和日志流绕过 DOM 排版管线
4.1 为什么 Canvas fillText 会更快
很多人一听“用 Canvas 代替 DOM 渲染”就怀疑:Canvas 内部不还是要做 shaping 和光栅化吗?确实,fillText() 也会走字体匹配和字形整形,但它省掉的东西同样可观:不需要创建海量 DOM 节点和相应的内联格式上下文,不参与浏览器布局树,也不需要处理每个 span 的盒模型、层叠样式、焦点和选中状态。当文本量达到千级甚至万级,省下的就是海量 Layout/Paint 开销。
在虚拟滚动表格场景里,用户真正能看到的区域通常只有一两百个单元格。让这些单元格逐个生成 <div> 再排版,和直接在一个 300x300 的 canvas 上画几十个字符串相比,后者有着数量级的优势。Canvas 绘制指令进入浏览器光栅管线的路径更短,主线程 JavaScript 和渲染进程之间的通信也更扁平。
4.2 自绘文本层的适用边界
自绘不是银弹,一上来就全站 canvas 只会制造灾难。Pretext 对“文本是否可以自绘”的判断条件有三个:
- 文本不需要用户选中、复制和拖动。哪怕只是偶尔需要复制,你也得预留一套 DOM 隐藏方案。
- 文本的断行规则简单可控。比如表格单元格的单行文本、自动省略号、日志面板的多行文本;富文本里环绕浮动、嵌套加粗、链接点击这种交给 DOM 更合适。
- 变化的文本量级足够大。如果整页只有十几个动态数字,自绘的初始化成本和维护成本会超过收益。
如果你的场景同时满足,就可以尝试把这块文本做成一个位图图层。
4.3 实现一个带脏矩形更新的文本图层
这里给出一个最小实现,它的能力是:在一个 canvas 图层上维护一批文本,每次只重绘真正变化的位置。为了避免高 DPI 屏幕下文字发虚,canvas 的物理像素按 devicePixelRatio 放大。
javascript复制class BitmapTextField {
constructor(container, width, height) {
this.container = container;
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.dpr = window.devicePixelRatio || 1;
this.width = width;
this.height = height;
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
this.canvas.width = Math.round(width * this.dpr);
this.canvas.height = Math.round(height * this.dpr);
this.ctx.scale(this.dpr, this.dpr);
container.appendChild(this.canvas);
}
clear() {
this.ctx.clearRect(0, 0, this.width, this.height);
}
drawText(text, x, y, fontStyle, color, maxWidth) {
this.ctx.font = `${fontStyle.fontWeight} ${fontStyle.fontSize} ${fontStyle.fontFamily}`;
this.ctx.fillStyle = color;
if (maxWidth) {
// 如果超过最大宽度,手动截断并追加省略号
const measured = this.ctx.measureText(text).width;
if (measured > maxWidth) {
let low = 0;
let high = text.length;
const ellipsis = '…';
while (low < high) {
const mid = Math.ceil((low + high) / 2);
const sub = text.slice(0, mid) + ellipsis;
if (this.ctx.measureText(sub).width <= maxWidth) {
low = mid;
} else {
high = mid - 1;
}
}
text = text.slice(0, low) + ellipsis;
}
}
this.ctx.fillText(text, x, y);
}
destroy() {
this.canvas.remove();
}
}
这个类的关键点有两个。一是 dpr 缩放:忽略它,文字会发虚,业务方一定不满意;但如果你做了缩放,任何一次改 canvas 尺寸都会清空画布,所以尺寸最好在构造时确定,不要频繁改。二是 省略号逻辑不要依赖 CSS 的 text-overflow:canvas 没有内置的省略号算法,我们这里用了二分截断,配合上一节的测量缓存后,性能是足够的。
什么场景可以用这个类?我建议优先处理表格里的“固定列”——列头、首列编号、状态列这类几乎不交互但又数量庞大的文本。滚动时你可以只改变图层的位置,而不必重新布局所有单元格。
4.4 和 DOM 混合的“文本位图化”策略
在用 canvas 自绘整张表之前,先尝试一种轻量做法:把短时间内不会变化、也不参与交互的长文本预先画到一个离屏 canvas 上,再把这张 canvas 当作背景图塞到对应的 DOM 区块里。文本变化时才重绘背景图,平时滚动只动 transform。
这种做法的收益非常直观:
- 文本“位图化”后,DOM 树里的文本节点数量锐减,Layout 和 Paint 成本被一次性摊销。
- 滚动时只移动整块位图的位置,不触发文本 layout。配合
will-change: transform,它可以跑在合成器线程上,主线程压力很小。 - 如果某些单元格需要点击交互,你可以在位图上叠一个透明的 DOM 层,用坐标命中测试找到对应文本行。
不要一上来把所有单元格都位图化。我们发现,位图化和虚拟滚动结合才是效果最好的组合:可见区域内保留少量真实 DOM,离屏外的大批量文本全部用位图渲染。这样既有虚拟滚动的边界控制,又有位图化的低成本优势。
5. 第三板斧:增量调度,把文本布局从“交互帧”挪到“空闲帧”
5.1 问题:不是布局太慢,而是布局总撞上用户交互
自绘文本层解决了很多展示场景,但总有一些文本必须留在 DOM 里:富文本内容、可编辑区域、需要复杂换行或无障碍阅读的正文。在对这些区域做优化的过程中,我最大的体会是,很多卡顿的根源不是 Layout 单次执行太久,而是它总是发生在用户最敏感的那一帧里。
虚拟列表里有个常见写法:用户滚动一下,回调里立刻取出新的可视区间,然后同步计算所有可见行的位置,把所有行重新 setState 一次。在低速设备上,这个“同步计算 + 同步布局”的时间非常容易超过 50ms,直接制造长任务。Pretext 的做法是调度:布局任务不应该和用户手势同步绑定,而是按优先级切碎,放到相邻的空闲帧里。
5.2 requestAnimationFrame 与 requestIdleCallback 的分工
我们设计了一个简单但有效的调度器,核心规则是:
- 渲染新出现的数据行用
requestAnimationFrame驱动,保证视觉上不闪烁。 - 计算下一屏预加载区域的行高、断行、节点复用等“不紧急但必须做”的任务,放到
requestIdleCallback里,如果用户又滚动,则立即停止这部分计算。 - 每一帧最多处理一个批次的文本测量,完全处理完一行或一个单元格就算任务结束,绝不在一帧里把整表扫完。
原型大概是:
javascript复制const scheduler = {
idleQueue: [],
isScheduled: false,
pushIdleTask(task) {
this.idleQueue.push(task);
if (!this.isScheduled) this.schedule();
},
schedule() {
this.isScheduled = true;
// 浏览器不支持的降级为 setTimeout
if ('requestIdleCallback' in window) {
requestIdleCallback((deadline) => this.run(deadline), { timeout: 2000 });
} else {
setTimeout(() => {
const deadline = { timeRemaining: () => 16 };
this.run(deadline);
}, 0);
}
},
run(deadline) {
while (
this.idleQueue.length &&
deadline.timeRemaining() > 2
) {
const task = this.idleQueue.shift();
task();
}
if (this.idleQueue.length) {
this.isScheduled = false;
this.schedule();
} else {
this.isScheduled = false;
}
}
};
使用起来很简单:当表格需要做预加载行高计算时,不要同步去做,而是推进队列里。注意我需要提醒一点:requestIdleCallback 在低优先级任务中非常合适,但千万别把用户已经能看到的内容也丢给它,否则会出现白屏和空窗感。应该先 rAF 渲染可视区,后 idle 补充预加载区。
5.3 CSS 的布局隔离和现代属性辅助
调度做的是“什么时候算”,CSS 的 content-visibility 则能告诉浏览器“哪里根本不用算”。在非可视区域批量渲染文本时,这个属性可以帮我们省掉全部视觉区外的布局和绘制:
css复制.text-block {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
content-visibility: auto 让浏览器跳过视口外元素的布局、绘制,和 canvas 位图化不同的是,它保留了 DOM、语义和可访问性,代价是每屏进入视口时可能有一次轻微的渲染成本。配合 contain-intrinsic-size 为浏览器预留的高度估计,可以避免“滚动条忽长忽短”的副作用。
Pretext 的完整形态里,这条 CSS 往往和其他两板斧一起使用:content-visibility 减少离线区文本的布局;位图化固定列避免高频 transform 时出现布局抖动;requestIdleCallback 把文本行测量的剩余任务切成小批。它们并不互斥,而是从三个不同层面减少主线程承担的排版成本。
5.4 Worker 里能做多少文本布局?
既然主线线程这么挤,很多人第一反应是把测量搬到 Web Worker 里。这是一个很好的本能,但要小心边界。标准 Web Worker 里没有 document,也访问不到 DOM 的 layout 信息;但如果浏览器支持 OffscreenCanvas 及其 2D context,你可以在 Worker 里创建一块离屏画布并调用 measureText(),从而把“纯测量”和“布局计算”真正移出主线程。
我在实践中并不建议把全量布局算法移植到 Worker,因为有些文本断行逻辑依赖容器的实际宽度变化和字体加载状态,全部同步到 Worker 反而引入复杂度。更合理的方式是分场景:
- 文本的固定宽度测量、按枚举字符串的宽高计算,这种计算和视图无关,适合放进 Worker。
- 和容器尺寸变化、DOM 结构内联影响相关的结果,留在主线程里做增量,用缓存去摊薄成本。
Worker 是 Pretext 可选的一步,不是必选项。在没有 OffscreenCanvas 的旧浏览器上,把测量放回主线程异步执行也可以,只要已经做了缓存和调度,性能差异通常还在可控范围内。
6. 大规模文本场景下的“手术式”改造实录
6.1 案例一:组织架构树表
这是个老套的树形表格,每一行有名称、部门、层级编号。由于部门名称可能很长,树表开启自动省略和行内换行。10000 行展开时,整个表格在低端机上的操作都像在慢动作一样。
第一步,我们只给所有单元格的宽度测量加上缓存,没有动渲染结构。过滤按钮从 860ms 降到 490ms,这说明文本测量在里面确实占了不小比例。
第二步,我们引入虚拟滚动,让表格只创建视口内的行。这次优化让首帧滚动跟上手指,但按下 PageDown 快速翻页时依然出现掉帧。
第三步,把最左侧固定列(部门名称)的文本做成了位图图层。因为固定列需要一直停在左侧,我们平时滚动只更新它的 y 偏移,内容变化时才重绘。做完这一步,PageDown 才能基本稳定保持在 45fps 以上。
这颗树表最终的效果,是在触屏设备上流畅滚动 10000 行,CPU 占用比最初的 DOM 方案低了近一半。留下的教训是:性能优化一定要有顺序,先测量,再缓存,后做渲染路径手术。 跳步骤会导致后续改动带着不可预期的副作用。
6.2 案例二:实时日志流面板
日志面板是另一个典型场景:后端通过 WebSocket 每秒钟推来几十到几百条文本日志,前端传统做法是把新日志 push 到数组,然后重新渲染整个列表。这样每条新日志都会导致历史几千条日志全部走一遍布局。
我们用增量方式重构:
- 日志列表不再重渲染全量,而是只向虚拟列表的可见区域追加新条目。
- 每一条日志的文本宽度进入测量缓存,日志内容字段多数来自固定输出模式,命中率相当高。
- 新增日志的行高计算被放进
requestIdleCallback,而不是在数据到达的同一帧同步算完。 - 开启
content-visibility: auto后,历史日志区完全跳过布局和绘制。
改造前,日志推送到渲染完成可能有 3 到 5 秒的堆积;改造后,即使每秒推送 200 条,最新日志也能在 100ms 内出现,历史区域滚动依然平滑。
6.3 怎么量化文本布局优化的收益
不要只靠肉眼感觉“好像流畅了”,量化指标一定要明确。我在 Pretext 实践中用到的关键指标是:
| 指标 | 测量方式 | 观察目标 |
|---|---|---|
| FPS | DevTools 渲染标签页的 FPS 图表 | 滚动和更新过程是否掉帧低于 30 |
| 长任务次数 | Performance Observer 里的 Longtask | 单任务是否超过 50ms |
| Layout 占用率 | Performance 录制的 Summary | Layout 百分比是否从 30% 以上降到 15% 以下 |
| 过滤/展示首帧耗时 | 业务自定义埋点 | 交互到首帧可用时间 |
| 命中率与内存 | Pretext 内部统计 | 测量缓存命中率需高于 60%,缓存内存低于预算 |
当时我们用这套指标记录了改造前后差异,Layout 占比从 37% 掉到 11%,首开过滤耗时从约 900ms 掉到约 200ms。但这些数字只在我们的测试场景里有效,你必须在自己的项目里建立基线,连续测 50 次以上再下结论。
7. 避坑清单:Pretext 最容易翻车的几个地方
7.1 DOM 和 Canvas 的测量结果不一致
同一个文本,在 DOM 里渲染成 200px,在 canvas 的 measureText 里可能是 198px 或 201px。差异来自两方面:一个是字体回退的上下文差异,另一个是不同排版引擎对 shaping 的微调。所以,千万不要把 DOM 渲染用的省略号判断直接拿去给 canvas 用,也不要反过来。
我们的经验是,如果文本最终要交给 canvas 绘制,那测宽和画字必须使用同一个 canvas context;如果要留给 DOM,就用 DOM 自己的 Range.getBoundingClientRect。两边混用会导致大量“差 1px 导致的意外换行”。
7.2 自定义字体加载造成的宽度雪崩
字体加载会让文本宽度产生跳变,这是所有基于测量缓存的方案里最隐蔽的坑。我们在测试页面用了自定义字体,开发机字体加载快,没发现问题;客户机器上字体加载慢,首屏文字先显示 fallback 字体,等自定义字体加载完,整个表格的行列宽度突然重新计算,表格抖动肉眼可见。
处理方式有两种路线。最稳妥的是在字体加载完成之前,就按 fallback 字体的宽度进行布局,不追求精确,加载完成后一次性清缓存并重新布局。更激进但可用的是用 CSS font-display: block 配合短超时,宁可白屏一小段,也不想布局反复横跳。前端团队应该从业务感受角度选择,我会优先用前者,因为它不会制造不可用的空白期。
7.3 emoji 和特殊字符的行高炸弹
在部分 Android 机型上,一段包含 emoji 的短文本会被排版引擎赋予巨大的行高,导致原本设定的 20px 行高突然变成 60px。这是因为系统 emoji 字体本身的 ascent/descent 超过了普通字体。放在自绘文本层里时,可以通过强设 textBaseline 和行高来压制视觉影响;放在 DOM 里时,需要给文本容器加 line-height: 1.2 并且为 emoji 单独设置一个较小尺寸的内联字体。
这个坑很难通过性能优化解决,只能靠样式兜底。
7.4 文本截断不要用 substring 按码点切
给 canvas 做省略号截断时,如果文本里包含中文字符、emoji 或代理对,按 substring(0, mid) 来二分很容易在字符中间切断,产生乱码或者奇怪的白块。更稳妥的方式是把字符串先转成代码点或利用 Intl.Segmenter 按字素切分,再做二分。
如果浏览器支持,可以直接用 Array.from(text) 把字符串转成 Unicode 码点数组再截断。文本量非常大时,这个转换会有成本,但它避免了渲染出不可读文字的严重问题。
7.5 位图化可能伤害表格的可访问性
把整列表格全部画到 canvas 后,屏幕阅读器完全读不到文本内容,这是一个很现实的无障碍风险。如果业务面向公共用户或有合规要求,自绘文本层必须搭配隐藏的语义 DOM 或使用 ARIA。我们在 Pretext 实践里做的妥协是:交互密集的表格保留“真实 DOM 模式”作为默认,仅当数据量突破阈值且用户选择开启“高性能模式”时才切换到位图加速。
7.6 缓存无上限导致低端机内存告急
之前说过缓存要做 LRU,但很多人实现时图省事直接用一个普通对象无限缓存。真实业务里的动态字段、拼接字符串、时间戳格式化结果,会让缓存无限制膨胀,低端机上比不做优化还惨。我们可以顺带在缓存 stats 里记录命中次数,每隔一分钟把连续一段时间没命中的键淘汰掉。
8. 一些不一定写进书里的经验
性能优化做了这么多年,最想说的一句是:别被热点技术忽悠,先弄清楚自己的文本是否真的是瓶颈。很多项目的“卡顿”其实来自网络请求、图片解码或者复杂的业务 JS,直接照搬 Pretext 自绘文本层,造成接入成本增加不说,还容易把 DOM 的可访问性和 SEO 优势弄丢。
我实际推动 Pretext 落地时,最有效的方式不是写酷炫的技术文档,而是先跑一个几十行代码的测量脚本,把 Layout 成本占比拍在业务负责人面前。性能优化最怕“大家都觉得应该做,但又说不清收益”,用数据说话比什么方案都靠谱。
另外一个小技巧:当你想判断某个优化是否有效时,
