本文实践依据另一篇文章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