Skip to content

Pretext

https://pretextjs.net/zh

一、Pretext.js 是什么?

Pretext.js 在不触达 DOM 的情况下,快速准确地计算一段文本在特定字体和宽度下的高度、行数等布局信息,完全避免使用 getBoundingClientRectoffsetHeight 等会触发浏览器重排(Reflow)的昂贵操作。

二、核心原理:冷路径与热路径分离

Pretext 的核心设计思路基于一个洞察:文本布局计算可以拆分为两个阶段,其中只有第一阶段需要"昂贵"的测量,第二阶段则可以纯算术完成。

传统 DOM 测量:            Pretext 两阶段:
每次测量都触发重排         prepare() 一次测量 + 缓存
↓                         ↓
昂贵 + 卡顿                layout() 纯算术计算
                          微秒级响应

阶段一:prepare() —— 冷路径(一次性重活)

prepare() 执行一次性的文本分析与字符测量,相对"昂贵"但只运行一次。主要工作包括:

  • 文本规范化:统一处理空白字符、制表符、换行符
  • 分段(Segmentation) :使用 Intl.Segmenter 将文本按语义拆分成可换行的最小单元(词、字符、emoji 等),遵循 Unicode 标准中的断字规则(UAX #14),正确处理混合方向文本
  • 字符测量:调用 Canvas measureText API 测量每个"段"的宽度,这是整个过程中唯一一次调用浏览器原生测量能力的地方
  • 缓存结果:所有测量结果打包成一个不透明的句柄(opaque handle),供后续复用

prepare() 的性能表现优异,以 500 段文本的批处理为例,预处理时间约为 19 毫秒

阶段二:layout() —— 热路径(纯算术运算)

layout() 接收 prepare() 返回的句柄,以及目标宽度和行高参数,在内存中通过纯加减乘除的算术运算模拟换行过程。它完全不依赖任何浏览器 API,因此可以做到微秒级别的响应速度,非常适合高频调用场景(如窗口缩放、滚动等)。

关键要求prepare() 传入的 font 字符串,必须与最终 CSS 中实际使用的字体声明完全一致(包括字体族名称、字重、字号等),否则测量结果会出现偏差。

javascript
// ✅ 正确:CSS 和 prepare() 字体声明一致
// CSS: .text { font: 16px "Inter", sans-serif; }
const prepared = prepare(text, '16px "Inter", sans-serif');

// ❌ 错误:字体声明不一致会导致测量偏差
const prepared = prepare(text, '16px system-ui');

三、安装与引入

bash
pnpm install @chenglou/pretext
javascript
import {
  prepare,
  layout,
  prepareWithSegments,
  layoutWithLines,
} from '@chenglou/pretext';

四、API 参考

4.1 prepare() + layout()

这是最常用的基础 API,覆盖绝大多数业务场景。

prepare(text: string, font: string, options?: PrepareOptions): PreparedHandle

对文本进行预处理和测量,返回一个可复用的"句柄"。

参数类型说明
textstring待测量的文本内容
fontstring字体声明,必须与 CSS 中实际使用的字体完全一致(如 '16px "Inter", sans-serif'
optionsPrepareOptions可选配置(见下方表格)
javascript
const handle = prepare(
  'Hello Pretext! 这是一段文本。',
  '16px "Inter", sans-serif'
);

options 可选配置:

属性类型默认值说明
whiteSpace'normal' | 'pre' | 'pre-wrap' | 'pre-line' | 'nowrap''normal'空白字符处理策略
languagestring自动检测指定文本语言,用于优化分段和断行规则
rtlbooleanfalse是否为从右到左书写方向的文本

layout(handle: PreparedHandle, maxWidth: number, lineHeight: number): LayoutResult

基于预处理句柄,计算在指定容器宽度下的布局结果。这是一个纯算术运算,速度极快。

参数类型说明
handlePreparedHandleprepare() 返回的句柄
maxWidthnumber容器最大宽度(像素)
lineHeightnumber行高(像素)
javascript
const { height, lineCount } = layout(handle, 300, 24);
console.log(`文本高度: ${height}px, 行数: ${lineCount}`);

LayoutResult 返回值:

属性类型说明
heightnumber文本块总高度(像素)
lineCountnumber总行数

代码示例:

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

// 1. 预处理(只执行一次)
const handle = prepare('AGI 春天到了。بدأت الرحلة', '16px "Inter", sans-serif');

// 2. 在不同容器宽度下快速计算布局
const resultMobile = layout(handle, 280, 24); // 手机屏幕
const resultTablet = layout(handle, 540, 26); // 平板屏幕
const resultDesktop = layout(handle, 800, 28); // 桌面屏幕

console.log(
  `手机: ${resultMobile.lineCount}行, 平板: ${resultTablet.lineCount}行`
);

4.2 prepareWithSegments() + layoutWithLines() / layoutNextLine()

当需要逐行渲染(如 Canvas 绘制、SVG 文本、自定义环绕排版等)时,可以使用高级 API 获取每一行的精确内容和位置坐标。

prepareWithSegments(text: string, font: string, options?: PrepareOptions): PreparedSegments

prepare() 类似,但返回更细粒度的分段数据,便于逐行布局。

layoutWithLines(handle: PreparedSegments, maxWidth: number, lineHeight: number): LinesResult

基于 prepareWithSegments() 返回的句柄,返回包含每一行详细信息的布局结果。

javascript
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

const segments = prepareWithSegments(longText, '16px "Inter"');
const layout = layoutWithLines(segments, 400, 24);

// 遍历每一行,进行自定义渲染
for (const line of layout.lines) {
  console.log(line.segments); // 该行包含的文本片段数组
  console.log(line.width); // 该行的实际宽度
}

layoutWithLines() 返回的 LinesResult 结构:

属性类型说明
linesArray<Line>每一行的详细信息数组
heightnumber文本块总高度
lineCountnumber总行数

每个 Line 对象包含:

属性类型说明
segmentsArray<Segment>该行包含的文本片段列表
widthnumber该行的实际宽度(像素)

layoutNextLine(segments: PreparedSegments, startIndex: number, maxWidth: number): NextLineResult

流式布局 API,支持按行逐步计算布局,适合需要动态追加内容或实现无限滚动场景。

javascript
let currentIndex = 0;
let allLines = [];

while (currentIndex < segments.totalLength) {
  const lineResult = layoutNextLine(segments, currentIndex, 400);
  allLines.push(lineResult);
  currentIndex = lineResult.nextStartIndex;
}

NextLineResult 返回值:

属性类型说明
segmentsArray<Segment>当前行的文本片段列表
widthnumber当前行的宽度
nextStartIndexnumber下一行的起始索引位置

五、典型使用场景

1. 虚拟滚动列表中的文本高度预测

在虚拟滚动列表中,Pretext 可以在渲染前精确计算出每一项的高度,避免滚动条跳动,实现 60fps 的流畅滚动。

javascript
// 为列表中的每条消息预计算高度
const items = messages.map(msg => ({
  data: msg,
  handle: prepare(msg.text, '14px system-ui'),
  height: null, // 高度将在布局时计算
}));

function updateItemHeights(containerWidth) {
  for (const item of items) {
    const { height } = layout(item.handle, containerWidth - 32, 20);
    item.height = height;
  }
  // 重新计算虚拟列表的总高度和可见范围
}

2. 实时 AI 聊天界面

在 AI 流式输出文本的场景中,Pretext 可以在内容完全渲染前预计算文本块的高度,防止气泡在内容流入时发生跳跃或偏移。

javascript
let streamHandle = null;
let streamBuffer = '';

function onStreamChunk(chunk) {
  streamBuffer += chunk;
  // 流式更新时重新计算布局
  if (!streamHandle) {
    streamHandle = prepare(streamBuffer, '16px system-ui');
  } else {
    // 注意:文本变化时需要重新 prepare(此处仅示意)
    streamHandle = prepare(streamBuffer, '16px system-ui');
  }
  const { height } = layout(streamHandle, maxWidth, 24);
  updateBubbleHeight(height);
}

💡 提示:在流式场景中,每次 prepare 会重新执行测量,建议采用节流策略控制调用频率。

3. Canvas/SVG 自定义文本渲染

结合高级 API,可以将文本逐行绘制到 Canvas 上。

javascript
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';

const ctx = canvas.getContext('2d');
const segments = prepareWithSegments(text, '16px system-ui');
const layout = layoutWithLines(segments, 400, 24);

let y = 0;
for (const line of layout.lines) {
  let x = 0;
  for (const seg of line.segments) {
    ctx.fillText(seg.text, x, y);
    x += seg.width;
  }
  y += 24;
}

六、注意事项与最佳实践

字体配置一致性

传递给 prepare()font 字符串必须与 CSS 中实际使用的字体声明完全一致。差异会导致测量结果与实际渲染高度不符。

何时需要重新 prepare()

prepare() 的结果仅在以下条件保持不变时有效:

  • 文本内容不变
  • 字体声明不变
  • 文本语言/书写方向不变

以下情况必须重新调用 prepare()

  1. 文本内容发生变化(如用户输入、新消息)
  2. 字体声明发生变化(如字号、字体族、字重)
  3. 容器样式中的 whiteSpace 策略发生变化

layout() 可以无限次复用

只要 prepare() 返回的句柄不变,layout() 可以在不同 maxWidthlineHeight无限次复用,这是实现高性能响应式布局的关键。

字体加载时机

如果使用了自定义字体或 Web Font,需要等待字体加载完成后再调用 prepare(),否则测量结果可能使用 fallback 字体。可以使用 Font Loading API 或 document.fonts.ready

javascript
await document.fonts.ready; // 等待所有字体加载完成
const handle = prepare(text, '16px "CustomFont", sans-serif');

文字环绕图片

图片位置: (50, 20)

图片尺寸: 150 x 120

文本行数: 0

容器宽度: 600px

html
<template>
  <div class="text-container">
    <!-- 可拖拽图片,使用绝对定位 -->
    <img
      :src="imageUrl"
      class="draggable-image"
      :style="{
        left: imagePos.x + 'px',
        top: imagePos.y + 'px',
        width: imageSize.width + 'px',
        height: imageSize.height + 'px'
      }"
      draggable="false"
      @mousedown="startDrag"
    />

    <!-- 文本层,每行文字使用绝对定位 -->
    <div class="text-layer">
      <span
        v-for="(line, index) in renderedLines"
        :key="index"
        class="text-line"
        :style="{
          top: line.y + 'px',
          left: line.x + 'px'
        }"
      >
        {{ line.text }}
      </span>
    </div>
  </div>
