普通视图

发现新文章,点击刷新页面。
昨天 — 2025年8月13日首页

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

作者 Legend80s
2025年8月13日 19:05

单元测试输出的 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…

昨天以前首页

bun 单元测试问题之 TypeError: First argument must be an Error object

作者 Legend80s
2025年8月11日 21:27

💰 价值

本文我们将学会如何在无法给开源模块提交 MR 的情况下仅针对测试运行时修改第三方模块。该模式可以帮助我们解决任何 node_modules 导致的测试报错。

我们通过对解法的深入探寻,从第一版到第五版,找到了隔离性最强安全性最高的解法。

最后将这种安全性高的修改三方模块源码模式,封装成成熟方法。故哪怕你没有遇到该问题本文最后总结的模式也是值得借鉴的。

🤔 问题

当你的 bun test 很大概率偶现 TypeError: First argument must be an Error object

16 | function AxiosError(message, code, config, request, response) {
17 |   Error.call(this);
18 |
19 |   if (Error.captureStackTrace) {
20 |     console.log(typeof this)
21 |     Error.captureStackTrace(this, this.constructor);
               ^
TypeError: First argument must be an Error object
      at new AxiosError (/path/to/project/node_modules/axios/lib/core/AxiosError.js:21:11)
      at settle (/path/to/project/node_modules/axios/lib/core/settle.js:19:12)
      at handleStreamEnd (/path/to/project/node_modules/axios/lib/adapters/http.js:599:11)
      at emit (node:events:90:22)
      at endReadableNT (internal:streams/readable:862:50)

Bun v1.2.14 (macOS arm64)

这个错误是 follow-redirects 抛出的,follow-redirects 是 axios-mock-adapter 调用的,而 axios-mock-adapter 用来帮助我们更容易 mock axios 发起的请求来做测试。

升级 bun 并不能解决,那我们只能通过修改 follow-redirects 源码来绕过。有问题的代码:

github.com/follow-redi…

function CustomError(properties) {
  if (isFunction(Error.captureStackTrace)) {
    Error.captureStackTrace(this, this.constructor);
    // ^ TypeError: First argument must be an Error object
  }
}

为什么 follow-redirects 要这么写,有什么好处我们下篇文章讲。

这里有个诡异的地方,我们打印了 this 然后 this instanceOf Errortrue。但就是报错,估计是 bun 的运行时没有兼容 node 的,在 bun issue 我有留言,希望官方可以调查下。

我们想要修改成如下,从而绕过报错:

function CustomError(properties) {
  // istanbul ignore else
  if (isFunction(Error.captureStackTrace)) {
-    Error.captureStackTrace(this, this.constructor);
+    Error.captureStackTrace(new Error(), this.constructor);
  }
  Object.assign(this, properties || {});
  this.code = code;
  this.message = this.cause ? message + ": " + this.cause.message : message;
}

如果是自己的文件那倒方便,但这却是三方依赖,而且在实际生产过程运行没有报错,仅在单元测试过程会报错,那么如何精确修改变成了一个棘手的事情。

🕵️‍♂️ 解法

思路是仅让这次修改发生在单元测试过程,测试结束自动复原,否则可能会影响生产环境,说到这里很容想到利用 bun test 的 preload。

重点:我们要确保无论任何异常都要能复原!

第一版:覆盖源文件 & 监听进程退出复原

bunfig.toml:

[test]
preload = ["./tests/fix.ts"]

tests/fix.ts:

/**  635 |       Error.captureStackTrace(this, this.constructor);
 *                   ^
 * TypeError: First argument must be an Error object
 *       at new CustomError (D:\xxx\node_modules\follow-redirects\index.js:635:13)
 *       at createErrorType (D:\xxx\node_modules\follow-redirects\index.js:643:27)
 *       at <anonymous> (D:\xxx\node_modules\follow-redirects\index.js:64:29)
 */
function fixFollowRedirectsFirstArgumentMustBeAnErrorObject() {
  const fs = require('node:fs')
  const followRedirectsPath = require.resolve('follow-redirects')

  // 备份原始模块
  const originalCode = fs.readFileSync(followRedirectsPath, 'utf8')

  // 修改模块内容
  const modifiedCode = originalCode.replace(
    /Error\.captureStackTrace\(this, this\.constructor\);/g,
    'Error.captureStackTrace(new Error(), this.constructor);',
  )

  // 临时覆写模块
  fs.writeFileSync(followRedirectsPath, modifiedCode)

  // 测试完成后恢复(需要监听进程退出)
  const restoreFile = (signal: string) => {
    console.log('Receiving signal', signal, 'restoring file...')
    fs.writeFileSync(followRedirectsPath, originalCode)
  }
  process.on('exit', () => {  
    restoreFile('exit')
  })
}

我用了 AI DeepSeek 提供的 monkey-patch 方案,它只在测试阶段生效,因此是安全的。

重点讲一下两点:

  1. const followRedirectsPath = require.resolve('follow-redirects') 这里巧妙使用 require.resolve 获取 node_modules 下的 follow-redirects 入口文件而非通过拼接字符串 D:\workspace\your-project\node_modules\follow-redirects\index.js 这样更加灵活无论 follow-redirects 入口文件路径以后如何变化都可以“以不变应万变”,而且可以规避操作系统以及包管理器带来的差异,让 Node.js 模块解析 require.resolve 帮我们做这个『脏活累活』。

  2. process.on('exit') 是否可以帮助我们即使在异常情况下也能『复原』代码?

我们可以试一试,让单元测试断言失败或者主动抛出未被捕获的异常。

实验结果:断言失败、抛错以及正常情况下都没有复原,也就是信号监听并未触发

尝试增加更多信号:

// 测试完成后恢复(需要监听进程退出)
process.on('SIGINT', restoreFile.bind(null, 'SIGINT'))
process.on('SIGTERM', restoreFile.bind(null, 'SIGTERM'))
process.on('exit', restoreFile.bind(null, 'exit'))
process.on('uncaughtException', restoreFile.bind(null, 'uncaughtException'))

依然无法复原(原因未知,我分别在 plugin 和单测文件内打印了 process.pid 和 process.ppid 二者都相同)。

process in plugin file: pid 52820 ppid 52896
process in test file:   pid 52820 ppid 52896

第二版:afterAll

使用 bun 测试框架提供的钩子

// tests/fix.ts:

// 临时覆写模块
fs.writeFileSync(followRedirectsPath, modifiedCode)

const restoreFile = () => {
  fs.writeFileSync(followRedirectsPath, originalCode)
}

+afterAll(() => {
+  restoreFile()
+})

经过测试(正常、异常:单测失败、主动抛错) afterAll 仍然被执行,也就是我们的『复原方案』是健壮的!

第三版:更安全的方案 —— 修改副本而非源码 & 覆盖 require 缓存

更安全的方法是创建临时副本而不是修改原文件:

// test/fix.ts
import fs from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { Logger, toUnixPath } from './utils'

const logger = new Logger('[bun][fix]')
logger.debugging = true

beforeAll(() => {
  fixFollowRedirectsFirstArgumentMustBeAnErrorObject()
})

function fixFollowRedirectsFirstArgumentMustBeAnErrorObject() {
  const originalPath = require.resolve('follow-redirects')
  const originalDir = path.dirname(originalPath)

  const tempPath = path.join(tmpdir(), 'follow-redirects-index.js')
  logger.log('tempPath:', tempPath)

  // 备份原始模块
  const originalCode = fs.readFileSync(originalPath, 'utf8')

  // 修改模块内容
  // fix error: Cannot find module './debug' from 'C:\Users\foo\AppData\Local\Temp\follow-redirects-index.js'
  let modifiedCode = originalCode.replace(/require\(['"](\.\/[^'"]+)['"]\)/g, (_, relPath) => {
    const absPath = path.join(originalDir, relPath)
    const unixPath = toUnixPath(absPath)
    logger.log('absPath:', absPath)
    logger.log('unixPath:', unixPath)

    return `require('${unixPath}')`
  })

  modifiedCode = modifiedCode.replace(
    /Error\.captureStackTrace\(this, this\.constructor\);/g,
    'Error.captureStackTrace(new Error(), this.constructor);',
  )
  fs.writeFileSync(tempPath, modifiedCode)

  // 覆盖 require 缓存
  const tempModule = require(tempPath)
  const originalModule = require(originalPath)

  originalModule.exports = tempModule

  afterAll(() => {
    restoreFile()
  })

  function restoreFile() {
    logger.log('restoring...')
    delete require.cache[originalPath]

    try {
      fs.unlinkSync(tempPath)
    } catch (unlinkError) {
      logger.error('不应该报错 Error restoring original file:', unlinkError)

      throw unlinkError
    }
  }
}

如果是单文件那没问题,假如 follow-redirects/index.js 依赖了其他模块,比如内置模块、三方模块、自身文件,该方案是否可以正常运行?

事实上 follow-redirects/index.js 三种模块都依赖了,var url = require("url");var debug = require("./debug");require("debug")("follow-redirects");

—— github.com/follow-redi…

—— github.com/follow-redi…

—— github.com/follow-redi…

如果不加下面代码将报错无法找到自身依赖文件 ./debugCannot find module './debug' from 'C:\Users\foo\AppData\Local\Temp\follow-redirects-index.js'

通过将相对路径改成原来的绝对路径可以规避该报错,即让副本内的 debug 正常寻址,回退到 node_modules/follow-redirects/debug.js,这样的好处是无需复制整个 follow-redirects 目录。

/require\(['"](\.\/[^'"]+)['"]\)/require("./debug") 替换成 require("/project/node_modules/follow-redirects/debug")

  let modifiedCode = originalCode.replace(/require\(['"](\.\/[^'"]+)['"]\)/g, (_, relPath) => {
    const absPath = path.join(originalDir, relPath)
    const unixPath = toUnixPath(absPath)
    logger.log('absPath:', absPath)
    logger.log('unixPath:', unixPath)

    return `require('${unixPath}')`
  })

第四版:精简版无需复原

复原更好,但是针对我们的清形不复原也可以。为什么无需复原,因为:

1)无需删除临时文件:修改的是系统临时目录下文件,无需删除(系统会自动清理)那如果系统不清除重名怎么办?没问题,重名会覆盖不会报错,而且复用的是同一个文件不会导致文件越来越多;

2)无需删除 require 缓存:缓存在内存中,单元测试一旦结束缓存自动清空,不会影响实际生产运行时。

