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

145 阅读2分钟

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

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

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

最后利用 biome format 和 bun HTMLRewriter,整体性能从 125ms125ms 提升到 35.8ms35.8ms 🚀。

🌱 基础版

一、format 利用 prettier

效果

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

format-html-diff.png

Before

<blockquote><p>思考部分行内公式 1 <span class="katex">...

After

<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 格式化即可。

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。

function filter(html: string, ignoreAttrs: IFilter): string
/**
 * - `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
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

/**
 * 使用 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()
}
记录下性能
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…

npm i @biomejs/js-api @biomejs/wasm-nodejs -D
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()
}
性能数据:
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…

代码:

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;
}
性能对比:
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,只有原来的 7100\frac{7} {100},整体耗时只有原来的 28100\frac{28} {100}

完整代码

github.com/legend80s/s…