</template>
js
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext';

// 1. 预处理文本:一次性分析文本,测量字形宽度
// prepareWithSegments 返回的结构包含分段信息,用于后续逐行布局
const preparedText = prepareWithSegments(text, '16px system-ui');

// 2. 计算环绕布局的核心算法
function calculateLayout() {
  const lines = [];
  const imgLeft = imagePos.x;
  const imgRight = imagePos.x + imageSize.width;
  const imgTop = imagePos.y;
  const imgBottom = imagePos.y + imageSize.height;

  // 计算图片两侧可用宽度
  const leftWidth = imgLeft;
  const rightWidth = containerWidth - imgRight;
  const hasLeftArea = leftWidth >= 30;
  const hasRightArea = rightWidth >= 30;

  let cursor = { segmentIndex: 0, graphemeIndex: 0 };
  let y = 0;

  while (true) {
    const lineBottom = y + lineHeight;
    // 判断当前行是否与图片重叠
    const overlapsImage = y < imgBottom && lineBottom > imgTop;

    if (!overlapsImage) {
      // 不与图片重叠:使用全宽布局
      const line = layoutNextLine(preparedText, cursor, containerWidth);
      if (line === null) break;
      lines.push({ text: line.text, x: 0, y: y });
      cursor = line.end;
    } else {
      // 与图片重叠:实现环绕效果
      if (hasLeftArea && hasRightArea) {
        // 两侧都有空间:左侧和右侧各渲染一行
        const leftLine = layoutNextLine(preparedText, cursor, leftWidth);
        if (leftLine === null) break;
        lines.push({ text: leftLine.text, x: 0, y: y });

        // 右侧行从左侧行的结束位置继续
        const rightLine = layoutNextLine(
          preparedText,
          leftLine.end,
          rightWidth
        );
        if (rightLine !== null) {
          lines.push({ text: rightLine.text, x: imgRight, y: y });
          cursor = rightLine.end;
        } else {
          cursor = leftLine.end;
        }
      } else if (hasLeftArea) {
        // 只有左侧有空间
        const line = layoutNextLine(preparedText, cursor, leftWidth);
        if (line === null) break;
        lines.push({ text: line.text, x: 0, y: y });
        cursor = line.end;
      } else if (hasRightArea) {
        // 只有右侧有空间
        const line = layoutNextLine(preparedText, cursor, rightWidth);
        if (line === null) break;
        lines.push({ text: line.text, x: imgRight, y: y });
        cursor = line.end;
      }
    }
    y += lineHeight;
  }
  return lines;
}

// 3. 拖拽处理:更新图片位置后重新计算布局
function onDrag(e) {
  imagePos.x = e.clientX - dragOffset.x;
  imagePos.y = e.clientY - dragOffset.y;
  calculateLayout(); // 实时重新计算文字布局
}
css
.text-container {
  position: relative;
  min-height: 400px;
  background: white;
}

/* 图片使用绝对定位,可自由拖拽 */
.draggable-image {
  position: absolute;
  cursor: move;
  user-select: none;
  z-index: 10;
}

/* 文本层作为定位容器 */
.text-layer {
  position: relative;
  width: 100%;
}

/* 每行文字使用绝对定位,由 JS 计算位置 */
.text-line {
  position: absolute;
  white-space: pre;
  font-size: 16px;
  line-height: 24px;
}