-  afterAll(() => {
-    restoreFile()
-  })
-
-  function restoreFile() {
-    logger.log('restoring...')
-    delete require.cache[originalPath]
-
-    try {
-      fs.unlinkSync(tempPath)
-    } catch (unlinkError) {
-      logger.error('不应该报错 Error restoring original file:', unlinkError)
-
-      throw unlinkError
-    }
-  }

第五版:封装 📦

这种修改三方模块源码的方式具备通用性,我们最后在进阶一版,将这种隔离性强安全的修改三方模块源码模式封装成方法。

一、patchModule 核心方法:封装不变的部分 🗿

type IPath = string
type ISourceCode = string


function patchModule(
  moduleName: string,
  modifySourceCode: ({ originalPath }: { originalPath: IPath }) => ISourceCode,
): void {
  const originalPath = require.resolve(moduleName)

  const tempPath = path.join(tmpdir(), `${moduleName}-index.js`)
  logger.log('tempPath:', tempPath)

  fs.writeFileSync(tempPath, modifySourceCode({ originalPath }))

  // 覆盖 require 缓存
  const tempModule = require(tempPath)
  const originalModule = require(originalPath)

  originalModule.exports = tempModule

  afterAll(() => {
    restoreFile()
  })

  function restoreFile() {
    logger.log('restoring...')
    delete require.cache[originalPath]

    try {
      fs.unlinkSync(tempPath)
    } catch (unlinkError) {
      logger.error('不应该报错 Error restoring original file:', unlinkError)

      throw unlinkError
    }
  }
}

