前端性能优化,我们习惯关注网络请求、图片压缩、代码分割。但有一个性能杀手藏得很深——文本测量触发的 Layout Reflow

当你调用 getBoundingClientRect()、读取 offsetHeight,或者在循环里频繁查询 DOM 尺寸,浏览器被迫执行强制同步布局。这是一次昂贵的"停顿",主线程阻塞,页面卡顿,用户体验崩塌。

上周,一个叫 Pretext 的库悄然发布,3周内斩获 28,000+ Star。它用纯 JavaScript 解决了这个困扰前端开发多年的难题。


传统文本测量的困境

先看一个常见场景:虚拟列表。

虚拟列表的核心在于"预知高度"。你需要知道每个列表项的高度,才能计算滚动位置、决定渲染哪些项。

问题来了:动态内容的高度无法预知

一段文本在不同宽度下会换行,高度随之变化。传统做法是:

// 先渲染到 DOM
element.textContent = text;
element.style.width = `${containerWidth}px`;

// 再读取高度 —— 触发 reflow
const height = element.offsetHeight;

这有什么问题?

Layout Reflow 是浏览器最昂贵的操作之一。 它需要重新计算整个页面的布局树,代价与 DOM 复杂度成正比。在虚拟列表场景,你可能需要测量几十上百个元素,每次测量都是一次性能惩罚。

更糟的是,当你需要响应式布局——窗口宽度变化时重新测量——性能问题会被放大。瀑布流、AI 流式输出、富文本编辑器,这些场景都逃不开文本测量。


Pretext 的解法:绕过 DOM,纯算法测量

Pretext 的核心思路很简单:不触碰 DOM,用纯 JavaScript 计算文本高度。

它的原理是利用 Canvas 的 measureText API 获取字符宽度,结合自己的换行算法,直接算出文本在不同宽度下的高度。

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

// 1. 预处理:分析文本结构,测量字符宽度
const prepared = prepare('这是一段测试文本', '16px "Microsoft YaHei"')

// 2. 布局计算:纯算术运算,零 DOM 操作
const { height, lineCount } = layout(prepared, 320, 24)
// height: 48, lineCount: 2

关键在于两阶段分离:

  • prepare():一次性分析文本,缓存字符宽度
  • layout():纯算术运算,根据容器宽度计算高度

这意味着:窗口 resize 时,只需要重新执行 layout(),耗时从毫秒级降到微秒级。


实战演示:零 DOM 读取的响应式布局

下面是一个基于 Lit + Pretext 的实战示例,展示如何在不触碰 DOM 的情况下实现响应式高度计算:

import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { prepare, layout } from '@chenglou/pretext';

const textContent = '春天到了,万物复苏。这是一段用于测试纯算法预测高度的文本。随着宽度的改变,它的行数和高度也会随之发生改变,且全程不会触发基于DOM读取的强制重排。';

@customElement('pure-algo-element')
export class PureAlgoElement extends LitElement {
  @state() containerWidth: number = 400;
  @state() predictedHeight: number = 0;
  @state() predictedLines: number = 0;

  // 预处理:只要文本和字体不变,只做一次
  private preparedText = prepare(textContent, '16px "Microsoft YaHei"');

  connectedCallback() {
    super.connectedCallback();
    this.calculateLayout(this.containerWidth);
  }

  // 纯内存计算,不触碰真实 DOM
  calculateLayout(width: number) {
    const { height, lineCount } = layout(this.preparedText, width, 24);
    this.predictedHeight = height;
    this.predictedLines = lineCount;
  }

  handleWidthChange = (e: Event) => {
    const newWidth = Number((e.target as HTMLInputElement).value);
    this.containerWidth = newWidth;
    console.time("算法执行耗时");
    this.calculateLayout(newWidth);
    console.timeEnd("算法执行耗时");
  }

  render() {
    return html`
      <div class="container">
        <input type="range" min="100" max="500"
               .value=${this.containerWidth}
               @input=${this.handleWidthChange} />
        <div class="result">
          高度: ${this.predictedHeight}px | 行数: ${this.predictedLines}行
        </div>
        <div class="text-box" style="width: ${this.containerWidth}px; height: ${this.predictedHeight}px;">
          ${textContent}
        </div>
      </div>
    `;
  }
}

运行效果:

在这里插入图片描述

拖动滑块改变宽度,高度实时计算,整个过程零 DOM 读取。


性能对比:差距肉眼可见

官方 benchmark 数据:

操作耗时
prepare() (500段文本)~19ms
layout() (500段文本)~0.09ms

我们用实际代码测试,单次 layout() 执行耗时:

在这里插入图片描述

