You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何基于字符偏移量分割DocumentFragment实现HTML分页

带HTML标签的文本分页实现

需求说明

需要将包含HTML标签的字符串按纯文本字符长度分割为多个合法HTML片段,用于分页场景。每个分页片段必须保证标签完全闭合,不能出现未闭合标签的问题,且分页依据是纯文本的字符数,而非HTML源码的长度。

示例演示

const pageCharacterSize = 10
const testString = 'some <strong>text with HTML</strong> tags'
function paginate(string, pageSize) { // 待实现 }
const pages = paginate(testString, pageCharacterSize)
console.log(pages)
// 预期输出:
// ['some <strong>text </strong>', '<strong>with HTML</strong> ', 'tags'] 

思路探索

考虑过使用DocumentFragment或Range API实现,但不清楚如何按字符偏移量精准切片。MDN上有类似功能的演示,但依赖需要坐标参数的caretPositionFromPoint()方法,无法满足需求。

测试用例(基于Vitest)

为明确需求,以下是验证分页逻辑的测试用例:

import { expect, test } from 'vitest'
import paginate from './paginate'

// 1. 纯文本分页测试
test('纯文本应按指定长度正确拆分', () => {
  // 单字符组拆分
  const testString = 'aa bb cc dd ee';
  const expected = ['aa', 'bb', 'cc', 'dd', 'ee']
  expect(paginate(testString, 2)).toStrictEqual(expected)

  // 带空格的字符组拆分
  const testString2 = 'a a b b c c';
  const expected2 = ['a a', 'b b', 'c c']
  expect(paginate(testString2, 3)).toStrictEqual(expected2)

  // 多字符组拆分
  const testString3 = 'aa aa bb bb cc cc';
  const expected3 = ['aa aa', 'bb bb', 'cc cc']
  expect(paginate(testString3, 5)).toStrictEqual(expected3)
  
  // 剩余字符不足一页长度时的拆分
  const testString4 = 'aa bb cc';
  const expected4 = ['aa', 'bb', 'cc']
  expect(paginate(testString4, 4)).toStrictEqual(expected4)

  // 混合空格的拆分
  const testString5 = 'a b c d e f g';
  const expected5 = ['a b c', 'd e f', 'g']
  expect(paginate(testString5, 5)).toStrictEqual(expected5)

  // 接近页长的拆分
  const testString6 = 'aa bb cc';
  const expected6 = ['aa bb', 'cc']
  expect(paginate(testString6, 7)).toStrictEqual(expected6)
})

// 2. 含HTML标签的文本分页测试
test('含HTML标签的文本拆分时不能遗留未闭合标签', () => {
  const testString = 'aa <strong>bb</strong> <em>cc dd</em>';
  const expected = ['aa', '<strong>bb</strong>', '<em>cc</em>', '<em>dd</em>']
  expect(paginate(testString, 3)).toStrictEqual(expected)
})

// 3. 跨分页标签处理测试
test('标签跨分页时应自动闭合并在后续页面重新开启', () => {
  const testString = '<strong>aa bb cc</strong>';
  const expected = ['<strong>aa</strong>', '<strong>bb</strong>', '<strong>cc</strong>']
  expect(paginate(testString, 2)).toStrictEqual(expected)
})

实现方案

以下是基于Range API的分页函数实现,能够准确处理文本字符计数与HTML标签的闭合:

function paginate(htmlString, pageSize) {
  const pages = [];
  const tempContainer = document.createElement('div');
  tempContainer.innerHTML = htmlString;

  const range = document.createRange();
  let currentCharCount = 0;
  let openTagStack = [];

  // 递归遍历DOM节点,处理文本与标签
  function traverseNode(node) {
    if (currentCharCount >= pageSize) return;

    if (node.nodeType === Node.TEXT_NODE) {
      const textContent = node.textContent;
      const remainingChars = pageSize - currentCharCount;

      if (textContent.length <= remainingChars) {
        currentCharCount += textContent.length;
      } else {
        // 分割文本节点,截取当前页能容纳的部分
        const pageText = textContent.slice(0, remainingChars);
        const remainingText = textContent.slice(remainingChars);
        const newTextNode = document.createTextNode(remainingText);
        
        node.parentNode.insertBefore(newTextNode, node.nextSibling);
        node.textContent = pageText;

        // 截取当前页的HTML内容,并补全未闭合标签
        range.selectNodeContents(tempContainer);
        range.setEndAfter(node);
        const pageHtml = range.cloneContents().innerHTML;
        const closedPage = pageHtml + openTagStack.reverse().map(tag => `</${tag}>`).join('');
        pages.push(closedPage.trim());

        // 重置状态,准备下一页
        currentCharCount = remainingText.length;
        const carriedOverTags = [...openTagStack];
        openTagStack = [];

        // 重建容器,携带未闭合的标签开启标记
        tempContainer.innerHTML = carriedOverTags.map(tag => `<${tag}>`).join('') + remainingText;
        // 重新遍历新的容器内容
        traverseNode(tempContainer.firstChild);
        return;
      }
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      const tagName = node.tagName.toLowerCase();
      openTagStack.push(tagName);

      // 遍历子节点
      for (let i = 0; i < node.childNodes.length; i++) {
        traverseNode(node.childNodes[i]);
        if (currentCharCount >= pageSize) break;
      }

      // 标签闭合,从栈中移除
      openTagStack.pop();
    }
  }

  // 开始遍历初始内容
  traverseNode(tempContainer.firstChild);

  // 处理最后一页剩余内容
  if (tempContainer.innerHTML.trim()) {
    pages.push(tempContainer.innerHTML.trim());
  }

  return pages;
}

实现原理

  1. Range API 精准截取:利用Range选择DOM范围,确保截取的内容包含完整的标签结构,避免标签断裂。
  2. 标签栈跟踪:通过openTagStack记录当前未闭合的标签,分页时自动补全闭合标签,并在下一页开头重新开启这些标签。
  3. 文本节点分割:当单个文本节点长度超过剩余分页长度时,分割文本节点,保证分页的字符数准确。
  4. 递归DOM遍历:遍历整个DOM树,处理文本节点和元素节点,确保所有内容都被正确分页。

内容的提问来源于stack exchange,提问作者emersonthis

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.11 20:50:35