HTML 处理以及性能对比 - Bun 单元测试系列

单元测试输出的 HTML 通常压缩在一行,没有空格和换行不利于 snapshot diff,我们需要有一个称手的工具来"美化" HTML,其次输出的路径的分隔符在 Windows 和类 Unix 系统不一样,导致本地运行正常的单测在 CI 却失败。

本文将针对这两个问题给出解决方案:

  • 利用 prettierformat(也可以用 biome,本文会讲到);
  • 利用 parse5 解析 HTML AST 将特定的节点做转换或删除,从而保持 HTML 在不同平台输出一致,即生成"稳定"的 HTML(也可以用 bun HTMLRewriter,本文也会讲到)。

最后利用 biome format 和 bun HTMLRewriter,整体性能从 <math xmlns="http://www.w3.org/1998/Math/MathML"> 125 m s 125ms </math>125ms 提升到 <math xmlns="http://www.w3.org/1998/Math/MathML"> 35.8 m s 35.8ms </math>35.8ms 🚀。

🌱 基础版

一、format 利用 prettier

效果

首先看看格式化前后对比:

Before

html 复制代码
<blockquote><p>思考部分行内公式 1 <span class="katex">...

After

html 复制代码
<blockquote>
  <p>
    思考部分行内公式 1
    <span class="katex">
      <span class="katex-mathml">
        <math xmlns="http://www.w3.org/1998/Math/MathML">
          ...
        </semantics>
      </math>
    </span>
  </span>
  块级公式 1:
</p>
...

思路很简单使用 prettier 格式化即可。

ts 复制代码
import prettier from 'prettier'

export async function format(html: string): Promise<string> {
  const formatted = await prettier.format(html, {
    parser: 'html',
    htmlWhitespaceSensitivity: 'ignore',
  })

  return formatted.trim()
}

但是有时候我们可能需要删除某些 HTML 元素,否则可能会导致 snapshot 太多,或者抹平某些属性在不同操作系统的差异,我们需要再设计一个方法在输出前处理这些事情。

二、 filter 利用 parse5 AST 的力量

parse5 HTML parser and serializer.

parse5 的周下载量是 5千万,可以放心使用。本文后面还会告诉大家如何使用 bun 内置的 HTMLRewriter 来实现。

先设计函数,输入 HTML,和一个 ignoreAttrs,输出处理后的 HTML。

ts 复制代码
function filter(html: string, ignoreAttrs: IFilter): string
ts 复制代码
/**
 * - `true`: 过滤掉该属性
 * - `false`: 保留该属性
 * - `string`: 替换该属性值
 */
type IFilter = (
  node: { tagName: string },
  attr: { name: string; value: string },
) => true | false | string;

ignoreAttrs 是一个过滤控制器:true 过滤,false 保留,string 替换。

具体实现:

  1. 用 parse5 解析 HTML
  2. 递归遍历 AST,移除要忽略的属性
  3. 将 AST 重新序列化为 HTML
ts 复制代码
function filter(html: string, ignoreAttrs: IFilter): string {
  // 1. 用 parse5 解析 HTML
  const document = parse5.parseFragment(html)

  // 2. 遍历 AST,移除要忽略的属性
  const removeIgnoredAttrs = (node) => {
    if (node.attrs) {
      node.attrs = node.attrs.filter((attr) => {
        const shouldIgnore = ignoreAttrs(node, attr) // 自定义匹配
        let keep = !shouldIgnore

        if (typeof shouldIgnore === 'boolean') return keep

        attr.value = shouldIgnore // 自定义替换
        keep = true

        return keep
      })
    }

    if (node.childNodes) {
      node.childNodes.forEach(removeIgnoredAttrs)
    }
  }

  removeIgnoredAttrs(document)

  // 3. 将 AST 重新序列化为 HTML
  const filteredHTML = parse5.serialize(document)

  return filteredHTML
}

filter 用途,将图片路径转换成"稳定"的路径,抹平操作系统和 CI 环境本地环境的差异,比如:

  • D:\\workspace\\foo\\src\\assets\\user-2.png to user-2.png
  • /app/src/assets/submitIcon.png to submitIcon.png
ts 复制代码
/**
 * 使用 parse5 过滤 HTML 属性,再用 Prettier 格式化
 * @param html 原始 HTML
 * @param ignoreAttrs 要忽略或替换的属性规则
 * @returns 格式化后的 HTML
 */
function formatAndFilterAttr(html: string, ignoreAttrs: IFilter): Promise<string> {
  return format(filter(html, ignoreAttrs))
}

export async function toStableHTML(html: string): Promise<string> {
  const formatted = await formatAndFilterAttr(html.trim(), (node, attr) => {
    const isSrcDiskPath =
      node.tagName === 'img' &&
      attr.name === 'src' &&
      (/^[a-zA-Z]:/.test(attr.value) || attr.value.startsWith('/app/'))

    if (isSrcDiskPath) {
      // D:\\workspace\\foo\\src\\assets\\user-2.png
      // to user-2.png
      // /app/src/assets/submitIcon.png to submitIcon.png
      return `...DISK_PATH/${path.basename(attr.value)}`
    }

    // 保留,不做处理
    return false
  })

  return formatted.trim()
}
记录下性能
ts 复制代码
main.innerHTML.length: 41685

[9.99ms] filter html
[113.38ms] format html
[125.56ms] toStableHTML

formatted.length after toStableHTML: 70629

