认识pretext
它是一个通过计算可以得到文本的高度的库,prepare方法传入文本内容和字体大小可以计算出每个文字的排版,layout传入prepare、容器宽度和行高,返回整个容器高度和行数。这个库对于一些需要提前知道高度的场景非常有用。但是这个库只能在前端使用,因为涉及到了canva。
- prepare():做一次性分析和测量
- layout():只基于缓存结果做纯算术布局
官方文档明确说明,不要在同样的文本和配置上反复执行 prepare()。例如窗口宽度变化时,应该只重新执行 layout()。
场景:
- 虚拟滚动列表
- ai流式输出
- canvas 渲染
像不定高的虚拟列表的场景,通常需要高度占位,然后滚动后再缓存,有了这个库可以做到内容虚拟列表内容的提前精确计算。解决了滚动过快导致计算不准确的问题。
快速上手
- 安装依赖
pnpm i @chenglou/pretext
- App.tsx
import { prepare, layout } from '@chenglou/pretext'
const text = `
AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀
`
export default function App() {
const prepared = prepare(text, '16px Inter', {
whiteSpace: 'pre-wrap' // 'normal' | 'pre-wrap'
})
const { height, lineCount } = layout(prepared, 200, 20)
return (
<div style={{ display: 'flex' }}>
<div style={{ lineHeight: '20px', width: '200px' }}>{text}</div>
<div>
<div>
计算高度: {height}px, {lineCount} lines
</div>
<div>
真实lines:
{text.split('').length}
</div>
</div>
</div>
)
}
- 效果
可以看到真实的dom高度和计算出来的dom高度一样
实现不定高虚拟滚动列表
import { useEffect, useMemo, useRef, useState } from 'react'
import { prepare, layout } from '@chenglou/pretext'
const fetchData = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
return response.json()
}
// 上下额外渲染的缓冲项数量,减少滚动时白屏
const BUFFER_COUNT = 5
// 文本排版和实际渲染统一使用的字号
const FONT_SIZE = 16
// 文本排版和实际渲染统一使用的字体
const FONT_FAMILY = 'Inter'
// 传给 pretext layout 的固定行高(px)
const LINE_HEIGHT = 20
// 每个 item 底部边框高度(对应 border-b 的 1px)
const ITEM_BORDER_Y = 1
// 在前缀和数组中找第一个 > target 的位置(upper bound)
const getFirstGreaterIndex = (arr, target) => {
let left = 0
let right = arr.length
while (left < right) {
const mid = (left + right) >> 1
if (arr[mid] <= target) {
left = mid + 1
} else {
right = mid
}
}
return left
}
// 在前缀和数组中找第一个 >= target 的位置(lower bound)
const getFirstGreaterOrEqualIndex = (arr, target) => {
let left = 0
let right = arr.length
while (left < right) {
const mid = (left + right) >> 1
if (arr[mid] < target) {
left = mid + 1
} else {
right = mid
}
}
return left
}
export default function Scroll() {
const containerRef = useRef(null)
const [screenHeight, setScreenHeight] = useState(0)
const [textLayoutWidth, setTextLayoutWidth] = useState(200)
const [scrollTop, setScrollTop] = useState(0)
const [listData, setListData] = useState([])
useEffect(() => {
const container = containerRef.current
if (!container) return
const updateMetrics = () => {
setScreenHeight(container.clientHeight)
// 文本可用宽度应与真实渲染宽度一致,避免高度预估偏大或偏小
setTextLayoutWidth(Math.max(1, container.clientWidth))
}
updateMetrics()
const observer = new ResizeObserver(updateMetrics)
observer.observe(container)
return () => {
observer.disconnect()
}
}, [])
useEffect(() => {
fetchData().then((res) => {
setListData(
res.map((item) => ({
uid: item.id,
value: item.body
}))
)
})
}, [])
// 预计算每条文本在固定宽度下的排版高度,用于不定高虚拟列表
const measuredList = useMemo(
() =>
listData.map((item) => {
const text = String(item.value ?? '')
const prepared = prepare(text, `${FONT_SIZE}px ${FONT_FAMILY}`)
const { height, lineCount } = layout(
prepared,
textLayoutWidth,
LINE_HEIGHT
)
const itemHeight = Math.ceil(height) + ITEM_BORDER_Y
return {
...item,
lineCount,
textHeight: height,
itemHeight
}
}),
[listData, textLayoutWidth]
)
const totalItemCount = measuredList.length
// 前缀和:prefixHeights[i] 表示前 i 项累计高度
const prefixHeights = useMemo(() => {
const result = new Array(totalItemCount + 1).fill(0)
for (let i = 0; i < totalItemCount; i += 1) {
result[i + 1] = result[i] + measuredList[i].itemHeight
}
return result
}, [measuredList, totalItemCount])
const containerHeight = useMemo(
() => prefixHeights[prefixHeights.length - 1] ?? 0,
[prefixHeights]
)
// 通过 scrollTop 在前缀和中二分定位当前顶部命中的 item
const topIndex = useMemo(() => {
if (totalItemCount === 0) return 0
const hit = getFirstGreaterIndex(prefixHeights, scrollTop) - 1
return Math.min(totalItemCount - 1, Math.max(0, hit))
}, [prefixHeights, scrollTop, totalItemCount])
const startIndex = useMemo(
() => Math.max(0, topIndex - BUFFER_COUNT),
[topIndex]
)
// 视口底部对应的结束索引,再加 buffer 做预渲染
const endIndex = useMemo(() => {
const viewportBottom = scrollTop + screenHeight
const visibleEndIndex = getFirstGreaterOrEqualIndex(
prefixHeights,
viewportBottom
)
return Math.min(totalItemCount, visibleEndIndex + BUFFER_COUNT)
}, [prefixHeights, scrollTop, screenHeight, totalItemCount])
const renderedItems = useMemo(
() => measuredList.slice(startIndex, endIndex),
[measuredList, startIndex, endIndex]
)
// 当前渲染窗口整体向下偏移到 startIndex 的真实起点高度
const offset = useMemo(
() => prefixHeights[startIndex] ?? 0,
[prefixHeights, startIndex]
)
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop)
}
return (
<div
ref={containerRef}
className='w-60 h-160 border m-auto mt-10 relative overflow-auto'
onScroll={handleScroll}
>
<div
className='absolute top-0 left-0 right-0 w-full -z-1'
style={{ height: `${containerHeight}px` }}
></div>
<div
className='w-full overflow-hidden'
style={{
transform: `translate3D(0, ${offset}px, 0)`,
fontSize: `${FONT_SIZE}px`,
fontFamily: FONT_FAMILY,
lineHeight: `${LINE_HEIGHT}px`
}}
>
{renderedItems.map((item) => (
<div
className='w-full border-b bg-amber-200'
key={item.uid}
>
{item.value}
</div>
))}
</div>
</div>
)
}
原理
不用 DOM + CSS layout(如 line-height、white-space 等),用 Canvas API 自己测量文本 → 手动换行 → 计算高度
核心API:ctx.measureText,返回文本宽度
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let text = ctx.measureText("Hello world");
console.log(text.width); // 56;
对比css的优势:
- 不参与 DOM layout
- 不受 CSS flow 影响
你可以:
- 精确测量每个字符串宽度
- 自己决定在哪里换行
- 自己累加高度
实现一个mini版
- 核心方法:
// Intl.Segmenter:它是浏览器内置的“文本切分器”
// 作用:把字符串切成“人眼看到的一个个字符单位”
// 例如:"👨👩👧👦" 会被拆成 ["👨", "", "👩", "", "👧", "", "👦"]
const segmenter = new Intl.Segmenter('zh', {
granularity: 'grapheme'
})
function tokenize(text) {
const tokens = []
// segment就是一个一个字符了,包含了中文、英文、emoji等
for (const { segment } of segmenter.segment(text)) {
if (/[\u4e00-\u9fff]/.test(segment)) {
// 判断是中文字符
tokens.push(segment)
} else if (/\s/.test(segment)) {
// 判断是否包含空白字符(空格、换行、制表符等)
tokens.push(segment)
} else {
// 英文需要合并成词(优化)
const last = tokens[tokens.length - 1]
if (last && /[a-zA-Z0-9]/.test(last)) {
tokens[tokens.length - 1] += segment
} else {
tokens.push(segment)
}
}
}
return tokens
}
function wrapText(text, maxWidth, ctx) {
// 保存最终的分行结果
let lines = []
// 当前正在拼接的一行文本
let currentLine = ''
// 将文本进行分词,为了兼容中文
const tokens = tokenize(text)
// 逐字符尝试拼接,确保按真实渲染宽度换行
for (let token of tokens) {
if (token === '\n') {
lines.push(currentLine)
currentLine = ''
continue
}
// 先假设把当前字符放进本行,再测量宽度
const testLine = (currentLine + token).trimEnd()
// 获取到当前行加上新字符的宽度
const testWidth = ctx.measureText(testLine).width
// 超过最大宽度时,当前字符需要换到下一行
if (testWidth > maxWidth) {
if (currentLine.length > 0) {
// 当前行已有内容:先收集当前行,再让新行从当前字符开始
lines.push(currentLine.trimEnd())
currentLine = token.trimStart()
} else {
// 当前lines没有内容,但是单个字符宽度都超过 maxWidth
// 这种情况下只能强制单字符成行,避免死循环
lines.push(token)
currentLine = ''
}
} else {
// 没超宽:继续在当前行累积
currentLine = testLine
}
}
// 循环结束后,把最后一行(如果有内容)补进结果
if (currentLine.length > 0) {
lines.push(currentLine)
}
return lines
}
// 返回布局的文本信息
function layoutText(text, maxWidth, lineHeight, ctx) {
const lines = wrapText(text, maxWidth, ctx)
const totalHeight = lines.length * lineHeight
const lineMetrics = lines.map((line) => ({
text: line,
width: ctx.measureText(line).width,
height: lineHeight
}))
return {
lines: lineMetrics,
lineHeight,
totalHeight
}
}
- 使用:
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
ctx.font = '16px Arial'
const result = layoutText(
'你好啊,世界!我是一个前端开发,我会vue、react等技术。还有后端哦',
120,
20,
ctx
)
console.log(result)
- 测试:
需要注意的是使用layoutText计算出来的lines和页面上的dom不一致的原因是因为浏览器排版布局和canvas布局是有差异的,但是总高度是一致的。
完整版代码
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Document</title>
</head>
<style>
div {
font-size: 16px;
font-family: Arial;
width: 120px;
line-height: 20px;
}
</style>
<div>你好啊,世界!我是一个前端开发,我会vue、react等技术。还有后端哦</div>
<!-- Hello world, this is a canvas layout demo. -->
<body>
<script>
// Intl.Segmenter:它是浏览器内置的“文本切分器”
// 作用:把字符串切成“人眼看到的一个个字符单位”
// 例如:"👨👩👧👦" 会被拆成 ["👨", "", "👩", "", "👧", "", "👦"]
const segmenter = new Intl.Segmenter('zh', {
granularity: 'grapheme'
})
function tokenize(text) {
const tokens = []
for (const { segment } of segmenter.segment(text)) {
// segment就是一个一个字符了,包含了中文、英文、emoji等
if (/[\u4e00-\u9fff]/.test(segment)) {
// 判断是中文字符
tokens.push(segment)
} else if (/\s/.test(segment)) {
// 判断是否包含空白字符(空格、换行、制表符等)
tokens.push(segment)
} else {
// 英文需要合并成词(优化)
const last = tokens[tokens.length - 1]
if (last && /[a-zA-Z0-9]/.test(last)) {
tokens[tokens.length - 1] += segment
} else {
tokens.push(segment)
}
}
}
return tokens
}
function wrapText(text, maxWidth, ctx) {
// 保存最终的分行结果
let lines = []
// 当前正在拼接的一行文本
let currentLine = ''
// 将文本进行分词,为了兼容中文
const tokens = tokenize(text)
// 逐字符尝试拼接,确保按真实渲染宽度换行
for (let token of tokens) {
if (token === '\n') {
lines.push(currentLine)
currentLine = ''
continue
}
// 先假设把当前字符放进本行,再测量宽度
const testLine = (currentLine + token).trimEnd()
// 获取到当前行加上新字符的宽度
const testWidth = ctx.measureText(testLine).width
// 超过最大宽度时,当前字符需要换到下一行
if (testWidth > maxWidth) {
if (currentLine.length > 0) {
// 当前行已有内容:先收集当前行,再让新行从当前字符开始
lines.push(currentLine.trimEnd())
currentLine = token.trimStart()
} else {
// 当前lines没有内容,但是单个字符宽度都超过 maxWidth
// 这种情况下只能强制单字符成行,避免死循环
lines.push(token)
currentLine = ''
}
} else {
// 没超宽:继续在当前行累积
currentLine = testLine
}
}
// 循环结束后,把最后一行(如果有内容)补进结果
if (currentLine.length > 0) {
lines.push(currentLine)
}
return lines
}
// 返回布局的文本信息
function layoutText(text, maxWidth, lineHeight, ctx) {
const lines = wrapText(text, maxWidth, ctx)
const totalHeight = lines.length * lineHeight
const lineMetrics = lines.map((line) => ({
text: line,
width: ctx.measureText(line).width,
height: lineHeight
}))
return {
lines: lineMetrics,
lineHeight,
totalHeight
}
}
</script>
<script>
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
ctx.font = '16px Arial'
const result = layoutText(
'你好啊,世界!我是一个前端开发,我会vue、react等技术。还有后端哦',
120,
20,
ctx
)
console.log(result)
</script>
</body>
</html>