关于 babel-plugin-macros
的介绍,简单可以总结为一句话:“在代码中显式声明需要在编译时需要做的事情”。
为什么需要“显式”?官方文档有介绍使用 babel plugin 方式面临的如下问题:
- They can lead to confusion because when looking at code in a project, you might not know that there’s a plugin transforming that code.
- They have to be globally configured or configured out-of-band (in a .babelrc or webpack config).
- They can conflict in very confusing ways due to the fact that all babel plugins run simultaneously (on a single walk of Babel’s AST).
最近刚好碰上个需求适合 macros 的场景,限制如下:
- 一个插件系统,插件由很多部件组成
- A 部件可能在系统的多个地方被使用
- A 部件根据不同的场景需要引入不同的代码
- 消费 A 部件的地方只接收同步代码
- 场景不感知插件细节,由插件管理器统一调度
理论上来说,插件中 A 部件的声明可以为如下形式:
import module1 from 'xxxx';
import module2 from 'yyyy';
const plugin = {
A: {
scence1: module1,
scence2: module2,
}
}
这样,在使用 A 部件时,根据场景取就可以了。
但这样引入了一个问题,场景是互斥的,这里所有场景的代码却被同时引入了,增大了包体积,影响性能。
所以,我们期望以如下形式来使用:
// plugin.ts
const plugin = {
A: {
scence1: 'module1 path',
scence2: 'module2 path',
}
}
// scence1.ts
pluginManager.usePlugin('A', 'module1 path')
当然,这代码并不能 work,因为只是声明了模块的路径,并未引入代码,我们期望代码被编译为:
import someRandomName from 'module1 path';
pluginManager.usePlugin('A', someRandomName);
借助babel-plugin-macros
可以“轻松”(不熟悉 ast 不太轻松)实现该需求,定义如下接口:
/**
* 为指定的文件路径绑定标识符
* @param identifier
* @param filePath
*/
declare function declare(identifier: string, filePath: string): void;
/**
* 通过文件标识符使用自动导入的文件
* @param identifier
*/
declare function use<T=any>(identifier: string): T;
export = {
declare,
use,
};
使用:
import macro from 'plugin.macro';
// 在插件中绑定
macro.declare('scence1', 'path to module1');
// 在场景中使用
macro.use('scence1');
完整实现:
ps: 由于不熟悉 babel 的 AST 操作,许多逻辑都抄抄改改自 import-all.macro
const { createMacro } = require('babel-plugin-macros');
const path = require('path');
const fs = require('fs');
module.exports = createMacro(macro);
// 全局的映射文件
const importMap = new Map();
function macro({ references, ...macroOptions }) {
references.default.forEach((referencePath) => {
if (
referencePath.parentPath.type === 'MemberExpression' &&
referencePath.parentPath.node.property.name === 'declare'
) {
declare({ referencePath, ...macroOptions });
} else if (
referencePath.parentPath.type === 'MemberExpression' &&
referencePath.parentPath.node.property.name === 'use'
) {
use({ referencePath, ...macroOptions });
} else if (
referencePath.parentPath.type === 'MemberExpression' &&
referencePath.parentPath.node.property.name === 'useNamespace'
) {
useNamespace({ referencePath, ...macroOptions });
} else {
throw new Error(`only support 'declare' and 'use' method`);
}
});
}
// 收集路径声明
function declare({ referencePath, state, babel }) {
const { types: t } = babel;
const {
file: {
opts: { filename },
},
} = state;
const callExpressionPath = referencePath.parentPath.parentPath;
let fileIdentifier;
let relativePath;
let namespace;
try {
fileIdentifier = callExpressionPath.get('arguments')[0].evaluate().value;
relativePath = callExpressionPath.get('arguments')[1].evaluate().value;
namespace = callExpressionPath.get('arguments')[2].evaluate().value;
} catch (error) {
// ignore the error
}
if (!relativePath || !fileIdentifier) {
throw new Error(
`params error: ${callExpressionPath.getSource()}. expect: (identifier: string, filePath: string)`,
);
}
const absolutePath = path.join(filename, '../', relativePath);
const exits = fs.existsSync(absolutePath);
if (!exits) {
throw new Error(`${absolutePath} not exits.`);
}
const isFile = fs.lstatSync(absolutePath).isFile();
if (!isFile) {
throw new Error(`${absolutePath} is not a file.`);
}
if (
importMap.has(fileIdentifier) &&
importMap.get(fileIdentifier) !== absolutePath
) {
console.warn(
`${fileIdentifier} already defined with plugin ${importMap.get(
fileIdentifier,
)}`,
);
}
importMap.set(fileIdentifier, absolutePath);
if (namespace) {
let files = []
if (importMap.has(namespace)) {
files = importMap.get(namespace);
}
// 热重载可能导致重复添加
if (!files.find((id, p) => id === fileIdentifier && p === absolutePath)) {
files.push([fileIdentifier, absolutePath]);
}
importMap.set(namespace, files);
}
callExpressionPath.replaceWith(t.expressionStatement(t.stringLiteral('')));
}
// 使用
function use({ referencePath, state, babel }) {
const { types: t } = babel;
const callExpressionPath = referencePath.parentPath.parentPath;
let fileIdentifier;
try {
fileIdentifier = callExpressionPath.get('arguments')[0].evaluate().value;
} catch (error) {
// ignore the error
}
if (!fileIdentifier) {
throw new Error(`params error: ${callExpressionPath.getSource()}.`);
}
if (!importMap.has(fileIdentifier)) {
throw new Error(
`can't find identifier:${fileIdentifier}`,
);
}
const absolutePath = importMap.get(fileIdentifier);
const id = referencePath.scope.generateUidIdentifier(absolutePath);
const importNode = t.importDeclaration(
[t.importDefaultSpecifier(id)],
t.stringLiteral(absolutePath),
);
const program = state.file.path;
program.unshiftContainer('body', importNode);
callExpressionPath.replaceWith(id);
}
// 使用命名空间
function useNamespace({ referencePath, state, babel }) {
const { types: t } = babel;
const callExpressionPath = referencePath.parentPath.parentPath;
let namespace;
try {
namespace = callExpressionPath.get('arguments')[0].evaluate().value;
} catch (error) {
// ignore the error
}
if (!namespace) {
throw new Error(`params error: ${callExpressionPath.getSource()}.`);
}
const files = importMap.get(namespace) || [];
const objectProperties = files.map(([id, absolutePath]) => {
const localIdentifier = t.identifier(id);
const importNode = t.importDeclaration(
[t.importSpecifier(localIdentifier, t.identifier('default'))],
t.stringLiteral(absolutePath),
);
state.file.path.unshiftContainer('body', importNode);
return t.objectProperty(t.stringLiteral(id), localIdentifier);
});
const objectExpression = t.objectExpression(objectProperties)
callExpressionPath.replaceWith(objectExpression);
}
PS: 后面发现该方案在开启 babel 缓存后存在重大缺陷。缓存+副作用,你品,你细品。。。