一、什么是Pretext

在 Web 前端开发中,"文本到底有多高"这个看似简单的问题,一直是困扰开发者的一大难题。传统做法是将文本塞入 DOM,然后通过 getBoundingClientRectoffsetHeight等 API 来获取尺寸,但这种操作会触发浏览器的布局回流(layout reflow) ——这是浏览器渲染流程中最耗时的操作之一。浏览器要重新计算整个页面流式布局,尤其在长列表、窗口频繁缩放、AI 内容流式输出等场景下,性能会急剧下降。

为规避这一问题,很多框架只好批量测量、缓存估算值、降低精度等妥协方案,结果就是滚动卡顿、布局偏移、虚拟列表高度计算偏差、画布渲染与CSS样式不匹配等问题。

Pretext 是一个纯 TypeScript 编写的多行文本测量与排版库。它的核心能力可以用一句话概括:在完全不碰 DOM 的情况下,精确计算出多行文本的渲染高度和行布局。它借助浏览器自身的字体引擎(通过 Canvas 的 measureText API)作为真实度量来源,实现了自己的文本测量逻辑。换句话说,Pretext 做了一次前期准备(prepare),之后的所有布局计算都是极其廉价的纯数学运算,无需再次触碰 DOM。

Pretext 支持几乎所有你能想到的语言和文字系统,包括 emoji 和混合双向文本(Bidi),并且针对不同浏览器的排版差异进行了专门处理。它体积小巧(gzip 后不到 10KB),零依赖,支持在浏览器主线程、Web Worker、Node.js、Deno 等任何 JavaScript 运行环境中使用。

浏览器的底层渲染管线:当你用JS改了一段文本或者换了一个样式,浏览器并不是立刻把像素画在屏幕上的,有一套极其严格的流水线:构建 DOM 树→匹配 CSSOM 生成样式→合并为渲染树(Render Tree)→布局(Layout) →绘制(Paint)→合成(Composite)。其中布局阶段是 CPU 消耗最高的环节,浏览器需精准计算每个元素、每行文字的屏幕坐标。

浏览器本身擅长批处理渲染任务,但 JS 中读写 DOM 交错操作会强制中断批处理,触发同步强制布局,引发严重的布局抖动(Layout Thrashing),成为复杂前端应用的核心性能瓶颈。

Pretext 的核心价值:彻底脱离 DOM 依赖,在用户态完成纯 JS/TS 文本布局,从根源解决文本布局引发的性能问题,支持中日韩(CJK)、emoji、阿拉伯文等双向文字混排,计算结果可灵活应用于 DOM 渲染、Canvas 绘制、SVG 导出、服务端排版等场景。

二、为什么需要 Pretext

Web 开发中,文本高度获取是虚拟列表、自定义布局、动态渲染等高级 UI 功能的基础,但传统 DOM 测量方案的性能代价过高。Pretext 为以下核心场景提供了全新解决方案:

1.虚拟列表与遮挡剔除

在构建长列表或无限滚动界面时,开发者需要知道每个条目的高度以正确计算滚动位置。传统方案依赖粗略的估算或缓存值。Pretext 能以极低的成本精确计算文本高度,让虚拟列表的实现变得可靠且高效。

2.自定义布局引擎

想在 JavaScript 中实现瀑布流(Masonry)布局、类 Flexbox 布局,或是对布局值进行微调而不依赖 CSS Hack,Pretext 提供的精确文本尺寸信息,正是这些自定义布局所需的关键输入。

3.开发阶段布局验证

特别是在 AI 辅助生成 UI 的今天,开发者可以在不打开浏览器的情况下验证按钮上的文案是否会溢出到下一行。

4.消除布局偏移

当新文本加载后需要重新锚定滚动位置时,Pretext 可以提前计算高度,从而避免页面跳动。

三、核心思想

Pretext 的文本测量,依托Canvas 的 measureText () API实现 —— 该 API 可直接调用浏览器底层 C++ 字体引擎,仅测量单行文本宽度,不会触发 DOM 全局重排,但自身不支持换行与复杂段落排版。

