likes
comments
collection
share

业务实践——实现一个简易DynamicImport插件

作者站长头像
站长
· 阅读数 19

本文实践基于另一篇文章DynamicImport实现原理中介绍的理论基础,请提前翻阅,便于理解。

业务背景

近期在团队内部做 Vite 迁移的相关改造时,发现部分老项目中存在一些稀奇古怪的依赖,它们提供的构建产物并不彻底,留下了一些特殊语法,这些特殊语法假定了用户使用的构建工具是 Webpack,这直接导致项目在迁移到 Vite 之后崩溃。

...
"domProps": {
  "innerHTML": require("!html-loader!./icon/close-big.svg")
}
...
"domProps": {
  "innerHTML": require("!html-loader!./icon/" + props.name + ".svg")
}
...

Vite 基于 rollup 的封装而来,并且内置了 @rollup/plugin-dynamic-import-vars。但是该插件只会处理 import 表达式语法,并不会处理 require 的动态引入,会直接忽略掉。虽然可以正常打包,但是产物存在运行时错误,找不到相关文件。而且Vite 并不支持通过 !html-loader! 这种路径前缀方式指定 loader,这个问题也需要一并处理。

解决方案

上面提到的这个问题如果不解决,技术改造将无法继续进行,由于历史依赖不敢擅动,市面上又没有找到相关解决方案,我们只能自己动手编写插件,所谓自己动手丰衣足食。

由于 Vite 在开发时使用 esbuild,生产构建时使用 rollup,想要解决问题,我们需要同时为这两种构建工具编写插件,略显繁琐。这就不得不提到 unplugin 这个项目,基于 unplugin 编写插件,可以使一套代码运行于不同的构建体系。

最终通过借鉴 @rollup/plugin-dynamic-import-vars 的实现方式,拓展定制了一个 100 行左右的小插件完美解决了问题,也算是趁热打铁的一份实践。

实现思路

首先,定制化插件,为避免误伤,我们需要确定处理范围,本例中只需要处理问题依赖@test/test-module中包含的文件。

观察源码发现,需要处理的部分都是采用 "innerHTML" 的方式设置 dom 内容,并且值为对应的 svg 文件。

由此推出,我们只需要通过分析找到这部分代码,直接读取对应的文件,然后将文件内容内联即可完成处理。只不过在内联文件内容的时候,需要一并处理动态引入问题。

核心代码解析

基于以上分析,插件框架代码如下:

import { createUnplugin } from 'unplugin';
import { createFilter } from '@rollup/pluginutils';

export const fixDepError = createUnplugin(() => {
  return {
    name: 'unplugin-fix-dynamic-import-error',
    // 在这里限定插件转换的文件范围
    transformInclude: createFilter('**/@test/test-module/**/*.js'),
    resolveId: (id, importer) => {
      // 在这里处理静态引用中包含的 !html-loader! 前缀
    },
    load: id => {
      // 在这里将静态引用的 svg 文件转换为 JavaScript module
    },
    transform(code, id) {
      //  在这里转换动态引用,同时一并处理,动态引用路径中包含的 !html-loader! 前缀
    },
  };
});

处理静态引用路径

对于静态引用路径,我们可以利用插件生命周期中的 resolveId 和 load 函数进行联合处理。

resolveId 钩子中,我们会接收到所有静态引用的路径 id,经过构建工具的调度,返回的 id 会取代原有的 id,如果什么都没有返回,那什么也不会发生。

resolveId: (id, importer) => {
  if (id.startsWith('!html-loader!')) {
    return path.resolve(path.dirname(importer), id.replace('!html-loader!', ''));
  }
}

上面的代码消除了静态引用路径 id 中的 !html-loader! 字样,如此一来,构建工具便可以正确解析到相关文件路径,并做进一步处理。

转换 svg 模块

从源码中统一使用 innerHTML 设置 dom 内容,以及指明使用 html-loader 处理相关 svg 文件的行为中,可以探出,此处是想将 svg 文件内容直接当做字符串进行设置。

在 resolveId 解析完路径 id 后,下一个处理钩子即是 load 钩子。 load 钩子接收所有处理过后的路径 id,并根据这些 id 返回相应的文件内容,返回内容均被视为 JS 模块。

我们可以通过 load 钩子在文件内容解析过程上做文章,将 svg 文件转换为 JavaScript 模块,简单包裹即可:

load: id => {
  if (id.endsWith('.svg')) return `export default \`${fs.readFileSync(id, 'utf-8')}\`;`;
}

至此,静态路径中包含 !html-loader! 字样的 svg 文件引用处理完毕。

处理动态引用

动态引用的处理过程本质上是对文件内容的语法分析,我们可以通过 transform 钩子进行。

猜的不错,load 钩子的下一步就是 transform 钩子。

transform 钩子接收所有 load 处理完毕的文件内容,我们可以在这里对文件内容进行进一步细致的处理,例如 AST 语法分析,进行局部替换等等。 返回内容会替换原本的文件内容,如果什么都没有返回,那什么也不会发生。

至于为什么不在 load 钩子里面做这些事,还要多出来一个 transform 钩子,想必是为了拆分职责,便于维护。

首先使用上下文中自带的 parse 方法生成 AST节点,调试发现生成的节点为 AcornNode,为了遍历节点,我们需要引入对应的依赖 acorn-walk

其次,明确我们要处理的源码位置,编写对应的 visitors。这里需要借助 AST explorer 这个在线项目,我们将需要分析的源码粘贴进去并选择对应的 parser,也就是 acorn,右侧将会出现对应的 AST 结构。