二、patch 核心业务操作:隔离变化的部分 ⚡

function patch({ originalPath }: { originalPath: IPath }): ISourceCode {
  // 备份原始模块
  const originalCode = fs.readFileSync(originalPath, 'utf8')
  const originalDir = path.dirname(originalPath)

  // 修改模块内容
  // fix error: Cannot find module './debug' from 'C:\Users\foo\AppData\Local\Temp\follow-redirects-index.js'
  let modifiedCode = originalCode.replace(/require\(['"](\.\/[^'"]+)['"]\)/g, (_, relPath) => {
    const absPath = path.join(originalDir, relPath)
    const unixPath = toUnixPath(absPath)
    logger.log('absPath:', absPath)
    logger.log('unixPath:', unixPath)

    return `require('${unixPath}')`
  })

  modifiedCode = modifiedCode.replace(
    /Error\.captureStackTrace\(this, this\.constructor\);/g,
    'Error.captureStackTrace(new Error(), this.constructor);',
  )

  return modifiedCode
}

三、二者结合完成需求 Mission Complete 🤝

beforeAll(() => {
  fixFollowRedirectsFirstArgumentMustBeAnErrorObject()
})

function fixFollowRedirectsFirstArgumentMustBeAnErrorObject() {
  return patchModule('follow-redirects', patch)
}