将一个 4w+ 长度的 HTML 转换成长度为 7w+ 的 HTML,总耗时 125.56ms,性能瓶颈在 prettier format 耗时占比 90%。

🎓 进阶版

一、format 的进阶 🚀:利用 biomeformat

biome 基于 Rust 一直以性能著称,让我们一探究竟。

@biomejs/biome 并未提供程序调用,但是官方提供了两个包: www.npmjs.com/package/@bi...

sh 复制代码
npm i @biomejs/js-api @biomejs/wasm-nodejs -D
ts 复制代码
import { Biome } from '@biomejs/js-api/nodejs'

const biome = new Biome()
const { projectKey } = biome.openProject('path/to/project/dir')

biome.applyConfiguration(projectKey, {
  html: {
    formatter: {
      enabled: true,
      indentStyle: 'space',
      indentWidth: 2,
    },
  },
})

export function format(html: string): Promise<string> {
  console.time('format html using biome')

  const { content: formatted } = biome.formatContent(projectKey, html, {
    // 必选,帮助 Biome 识别文件类型
    filePath: 'example.html',
  })
  console.timeEnd('format html using biome')

  return formatted.trim()
}
性能数据:
ts 复制代码
main.innerHTML.length: 41685
[11.22ms] filter html
[61.33ms] format html using biome
[74.18ms] toStableHTML
formatted.length after toStableHTML: 70085

main.innerHTML.length: 41685
[10.40ms] filter html
[48.71ms] format html using biome
[60.59ms] toStableHTML
formatted.length after toStableHTML: 70085

main.innerHTML.length: 41685
[9.93ms] filter html
[51.78ms] format html using biome
[63.14ms] toStableHTML
formatted.length after toStableHTML: 70085

三次平均值,整体性能从 125ms 提升到 65.67ms,format 从 113ms 提升到 53.67ms,整体性能提升了一倍!没有达到想象中的数倍,有点遗憾。

二、filter 的进阶 🧗‍♂️:利用 bun 内置的 HTMLRewriter

本身我们的项目单元测试运行时就是 bun,那为何不用 bun 内置的 HTMLRewriter?速度快且无依赖。

HTMLRewriter 允许你使用 CSS 选择器来转换 HTML 文档。它支持 Request、Response 以及字符串作为输入。Bun 的实现基于 Cloudflare 的 lol-html。

bun.sh/docs/api/ht...

代码:

ts 复制代码
function filter(html: string, ignoreAttrs: IFilter): string {
  // console.time("filter html using HTMLRewriter");
  const rewriter = new HTMLRewriter().on("img", {
    element(node) {
      for (const [name, value] of node.attributes) {
        const shouldIgnore = ignoreAttrs(node, { name, value }); // 自定义匹配

        if (typeof shouldIgnore === "boolean") {
          node.removeAttribute(name);
        } else {
          node.setAttribute(name, shouldIgnore); // 自定义替换
        }
      }
    },
  });

  const result = rewriter.transform(html);
  // console.timeEnd("filter html using HTMLRewriter");

  return result;
}
性能对比:
ts 复制代码
main.innerHTML.length: 41685
[0.59ms] filter html using HTMLRewriter
[31.86ms] format html using biome
[33.54ms] toStableHTML
formatted.length after toStableHTML: 69335

main.innerHTML.length: 41685
[0.60ms] filter html using HTMLRewriter
[33.85ms] format html using biome
[35.64ms] toStableHTML
formatted.length after toStableHTML: 69335

main.innerHTML.length: 41685
[0.85ms] filter html using HTMLRewriter
[33.82ms] format html using biome
[36.43ms] toStableHTML
formatted.length after toStableHTML: 69335

main.innerHTML.length: 41685
[0.58ms] filter html using HTMLRewriter
[34.67ms] format html using biome
[36.45ms] toStableHTML
formatted.length after toStableHTML: 69335

main.innerHTML.length: 41685
[0.91ms] filter html using HTMLRewriter
[37.10ms] format html using biome
[39.89ms] toStableHTML
formatted.length after toStableHTML: 69335

五次取平均值,整体性能从 125ms 提升到 35.8ms,filter 从 10ms 提升到 0.70ms,只有原来的 <math xmlns="http://www.w3.org/1998/Math/MathML"> 7 100 \frac{7} {100} </math>1007,整体耗时只有原来的 <math xmlns="http://www.w3.org/1998/Math/MathML"> 28 100 \frac{28} {100} </math>10028。

完整代码

github.com/legend80s/s...

相关推荐
小高0072 小时前
React useMemo 深度指南:原理、误区、实战与 2025 最佳实践
前端·javascript·react.js
一颗奇趣蛋3 小时前
React.memo & useMemo:为什么 React 里「看起来没变的组件」还是渲染了
前端·react.js
Ice_Sugar_74 小时前
React 腾讯面试手写题
javascript·react.js·ecmascript
卸任5 小时前
Electron自制翻译工具:自动更新
前端·react.js·electron
小喷友7 小时前
Next.js 中的 Edge Middleware 实战
前端·react.js·next.js
前端美少女战士9 小时前
post方法下载文件,需做哪些特殊处理
javascript·react.js
Jimmy11 小时前
React 性能优化:从慢到闪电般快
前端·javascript·react.js
江城开朗的豌豆11 小时前
React Hooks 真香定律:告别Class组件,我为什么回不去了?
前端·javascript·react.js
江城开朗的豌豆12 小时前
React Component和Purecomponent区别
前端·javascript·react.js