基于此,Pretext 采用两阶段架构的核心设计,将文本排版拆分为「一次性重活」与「高频轻活」:

第一阶段:prepare () —— 仅执行一次的预处理

该函数接收原始文本与字体信息,完成以下核心工作:

  • 通过Intl.Segmenter做 Unicode 分词、空白字符规范化

  • 应用语言断行规则、处理双向文本(Bidi)

  • 调用 CanvasmeasureText()逐段测量宽度并缓存结果

  • 处理标点粘连、CJK 文字断行等排版规则

处理 500 段文本仅需约 19 毫秒,完成后返回缓存句柄,相同文本无需重复执行

第二阶段:layout () —— 可反复调用的轻量计算

基于 prepare () 的缓存数据,仅通过纯算术运算计算文本高度、行数、字符坐标,无 DOM 操作、无 Canvas 调用,性能极致轻量化。窗口缩放时仅需重新调用 layout (),单步耗时约 0.09 毫秒,可轻松支撑 60~120fps 的高频渲染需求。

这一设计的精妙之处:绕过 DOM 这一高成本中间层,直接获取浏览器字体引擎的真实度量数据,实现「一次预处理、无限次轻量计算」。

四、快速上手

安装非常简单:

npm install @chenglou/pretext

想要体验更多 Demo,可以访问 chenglou.me/pretext 或克隆仓库后运行 bun install && bun start 在本地查看。

五、基础用法详解

用法一:快速获取文本高度

这是最简单也是最常用的场景——你只需要知道一段文字在给定宽度下会渲染出多高。

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

// 第一步:准备文本(只需做一次) 
const prepared = prepare( '这是一段需要测量高度的文字,可能会很长,需要自动换行。', '16px "PingFang SC", "Microsoft YaHei", sans-serif' ) 

// 第二步:计算布局(可以反复调用) 
const containerWidth = 320 // 容器宽度,单位是像素 
const lineHeight = 24 // 行高,单位是像素 
const result = layout(prepared, containerWidth, lineHeight) 

console.log(result.height) // 文本总高度 
console.log(result.lineCount) // 总行数

如果容器宽度变了(比如用户拖拽了窗口),只需要重新调用 layout()

window.addEventListener('resize', () => { 
    const newWidth = container.clientWidth 
    const { height } = layout(prepared, newWidth, lineHeight) 
    // 用新高度更新你的 UI 
})

因为 layout() 只做算术运算,这个 resize 回调的性能开销几乎可以忽略不计。

用法二:处理 textarea 输入

对于用户输入的文本(保留换行和连续空格),使用 pre-wrap 模式:

const textarea = document.querySelector('textarea') 

textarea.addEventListener('input', () => { 
    const prepared = prepare( textarea.value, '14px monospace', 
        { whiteSpace: 'pre-wrap' } // 保留用户输入的空白字符 
    ) 
    const { height } = layout(prepared, textarea.clientWidth, 20) 
    textarea.style.height = `${height}px` // 自动增长高度 
})

用法三:手动控制每一行的排版

当你需要把文本绘制到 Canvas 上,或者需要对每一行做精确控制时,使用 prepareWithSegmentslayoutWithLines

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

const prepared = prepareWithSegments( 'AGI 春天到了. بدأت الرحلة 🚀', '18px "Helvetica Neue"' ) 

const { lines } = layoutWithLines(prepared, 320, 26) 

// 遍历每一行,手动绘制 
const ctx = canvas.getContext('2d') 
ctx.font = '18px "Helvetica Neue"' 
ctx.textBaseline = 'top' 
for (let i = 0; i < lines.length; i++) { 
    ctx.fillText(lines[i].text, 0, i * 26) 
}

每一个 line 对象包含以下信息:

type LayoutLine = { 
    text: string // 这一行的文本内容 
    width: number // 这一行的像素宽度 
    start: LayoutCursor // 起始位置 
    end: LayoutCursor // 结束位置 
}