👨‍💻 完整代码

未封装版本

封装版本

github.com/legend80s/s…

🎯 最后

但是导致这个问题的根本原因还未排查到,我怀疑这是 bun 的 bug 没有完全兼容 Node.js,目前 bun 有一个 issue github.com/oven-sh/bun… ,我也留言了。

公众号『JavaScript与编程艺术』

写一个 bun 插件解决导入 svg 文件的问题 - bun 单元测试系列

作者 Legend80s
2025年8月11日 21:21

💎 价值

本文通过自定义 bun 插件解决 bun 具名导入 svg 遇到的问题,同时通过灵活运用 onResolveonLoad 钩子『编织出』导入关系图,精准还原导入方式,让我们熟悉了插件的写法和生命周期。

🤕 问题

如果你在代码里面通过这种 svgr 自动转 React Component 的写法导入 svg 文件,代码运行没问题,但是 bun test 会失败。

import { ReactComponent as ApiKeyIcon } from './api-key.svg'

SyntaxError: Import named 'ReactComponent' not found in module '/path/to/repo/api-key.svg'.

早在 2023 年就有很多人在 bun 的 issue 反馈 bun test fails whenever it encounters an SVG #3673,这是从 CRA 项目转到 bun 遇到的第一个问题。

🧩 解法

写一个 bun 插件,通过 @svgr/core 将“不认识”的模块转成 React Component:

下面是 issue github.com/oven-sh/bun… 内给出的写法。

# bunfig.toml
[test]
preload = ["./bunSvgPlugin.ts"]
// bunSvgPlugin.ts
import { plugin } from 'bun';

plugin({
    name: 'SVG',
    async setup(build) {
        const { transform } = await import('@svgr/core');
        const { readFileSync } = await import('fs');

        build.onLoad({ filter: /\.(svg)$/ }, async args => {
            const text = readFileSync(args.path, 'utf8');
            const contents = await transform(
                text,
                {
                    icon: true,
                    exportType: 'named',
                    plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'],
                },
                { componentName: 'ReactComponent' }
            );

            return {
                contents,
                loader: 'js', // not sure why js and not jsx, but it works only this way
            };
        });
    },
});

我对其做了一些修改,1)因为只是测试我们无需真正地将 svg 一比一转换,这样能减少包依赖且性能更好。2)且上述代码默认我们所有的引用方式都是命名导出 import as,而其实默认引用无需转换,否则会导致 import fooIcon from 'path/to/foo.svg' 这种写法也会一并转换(默认导出通常会配合 <img src={fooIcon} /> 使用),所以我们需要过滤出这些非命名导出这些才是我们的目标。

第一步:极简转换 svg

我们只是做单元测试或者集成测试,并非端到端测试,不是让真正让组件渲染出来,故可以简化 svg 的转换,只要能标识出 svg 的“身份”即可,“身份”用 svg 的路径唯一标识即可。

即这段代码的用途:

export function ReactComponent() {
  return <svg
    aria-label="${relativeSvgPath} simplified by bunSvgPlugin">
  </svg>
  }

故这些包都可以不用:

// package.json
- @svgr/core
- @svgr/plugin-svgo
- @svgr/plugin-jsx
// tests/svgPlugin.ts
-const { transform } = await import('@svgr/core');
-const contents = await transform(
-    text,
-    {
-        icon: true,
-        exportType: 'named',
-        plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'],
-    },
-    { componentName: 'ReactComponent' }
-);

