业务实践——实现一个简易DynamicImport插件
本文实践基于另一篇文章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 结构。
结合实际,我们有如下代码:
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
转载自:https://juejin.cn/post/7186874743662837797