用法四:文字环绕(围绕图片排版)

这是 Pretext 最强大的能力之一——每一行可以有不同的宽度。想象一下文字围绕一张浮动图片流动的效果:

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

const prepared = prepareWithSegments(articleText, '16px Georgia') 

let cursor = { segmentIndex: 0, graphemeIndex: 0 } 
let y = 0 
const lineHeight = 26 

while (true) { 
    // 如果当前行还在图片范围内,缩窄可用宽度 
    const availableWidth = y < image.bottom ? columnWidth - image.width - image.margin : columnWidth 
    const line = layoutNextLine(prepared, cursor, availableWidth) 

    if (line === null) break // 文本排完了 
    ctx.fillText(line.text, 0, y) 
    cursor = line.end 
    y += lineHeight 
}

这个 API 的设计非常精巧:layoutNextLine 是一个迭代器式的接口,每次调用返回一行,你可以在每一行之间动态决定下一行的宽度。这让"文字环绕障碍物"这种在 CSS 中很难做到的效果变得轻而易举。

用法五:计算最小包裹宽度(shrink-wrap)

如果你想知道一段文字的"最紧凑"容器宽度——也就是刚好能容纳最宽那一行的宽度——可以walkLineRanges

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

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

let maxLineWidth = 0 

walkLineRanges(prepared, 320, (line) => { 
    if (line.width > maxLineWidth) { 
        maxLineWidth = line.width 
    } 
}) 

// maxLineWidth 就是最紧凑的容器宽度 
container.style.width = `${Math.ceil(maxLineWidth)}px`

walkLineRangeslayoutWithLines 的区别在于,它不会构建每一行的文本字符串,只提供宽度和位置信息,因此性能更好。适合你只关心宽度而不需要文本内容的场景。

六、实战场景案例

案例一:高性能虚拟滚动列表

虚拟滚动(virtual scrolling)是处理长列表的标准方案,但它有一个核心痛点:你需要提前知道每个列表项的高度,才能正确计算滚动位置和可视区域。

传统方案要么使用固定行高(牺牲灵活性),要么先渲染到隐藏 DOM 再测量(牺牲性能),要么用估算值加上滚动时的修正(体验不好,会抖动)。Pretext 提供了第四种方案——精确预测,零 DOM 开销

import { prepare, layout } from '@chenglou/pretext' // 一次性预处理所有消息 

const preparedMessages = messages.map(msg => prepare(msg.text, '14px -apple-system, sans-serif') ) // 计算每条消息的高度(纯算术,亚毫秒级) 

function getMessageHeight(index: number, containerWidth: number) { 
    const padding = 24 // 上下内边距 
    const { height } = layout(preparedMessages[index], containerWidth - 32, 20)
    return height + padding 
} 

// 计算总高度和每个元素的偏移量 
let totalHeight = 0 
const offsets = preparedMessages.map((_, i) => { 
    const offset = totalHeight 
    totalHeight += getMessageHeight(i, viewportWidth) 
    return offset
}) 

// 窗口 resize 时,重新计算所有高度——依然很快 
window.addEventListener('resize', () => { 
    totalHeight = 0 
    offsets.forEach((_, i) => { 
        offsets[i] = totalHeight 
        totalHeight += getMessageHeight(i, container.clientWidth) 
    }) 
})

这种方案的优势在于:即使列表有上万条数据,resize 时的重算也只需要几毫秒,而且完全不会触发任何 DOM 回流。

案例二:聊天气泡的精确宽度

在聊天应用中,消息气泡的宽度通常需要"包裹"文字——既不能太宽浪费空间,也不能让文字在不该换行的地方换行。传统做法是设置 max-width 然后让浏览器自动处理,但如果你想在渲染之前就知道气泡的精确尺寸(比如做动画或虚拟化),就需要 Pretext。

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