+ const relativeSvgPath = toUnixPath(path.relative('src', args.path))

+const contents = `export function ReactComponent() {
+  return <svg aria-label="${relativeSvgPath} simplified by +bunSvgPlugin"></svg>
+}`

注意:svg 的路径要相对路径,否则在你电脑上可以运行,但在 CI 或其他人的电脑上单测就失败。

第二步:识别出真正需要转换的 svg - 命名导出

这一步是本文最难的地方,我们既要拿到 svg 的路径,而且要拿到 import 这些 svg 的文件本身,然后分析其 import 方式,是否是严格的命名导出,即 import { ReactComponent as xx } from 'xxx'

首先我们要熟悉 bun 插件的生命周期:

插件可以注册回调到打包过程的不同阶段,即生命周期:

  • onStart(): Run once the bundler has started a bundle
  • onResolve(): Run before a module is resolved
  • onLoad(): Run before a module is loaded.
  • onBeforeParse(): Run zero-copy native addons in the parser thread before a file is parsed.

bun.com/docs/bundle…

onResolveonLoad 是我们本文的重点。

之前的代码仅使用了 onLoad 收集到了 svg 本身的路径,我们还需要知道导入这个 svg 的文件的路径才能识别是否符合我们的目标导入方式,可以用 ast 或者正则表达式,简单起见使用正则表达式即可。我们可以通过 onResolve 建立二者的导入关系图。

2.1 第一步:建立导入关系图

首先需要熟悉 onResolve 的参数:

type PluginBuilder = {
  onStart(callback: () => void): void;
  onResolve: (
    args: { filter: RegExp; namespace?: string },
    callback: (args: { path: string; importer: string }) => {
      path: string;
      namespace?: string;
    } | void,
  ) => void;
  onLoad: (
    args: { filter: RegExp; namespace?: string },
    defer: () => Promise<void>,
    callback: (args: { path: string }) => {
      loader?: Loader;
      contents?: string;
      exports?: Record<string, any>;
    },
  ) => void;
  config: BuildConfig;
};

type Loader = "js" | "jsx" | "ts" | "tsx" | "css" | "json" | "toml";

onResolvecallback 我们可以拿到:

  • importer:命中 filter 的文件路径
  • path:文件内的导入语句的 path
// 存储文件导入关系
const importGraph = new Map<string, Set<string>>()

const SVG_REGEXP = /^(?!.*node_modules).*\.(svg)$/

// 第一步:建立导入关系图
build.onResolve({ filter: SVG_REGEXP }, (args) => {
    if (!importGraph.has(args.importer)) {
      importGraph.set(args.importer, new Set())
    }

    importGraph.get(args.importer)?.add(args.path)

    return null
})

importGraph key 是文件路径,value 是文件内发生的导入事件。比如假设 foo.tsx 导入了两个命名 svg,一个默认导入:

// foo.tsx
import { ReactComponent as CopyIcon } from '@/assets/copy-gray.svg'
import { ReactComponent as ReloadIcon } from './reload.svg'

import userIcon from './user.svg'

那么形成的导入关系图如下:

// importGraph is
Map {
  '/absolute/path/to/foo.tsx': Set {
    '@/assets/copy-gray.svg',
    './reload.svg',
    './user.svg'
  }
}

经过这一步我们就可以在 onLoad 中根据 svg 的 path 反查出引入其的文件路径,然后分析文件内容里面的导入方式即可确定是否需要转换。

2.1 第二步:反向查找哪些文件导入了当前 SVG

onLoad 回调中利用 onResolve 建立的引用关系反查导入方式是否符合目标。

build.onLoad({ filter: SVG_REGEXP }, (args) => {
  // log('importGraph:', importGraph)
  // 检查是否有文件通过 `{ ReactComponent }` 导入此 SVG
  const shouldTransform = checkIfReactComponentImport(args.path)

  if (!shouldTransform) {
    log('not:', args.path)

    return {
      contents: `export default ${JSON.stringify(args.path)}`,
      loader: 'js',
    }
  }

  log('yes:', args.path)

  const relativeSvgPath = toUnixPath(path.relative('src', args.path))

  return {
    contents: `export function ReactComponent() { return <svg aria-label="${relativeSvgPath}-simplified by bunSvgPlugin"></svg> }`,
    loader: 'js', // not sure why js and not jsx, but it works only this way
  }
})

