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