function measureBubble(text: string, maxWidth: number) { 
    const font = '15px -apple-system, sans-serif' 
    const lineHeight = 22 
    const padding = { x: 12, y: 8 } 
    const prepared = prepareWithSegments(text, font) 
    const contentMaxWidth = maxWidth - padding.x * 2 
    // 找出最宽行的宽度 
    let contentWidth = 0 
    walkLineRanges(prepared, contentMaxWidth, (line) => { 
        if (line.width > contentWidth)  contentWidth = line.width 
    }) 
    // 获取总高度和行数 
    const { height, lineCount } = layoutWithLines(prepared, contentMaxWidth, lineHeight) 
    
    return { 
        width: Math.ceil(contentWidth) + padding.x * 2, 
        height: height + padding.y * 2, 
        lineCount 
    } 
}

案例三:瀑布流布局的高度预测

瀑布流(Masonry)布局需要在定位每张卡片之前就知道它的高度。对于包含文字的卡片,这意味着必须提前算出文本部分的高度。

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

function calculateCardHeight(card: CardData, columnWidth: number) { 
    const imageHeight = card.imageRatio * columnWidth 
    const titlePadding = 16 
    const bodyPadding = 12 

    // 测量标题高度 
    const titlePrepared = prepare(card.title, 'bold 18px Inter') 
    const { height: titleHeight } = layout( titlePrepared, columnWidth - titlePadding * 2, 26 ) 

    // 测量正文高度 
    const bodyPrepared = prepare(card.body, '14px Inter') 
    const { height: bodyHeight } = layout( bodyPrepared, columnWidth - bodyPadding * 2, 20 ) 

    return imageHeight + titleHeight + bodyHeight + titlePadding * 2 + bodyPadding * 2 } 

// 瀑布流定位 
function layoutMasonry(cards: CardData[], columns: number, containerWidth: number) { 
    const gap = 16 
    const columnWidth = (containerWidth - gap * (columns - 1)) / columns 
    const columnHeights = new Array(columns).fill(0) 
    return cards.map(card => { 
        const height = calculateCardHeight(card, columnWidth) 
        // 放到最短的列 
        const col = columnHeights.indexOf(Math.min(...columnHeights)) 
        const position = { 
            x: col * (columnWidth + gap), 
            y: columnHeights[col], 
            width: columnWidth, 
            height 
        } 
        columnHeights[col] += height + gap 
        return position 
    }) 
}

案例四:防止布局偏移(Layout Shift)

当新内容加载时(比如聊天消息的懒加载),页面经常会发生布局偏移——用户正在看的内容突然跳到了别的位置。CLS(Cumulative Layout Shift)是 Core Web Vitals 的一个关键指标,也是用户体验的大敌。

Pretext 可以在新内容插入之前就算出它的精确高度,从而提前调整滚动位置:

async function loadOlderMessages() { 
    const oldScrollHeight = container.scrollHeight 
    const oldScrollTop = container.scrollTop 
    const olderMessages = await fetchOlderMessages() 

    // 在插入 DOM 之前,精确计算新消息的总高度 
    let insertedHeight = 0 
    for (const msg of olderMessages) { 
        const prepared = prepare(msg.text, '14px sans-serif') 
        const { height } = layout(prepared, container.clientWidth - 32, 20) 
        insertedHeight += height + 16 // 16px 是消息间距 
    } 

    // 插入新消息 
    renderMessages(olderMessages) 

    // 立即补偿滚动位置,用户感知不到任何跳动 
    container.scrollTop = oldScrollTop + insertedHeight 
}

案例五:Canvas/WebGL 文本渲染

当你在 Canvas 或 WebGL 中渲染文字时,DOM 的文本排版能力是用不上的。Pretext 恰好填补了这个空白:

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

function renderTextOnCanvas( ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number ) { 
    const font = '16px Inter' 
    const lineHeight = 24 
    ctx.font = font 
    ctx.fillStyle = '#333' 
    ctx.textBaseline = 'top' 
    const prepared = prepareWithSegments(text, font) 
    const { lines } = layoutWithLines(prepared, maxWidth, lineHeight) 
    for (let i = 0; i < lines.length; i++) { 
        ctx.fillText(lines[i].text, x, y + i * lineHeight) 
    }
}