关键代码在 checkIfReactComponentImport

// 第二步:反向查找哪些文件导入了当前 SVG
function checkIfReactComponentImport(svgPath: string): boolean {
  for (const [importer, importedFiles] of importGraph) {
    if (
      Array.from(importedFiles).some((relativeSvgPath) =>
        isPathEquivalent({ relativePath: relativeSvgPath, absolutePath: svgPath }),
      )
    ) {
      const code = readFileSync(importer, 'utf8')

      if (hasReactComponentImport(code, svgPath)) {
        return true
      }
    }
  }

  return false
}

读取文件内容然后验证导入方式是否符合目标 hasReactComponentImport

function hasReactComponentImport(code: string, svgPath: string) {
  const relativeSvgPath = path.relative('src', svgPath)

  return code.split('\n').some((line) => {
    if (line.includes(`import { ReactComponent as `)) {
      if (line.includes(toUnixPath(relativeSvgPath))) {
        debug('line hit condition 1:', line)

        return true
      } else {
        // @ts-expect-error
        const importPath = line.split(' from ').at(-1).split("'").at(1) as string

        if (isPathEquivalent({ absolutePath: svgPath, relativePath: importPath })) {
          debug('line hit condition 2:', line)

          return true
        }

        debug('line miss:', line)
      }
    }
  })
}

逐行分析首先匹配到 import { ReactComponent as 这种导入模式,然后分析其导入的路径是否匹配 svg 路径。

完整代码,见 github github.com/legend80s/s…

🎯 总结

本文比较硬,开发了一个 bun svg plugin 解决了 bun test 遇到导入 svg 文件的报错问题 Import named 'ReactComponent' not found in module,使用了一种高效、隔离度高、精准的方式。注意本文的插件只适用于单元测试,如果是构建给生产环境用,仍然需要 svgr 一比一进行转换。

公众号『JavaScript与编程艺术』

TypeScript v5 一个非常有用的新语法:“const 类型参数”

作者 Legend80s
2025年8月11日 21:18

前述文章讲到我们的 CRA(Create React App)项目升级 TypeScript 到 v5 遇到了 TypeScript 版本冲突问题,但是没有讲到为何要进行此次升级,本文介绍原因以及详细介绍 TypeScript v5 新引入的一个语法。

ts-pattern Expected 0 arguments, but got 1

项目引入 ts-pattern 后,需要升级到 TypeScript v5,否则将报错:match doesnt work (Expected 0 arguments, but got 1)

issue 亦有记载 github.com/gvergnaud/t…

因为 ts-pattern 引入了一个新语法:“const 类型参数”const type parameters)。

为何要引入 ts-pattern?

如果大家用过 Rust 或 Python 的 match 一定会被该库吸引,具体介绍可见我的这篇文章 TypeScript 系列:无需再写 if / else 了!引入 Rust / Python 模式匹配到 TS 中

export declare function match<const input, output = symbols.unset>(value: input): Match<input, output>;
//                            ^^^^^

const 类型参数即此处的 const input

const 类型参数

这是 TypeScript 5.0 引入的特性。作用:

  • 它会尽可能保持字面量类型的精确性,而不是拓宽为更通用的类型
  • 对于对象和数组,它会推断出只读类型

一句话介绍:const type parameters 让我们的类型能进一步收窄而无需在调用时每次手动增加 as const

为了了解新语法的用处,我们先看看普通泛型写法:

declare function f<T>(x: T): T;
const r = f({ a: 1 });   // T 推断为 { a: number },不是 { a: 1 }

我们想进行严格推断,在 TypeScript v4 可以通过 as const 将类型从 number 收窄到字面量 1

declare function f2<T>(x: T): T;
const r2 = f2({ a: 1 } as const); // T 推断为 { readonly a: 1 }

但每次调用都得加麻烦且易忘记 😩,v5 可以给入参增加 const 关键词,一劳永逸 💐:

