单元测试输出的 HTML 通常压缩在一行,没有空格和换行不利于 snapshot diff,我们需要有一个称手的工具来"美化" HTML,其次输出的路径的分隔符在 Windows 和类 Unix 系统不一样,导致本地运行正常的单测在 CI 却失败。
本文将针对这两个问题给出解决方案:
- 利用
prettier
做format
(也可以用biome
,本文会讲到); - 利用
parse5
解析 HTMLAST
将特定的节点做转换或删除,从而保持 HTML 在不同平台输出一致,即生成"稳定"的 HTML(也可以用 bunHTMLRewriter
,本文也会讲到)。
最后利用 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
替换。
具体实现:
- 用 parse5 解析 HTML
- 递归遍历 AST,移除要忽略的属性
- 将 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
touser-2.png
/app/src/assets/submitIcon.png
tosubmitIcon.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
的进阶 🚀:利用 biome
做 format
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。
代码:
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。