这个方案在数据可视化、游戏 UI、图表标注等场景中非常实用。你不需要在画布上放一个隐藏的 DOM 元素来测量文字了。

案例六:AI 生成内容的布局验证

这是一个很有意思的新兴场景。当 AI 生成 UI 代码时(比如按钮文案、卡片标题),如何在不启动浏览器的情况下验证文字是否会溢出?

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

function validateButtonText(text: string, buttonWidth: number) { 
    const prepared = prepare(text, '14px Inter') 
    const { lineCount } = layout(prepared, buttonWidth - 24, 20) // 24px 是左右内边距 
    
    if (lineCount > 1) { 
        console.warn(`按钮文字 "${text}" 在 ${buttonWidth}px 宽度下会换行!`) 
        return false 
    } 
    return true 
} 

// AI 生成了一组按钮文案,批量验证 
const buttonTexts = aiGeneratedTexts 
const buttonWidth = 120 
buttonTexts.forEach(text => { 
    if (!validateButtonText(text, buttonWidth)) { 
        // 请 AI 重新生成更短的文案 
    } 
})

由于 Pretext 不依赖 DOM,这个验证可以在 Node.js 环境中运行,集成到 CI/CD 流水线里。

七、总结

1.大模型时代:Pretext 的核心价值

Pretext 的诞生恰逢大模型时代,精准解决了 AI 应用的前端文本痛点:

  1. 流式文本渲染无性能压力:AI 内容逐 Token 流式输出时,layout()纯算术运算可 60fps 高频调用,无 DOM 回流,滚动流畅无卡顿。

  2. 多语言混排精准适配:支持 CJK、阿拉伯文、emoji、双向文本等全球语言,适配大模型多语言对话场景。

  3. AI 生成 UI 闭环验证:集成到 AI Agent 生成流程,毫秒级验证布局,无需浏览器即可完成「生成→验证→修正」闭环。

  4. 服务端渲染零 CLS:无 DOM 依赖,可在 Node.js/Deno 服务端预计算文本高度,注入 HTML 后实现首屏零布局偏移。

同时,Pretext 本身是AI 辅助开发的产物,作者通过「AI 编码 + 浏览器真实渲染验证」的模式完成开发,是大模型时代个人开发者攻克系统级难题的典型案例。

2.使用注意事项

在实际使用 Pretext 之前,有几个注意事项值得了解。

  1. 字体配置建议:macOS 上system-ui在 Canvas 与 DOM 中可能解析为不同字体,导致测量结果不准确。建议明确指定字体名称,比如-apple-systemInter等具体字体。

  2. 窄容器断行规则:容器宽度极窄时,会在字素边界断词,因为已经没有足够的空间放下一个完整的词了,属于合理适配逻辑。

  3. API 调用规范:prepare() 的调用有一定开销,不应该在每一帧都调用。正确的做法是:文本变化时执行prepare(),容器宽度变化时仅执行layout(),避免重复预处理。

3.与传统方案的性能对比

对比维度 传统 DOM 测量 Pretext
触发回流 每次测量均触发 零回流
单步耗时 毫秒级,随文本量激增 layout () 仅 0.09 毫秒
运行环境 仅浏览器主线程 浏览器 / Worker / 服务端全支持
多语言兼容性 差,易出现排版偏差 全语言精准适配
布局抖动风险

Pretext 不是一个库,而是一次前端文本能力的重启。它证明了:把浏览器字体引擎的真相前置缓存 + 纯 JS 布局,就能干掉 DOM reflow 这个老大难。作者用 AI 迭代对齐浏览器行为,这条路以后会成为标配。

对普通开发者,它让你写更丝滑的列表、更稳的编辑器;对设计师,它打开了杂志级响应式排版;对 AI 时代,它把文本从“渲染后才知道”变成“生成前就知道”,让智能界面真正智能起来。

参考资料

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