🔍 探究pretext,从不定高虚拟列表入手,到手写一个mini pretext

0 阅读8分钟

github.com/chenglou/pr…

认识pretext

它是一个通过计算可以得到文本的高度的库,prepare方法传入文本内容和字体大小可以计算出每个文字的排版,layout传入prepare、容器宽度和行高,返回整个容器高度和行数。这个库对于一些需要提前知道高度的场景非常有用。但是这个库只能在前端使用,因为涉及到了canva。

  • prepare():做一次性分析和测量
  • layout():只基于缓存结果做纯算术布局

官方文档明确说明,不要在同样的文本和配置上反复执行 prepare()。例如窗口宽度变化时,应该只重新执行 layout()。

场景:

  • 虚拟滚动列表
  • ai流式输出
  • canvas 渲染

像不定高的虚拟列表的场景,通常需要高度占位,然后滚动后再缓存,有了这个库可以做到内容虚拟列表内容的提前精确计算。解决了滚动过快导致计算不准确的问题。

快速上手

  1. 安装依赖
pnpm i @chenglou/pretext
  1. 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>
  )
}
  1. 效果

可以看到真实的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-heightwhite-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>