declare function f3<const T>(x: T): T;
//                  ^^^^^

const r3 = f3({ a: 1 }); // T 推断为 { readonly a: 1 }

大家可以在 playground 试试 www.typescriptlang.org/play/

官方示例

我们再结合官方示例讲解下,官方示例是 v5.0 的,现在最新版本 v5.8.3 有些许变化也一并会讲到。

当 TypeScript 推断一个对象的类型时,通常会选用一种泛化的类型。例如,在下面这段代码里,names 的推断类型是 string[]

type HasNames = { names: readonly string[] };
function getNamesExactly<T extends HasNames>(arg: T): T["names"] {
    return arg.names;
}

// 推断类型:string[]
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });

这样做的目的通常是为了后续还能对这个数组进行修改。

然而,根据 getNamesExactly 的具体实现及其使用场景,我们往往更希望得到更精确的类型

在 5.0 之前,库作者通常只能建议调用者在某些地方手动加上 as const 来达到期望的推断:

// 我们想要的类型:
//    readonly ["Alice", "Bob", "Eve"]
// 实际得到的类型:
//    string[]
const names1 = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });

// 这样才行:
//    readonly ["Alice", "Bob", "Eve"]
const names2 = getNamesExactly({ names: ["Alice", "Bob", "Eve"] } as const);

这种做法既繁琐又容易忘。在 TypeScript 5.0 中,你可以在类型参数声明前加 const 修饰符,让上述 as const 的推断成为默认行为:

type HasNames = { names: readonly string[] }; // 我注:此处的 readonly 加或不加有差别下文会讲到
function getNamesExactly<const T extends HasNames>(arg: T): T["names"] {
//                       ^^^^^
    return arg.names;
}

// 推断类型:readonly ["Alice", "Bob", "Eve"]
// 注意:这里不再需要手动写 as const
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });

注意,const 修饰符并不会拒绝可变值,也不要求约束必须是不可变类型
如果约束本身是可变类型,结果可能出乎意料。例如:

declare function fnBad<const T extends string[]>(args: T): void;
// T 仍旧是 string[],因为 readonly ["a", "b", "c"] 不能赋值给 string[] - 我注:这里有误
fnBad(["a", "b", "c"]);

在这里,推断候选是 readonly ["a", "b", "c"],而只读数组不能赋给可变数组,于是推断回退到约束 string[],调用仍然成功。

我注:上述 fnBad 描述是 v5.0 的,最新版 TS 如果不加 readonly 并不会推断为 string[] 而是 ["a", "b", "c"],二者的区别是后者只能添加字符串 a b c,但仍然是可变的,因为虽然加了 const 但是没有加 readonly

改一下返回值类型,就容易理解一些了

declare function fnBad<const T extends string[]>(args: T): T;
// T 在 v5.0 是 string[],在 v5.8 是 ["a", "b", "c"],因为 readonly ["a", "b", "c"] 不能赋值给 string[]

// v5.8 list 类型 ["a", "b", "c"] 非 readonly ["a", "b", "c"] 故可修改
const list = fnBad(["a", "b", "c"]);

// 但只能 push "a" / "b" / "c",如果是 v5.0 可 push 任意字符串。
list.push('a')

正确的做法是把约束也设成 readonly string[](我注:这句话没错):

declare function fnGood<const T extends readonly string[]>(args: T): void;
// T 推断为 readonly ["a", "b", "c"]
fnGood(["a", "b", "c"]);

同样要记住:const 修饰符只影响在调用处直接写出的对象、数组或基本类型字面量;对于“事先存好的变量”不会起作用:

declare function fnGood<const T extends readonly string[]>(args: T): void;
const arr = ["a", "b", "c"];
// T 仍是 string[] —— const 修饰符在此无效
fnGood(arr);

ts-pattern 结合 const type parameters

到这里我们就能理解为何 ts-pattern 需要依赖这个新特性。

declare let input: "A" | "B";

return match(input)
  .with("A", () => "It's an A!")
  .with("B", () => "It's a B!")
  .exhaustive();

如果不利用 const 类型参数,ts-pattern 的 with 函数参数就可以是任意字符串,有了 const 类型参数的“加持”才能做到精确推断以及穷尽性检查

