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

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

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

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

最后利用 biome format 和 bun HTMLRewriter,整体性能从 125 m s 125ms 125ms 提升到 35.8 m s 35.8ms 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,只有原来的 7 100 \frac{7} {100} 1007,整体耗时只有原来的 28 100 \frac{28} {100} 10028。

完整代码

github.com/legend80s/s...

相关推荐
喵个咪1 小时前
Headless 后端实践:基于Go的企业级多栈管理系统脚手架
前端·vue.js·react.js
川石课堂软件测试3 小时前
使用mock进行接口测试教程
数据库·python·功能测试·测试工具·华为·单元测试·appium
代码N年归来仍是新手村成员4 小时前
【AWS】Lambda 初识与服务部署
javascript·react.js·ai·node.js·云计算·ai编程·aws
大雷神4 小时前
HarmonyOS APP<玩转React>开源教程三十一:示例项目下载功能
react.js·开源·harmonyos
大鱼前端5 小时前
Veaury:让Vue和React组件在同一应用中共存的神器
前端·vue.js·react.js
五月君_5 小时前
继 React、Vue 之后,Three.js 也有 Skills 了!AI 写 3D 终于不“晕”了
javascript·vue.js·人工智能·react.js·3d
小崽崽15 小时前
如何实现React 19+Vite+TypeScript技术栈告别高薪主播!从零打造 24 小时“AI 销冠”:星云数字人直播间全链路实战
人工智能·react.js·typescript
光影少年7 小时前
Redux Toolkit 用法、解决原生Redux 冗余问题
开发语言·前端·javascript·react.js·中间件·前端框架·ecmascript
whuhewei8 小时前
一道React缓存的题目
javascript·react.js
芒鸽19 小时前
鸿蒙应用测试实战:从单元测试到自动化测试
华为·单元测试·harmonyos