写一个 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与编程艺术』

相关推荐
某公司摸鱼前端10 小时前
一键 i18n 国际化神库!适配 Vue、React!
前端·vue.js·react.js·i18n
前端达人12 小时前
从 useEffect 解放出来!异步请求 + 缓存刷新 + 数据更新,React Query全搞定
前端·javascript·react.js·缓存·前端框架
qczg_wxg12 小时前
ReactNative系统组件四
javascript·react native·react.js
哒哒哒就是我14 小时前
React中,函数组件里执行setState后到UI上看到最新内容的呈现,react内部会经历哪些过程?
前端·react.js·前端框架
MoSheng菜鸟16 小时前
React Native开发
android·react.js·ios
维维酱16 小时前
为什么说 useCallback 实际上是 useMemo 的特例
前端·react.js
颜酱16 小时前
基于 Ant Design 的配置化表单开发指南
前端·javascript·react.js
柯南二号17 小时前
【大前端】React 父子组件通信、子父通信、以及兄弟(同级)组件通信
前端·javascript·react.js
维维酱17 小时前
React.memo 实现原理解析
前端·react.js
子兮曰21 小时前
🚀 图片加载速度提升300%!Vue/React项目WebP兼容方案大揭秘
前端·vue.js·react.js