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

💎 价值

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

🤕 问题

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

tsx 复制代码
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... 内给出的写法。

toml 复制代码
# bunfig.toml
[test]
preload = ["./bunSvgPlugin.ts"]
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 的路径唯一标识即可。

即这段代码的用途:

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

故这些包都可以不用:

diff 复制代码
// package.json
- @svgr/core
- @svgr/plugin-svgo
- @svgr/plugin-jsx
diff 复制代码
// 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 的参数:

ts 复制代码
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
ts 复制代码
// 存储文件导入关系
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,一个默认导入:

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

import userIcon from './user.svg'

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

ts 复制代码
// 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 建立的引用关系反查导入方式是否符合目标。

ts 复制代码
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

ts 复制代码
// 第二步:反向查找哪些文件导入了当前 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

ts 复制代码
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与编程艺术』

相关推荐
niusir21 分钟前
Zustand 实战:10 行代码搞定全局状态
前端·javascript·react.js
niusir21 分钟前
React 状态管理的演进与最佳实践
前端·javascript·react.js
OneWind2 小时前
今天发现一个提升图片加载速度的方法就是使用服务器代理
react.js
鹏多多4 小时前
关于React父组件调用子组件方法forwardRef的详解和案例
前端·javascript·react.js
Hilaku9 小时前
前端的单元测试,大部分都是在自欺欺人
前端·javascript·单元测试
Lotzinfly9 小时前
10个React性能优化奇淫技巧你需要掌握😏😏😏
前端·react.js·面试
慧都小项10 小时前
Parasoft C/C++test 单元测试用例如何导出与有效管理
单元测试·测试用例·parasoft
江城开朗的豌豆10 小时前
React-Redux性能优化:告别"数据一变就刷屏"的烦恼!
前端·javascript·react.js
江城开朗的豌豆11 小时前
前端异步难题?用Redux-Thunk轻松搞定!
前端·javascript·react.js
陈陈CHENCHEN15 小时前
使用 Webpack 快速创建 React 项目 - SuperMap iClient JavaScript / Leaflet
react.js·webpack