注意:这只是单个 DOM 元素的差距。 在真实场景中,当页面有几十上百个需要测量的元素时,性能差距会被放大数十倍。

传统方案的问题在于:

循环 N 个元素 {
  渲染到 DOM
  读取 offsetHeight ← 触发 reflow
}

每次读取都触发一次 reflow,N 个元素就是 N 次昂贵的布局计算。

Pretext 的方案:

循环 N 个元素 {
  调用 layout() ← 纯算术运算
}

N 次计算,但全是内存操作,无任何 reflow。


三大核心场景

场景一:虚拟列表

虚拟列表的核心难题:如何预知动态内容的高度?

传统方案需要"先渲染再测量",导致:

  1. 初始渲染闪烁
  2. 滚动位置跳动
  3. 频繁 reflow

使用 Pretext:

// 列表初始化时,预处理所有文本
const items = data.map(item => ({
  ...item,
  prepared: prepare(item.text, '14px Inter')
}))

// 根据容器宽度,计算每个 item 高度
function getItemHeight(index: number, width: number): number {
  const { height } = layout(items[index].prepared, width, 20)
  return height
}

完美虚拟化,零闪烁,零 reflow。

场景二:瀑布流布局

瀑布流需要计算每列的高度,决定下一个元素放在哪一列。

传统做法是用 CSS column-count 或 JS + DOM 测量。前者不够灵活,后者性能堪忧。

Pretext 提供了 layoutNextLine() API,支持变宽度逐行布局

import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext'

const prepared = prepareWithSegments(text, '14px Inter')
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0

while (true) {
  // 不同行可以有不同的宽度!
  const width = y < imageHeight ? columnWidth - imageWidth : columnWidth
  const line = layoutNextLine(prepared, cursor, width)
  if (!line) break

  // 渲染这一行
  renderLine(line.text, y)
  cursor = line.end
  y += lineHeight
}

这让文字环绕图片、复杂网格布局成为可能,而且全是纯 JS 计算,无任何 DOM 依赖

场景三:AI 流式输出

ChatGPT 类应用的核心体验:流式输出文本。问题在于:

  1. 文本不断增长,高度不断变化
  2. 需要实时调整滚动位置
  3. 频繁的 DOM 测量 = 频繁的 reflow

使用 Pretext 的方案:

// 流式输出时,增量计算高度
function onNewToken(token: string) {
  fullText += token

  // 只需要重新执行 layout,不需要重新 prepare
  const { height } = layout(prepared, containerWidth, lineHeight)

  // 更新容器高度,保持滚动锚点
  updateContainerHeight(height)
}

高频更新场景下,layout() 的微秒级延迟 vs DOM 测量的毫秒级延迟,差距是数量级的。


API 速览

Pretext 提供两套 API:

场景一:只关心高度

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

const prepared = prepare(text, '16px Inter')
const { height, lineCount } = layout(prepared, maxWidth, lineHeight)

场景二:需要逐行控制

import { prepareWithSegments, layoutWithLines, walkLineRanges, layoutNextLine } from '@chenglou/pretext'

const prepared = prepareWithSegments(text, '16px Inter')

// 获取所有行信息
const { lines } = layoutWithLines(prepared, maxWidth, lineHeight)

// 遍历行范围(不构建文本字符串)
walkLineRanges(prepared, maxWidth, line => {
  console.log(line.width, line.start, line.end)
})

// 逐行布局(支持变宽度)
const line = layoutNextLine(prepared, cursor, width)

注意事项

Pretext 目前支持的 CSS 属性:

  • white-space: normalpre-wrap
  • word-break: normal
  • overflow-wrap: break-word
  • line-break: auto

不支持的场景:

  • 复杂的 CSS 文本样式(如 text-shadow 影响布局)
  • word-break: break-all 等特殊断词规则
  • 使用 system-ui 字体(macOS 上精度有问题,建议用命名字体)

总结

Pretext 解决的是一个"不起眼但很痛"的问题——文本测量的性能开销。

它的价值在于:

  1. 性能提升:将文本测量从 O(DOM复杂度) 降到 O(1)
  2. 解锁场景:虚拟列表、瀑布流、AI 流式输出,都可以做到真正的"预知高度"
  3. 开发体验:告别 CSS hack,用 JS 精确控制布局

适用场景:

  • 需要预知动态文本高度的虚拟列表
  • 瀑布流、网格布局等复杂排版
  • AI 流式输出、实时编辑器等高频更新场景
  • 需要在服务端计算文本高度(未来支持)

如果你的项目有类似痛点,值得尝试。

GitHub: https://github.com/chenglou/pretext


参考资料

  • Pretext 官方文档
  • MDN: Layout Thrashing
  • Sebastian Markbage 的 text-layout 设计思想
Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