更多阅读

公众号『JavaScript与编程艺术』。

CRA 项目 create-react-app 请谨慎升级 TypeScript

作者 Legend80s
2025年8月11日 21:11

本文解决了 create-react-app 或 react-scripts 项目在升级 TypeScript v4 到 v5 遇到的问题,还讲解了 TypeScript v5 新的 const 类型参数 语法。

问题

一个使用官方 create-react-app 的项目,某次为了引入 ts-pattern 将 TypeScript 从 v4 升级到 v5,随后在安装或移除其他依赖的时候报错:

❯ npm uninstall markdown-it-katex 
npm error code ERESOLVE
npm error ERESOLVE could not resolve
...
npm error
npm error Conflicting peer dependency: typescript@4.9.5
npm error node_modules/typescript
npm error   peerOptional typescript@"^3.2.1 || ^4" from react-scripts@5.0.1

重点:peerOptional typescript@"^3.2.1 || ^4" from react-scripts@5.0.1

翻译一下就是 react-scripts 写死了 TypeScript 的范围,但是 CRA (create-react-app) 或 react-scripts 已经不维护和更新了,没法给其提 PR,社区也已有 issue,我们只能自己想办法。

CRA 不维护:

Upgrade TypeScript to v5 in React-scripts #13283

github.com/facebook/cr…

CRA is pretty much dead. I advise switching to vite. I have done this for a few projects, and it is really straight forward.

方案 1:vite

方案 1 迁移到 vite,但是我们项目步子还不想跨的这么大。

方案 2:--legacy-peer-deps

报错之后 npm 紧接着给了一个解决方案,但是我觉得不是很好,因为其针对的范围太大而不仅仅是 TypeScript 的版本不匹配问题:

npm error Fix the upstream dependency conflict, or retry
npm error this command with --force or --legacy-peer-deps
npm error to accept an incorrect (and potentially broken) dependency resolution.  

也就是在每次 npm install/uninstall 的时候增加 flag --force or --legacy-peer-deps,或者在 .npmrc 中新增就无需每次安装或卸载都加。

方案 3:overrides

项目 package.json 增加 overrides(如果你是其他包管理也有相应的字段,比如 yarn 的 resolution):

  "dependencies": {
    "typescript": "^5.8.3",
  }
  "// 为了升级 typescript 到 v5(ts-pattern 以及趋势) 但 react-scripts 限定了 v4 导致下面的报错": "",
  "// fix Could not resolve dependency: npm error peerOptional typescript@\"^3.2.1 || ^4\" from react-scripts@5.0.1": "",
  "// overrides 比 --force 或 --legacy-peer-deps 更安全因为可以精确锁定某个依赖的版本": "",
  "overrides": {
    "typescript": "$typescript"
  },

注意 "$typescript" 这是一种引用写法,当然你可以写成 ^5.8.3,但引用是一种更好的方式,以后更新 TypeScript 版本此处无需随着变化。注意不能写死版本号 "5.8.3",否则报错 EOVERRIDE

❯ npm uninstall markdown-it-katex
npm error code EOVERRIDE
npm error Override for typescript@^5.8.3 conflicts with direct dependency

更多阅读

{
  "pnpm": {
    "overrides": {
      "foo": "^1.0.0",
      "quux": "npm:@myorg/quux@^1.0.0",
      "bar@^2.1.0": "3.0.0",
      "qar@1>zoo": "2"
    }
  }
}

pnpm 同样支持引用 "bar": "$foo",还支持删除某个依赖 "foo@1.0.0>heavy-dep": "-",比如某个大的依赖没有用到,从而减少安装时间。

为何安装 ts-pattern 需要升级 TypeScript

最后回答一个问题,为什么 ts-pattern 需要升级到 ts v5。ts-pattern 引入了一个新的语法:“const 类型参数”const type parameters)。

export declare function match<const input, output = symbols.unset>(value: input): Match<input, output>;

即此处的 const input,如果不升级将报错:match doesnt work (Expected 0 arguments, but got 1)

详见我的另一篇文章:TypeScript v5 一个非常有用的新语法:“const 类型参数”

❌
❌