业务实践——实现一个简易DynamicImport插件

业务实践——实现一个简易DynamicImport插件

结合实际,我们有如下代码:

import walk from 'acorn-walk';
import glob from 'fast-glob';
import MagicString from 'magic-string';

transform(code, id) {
  const parsedAST = this.parse(code);
  walk.simple(parsedAST, {
    CallExpression: node => {
      if (node.callee.name !== 'require' || !node.arguments[0]) return;
      const argv0 = node.arguments[0];
      // 由于源码中主要存在的是 二元表达式,故此处只处理 BinaryExpress 节点
      if (argv0.type !== 'BinaryExpression') return;
      // expressionToGlob 负责将 DynamicImport 对应的表达式转换为通配符,下文提及
      let globPattern = expressionToGlob(argv0);
      // 我们仅处理包含 !html-loader! 特殊标记的语法 和 svg 文件动态引入
      if (!globPattern.includes('!html-loader!') || path.extname(globPattern) !== '.svg') return;
      globPattern = globPattern.replace('!html-loader!', '');
      
      // 基于所在文件目录执行通配符抓取文件,这也是为什么动态引用只支持相对路径
      // 因为我们需要相对发起引用的文件去通配文件
      const cwd = path.dirname(id);
      const files = glob.sync(globPattern, { cwd });
      // glob 抓取的文件如果在当前目录,是没有 './' 前缀的,我们需要判断是否为相对路径
      const paths = files.map(r => (r.startsWith('./') || r.startsWith('../')) ? r : `./${r}`);
      // 如果通配符没有抓取到文件,什么也不会做
      // 如果有,则会替换源码对应位置的字符串
      if (paths.length) {
        // ms 为当前 code 的 MagicString 实例
        ms.overwrite(
          node.start,
          node.end,
          code.substring(node.start, node.end)
            // 首先消除特殊标记
            .replace('!html-loader!', '')
            // 然后将 require 函数替换成我们的函数
            .replace('require', `__variableDynamicImportRuntime__`),
        );
        // 然后再代码结尾注入我们添加的函数 __variableDynamicImportRuntime__,下文提及
        ms.append(createDynamicImport(paths, cwd, dynamicImportIndex));
      }
    }
  })
  
  if (/* 最后做一些判断,如果存在动态引用,则返回转换后的代码 */) {
    return {
      code: ms.toString(),
      map: ms.generateMap({
        file: id,
        includeContent: true,
        hires: true,
      }),
    };
  }
}

生成通配符

上文提到了 expressionToGlob 函数,该函数的作用主要是将动态引入的表达式转换为对应的通配符,用于后续的文件抓取。

function expressionToGlob(node) {
  return node.type === 'BinaryExpression'
    ? binaryExpressionToGlob(node)
    : node.type === 'Literal' // 表达式中的字符串字面量类型
      ? node.value
      : '*';
}

function binaryExpressionToGlob(node) {
  // 我们只处理操作符为 '+' 的二元表达式,代表字符串连接
  if (node.operator !== '+') {
    throw new Error(`${node.operator} operator is not supported.`);
  }
  // 加号连接的表达式,将左右递归连接
  return `${expressionToGlob(node.left)}${expressionToGlob(node.right)}`;
}

由于该插件属于定制化需求,我们只需要关注二元表达式(BinaryExpression)即可。如果要作为通用插件,我们还需要考虑更多的情况,例如模板字符串节点(TemplateLiteral)。

更多完整实现可以参考 plugins/dynamic-import-to-glob.js at master · rollup/plugins

代码注入

根据通配符匹配到文件之后,我们需要构造一个动态函数去替换原有的 import/require 语法

function createDynamicImport(paths, cwd, index) {
  return `
    function __variableDynamicImportRuntime${index}__(path) {
      switch (path) {
        ${paths.map(p => `case '${p}': return \`${fs.readFileSync(path.resolve(cwd, p), 'utf-8')}\`;`).join('\n    ')}
        ${`default: return new Promise(function(resolve, reject) {
          (typeof queueMicrotask === 'function' ? queueMicrotask : setTimeout)(
            reject.bind(null, new Error("Unknown variable dynamic import: " + path))
          );
        })\n`}  }
    }\n\n
`;
}

这里的逻辑非常简单,生成一段 switch 语句,根据路径返回对应文件内容。由于我们的定制场景,此处将 svg 文件内容直接读取内联即可。若在通用场景下,则直接使用 import/require 替换即可,然后交由构建工具处理:

${paths.map(p => `case '${p}': return import('${p}');`).join('\n    ')}

细节处理

上面的实现方式默认了代码中仅存在一处 DynamicImport,如果存在多处,则相同的函数名必定会造成冲突。最简单的方式就是定义一个计数变量,与函数名拼接:

transform(code, id){
  let dynamicImportIndex = -1;
  ...
  if (paths.length) {
    dynamicImportIndex += 1; // 遇到动态引入就自增
    ms.overwrite(
      node.start,
      node.end,
      code.substring(node.start, node.end)
        .replace('!html-loader!', '')
        // 将 require 函数替换成我们的函数
        .replace('require', `__variableDynamicImportRuntime${dynamicImportIndex}__`),
    );
    ms.append(createDynamicImport(paths, cwd, dynamicImportIndex));
  }
}

// 接收 index 进行命名拼接
function createDynamicImport(paths, cwd, index) {
  return `
    function __variableDynamicImportRuntime${index}__(path)
  `;
}

至此,问题依赖的转换处理完毕,项目已经能正常运行起来了。

最后

更多实现细节可以参考 plugins/index.js at master · rollup/plugins