作为一名前端菜?,日常工作就是写各种业务代码, 看着大佬们写的小工具、插件啥的,羡慕不已。 偶然想到要不也写个插件试试?试试就试试,抱着试试看的态度,开始了。
第一次写,有不当之处还望各位大佬指正。
一、插件介绍。
看图
良辰:在左边test文件中写export语句时, 会自动在右边的index文件中导出。
没错!
橘子:这样省得自己再去手动引入到index中去, 能提升不少开发效率。
没错!!
主要应用场景
类似上图的文件目录
插件安装
npm i auto-export-plugin -D
插件功能
- 文件改动时,自动收集文件中的export语句,并将其导出语句写入index.js文件中。
- 文件删除时, 自动从index文件中删除该文件的导出语句。
如果是非index.js文件改动会自动写入同级目录index.js文件中; 如果是index.js文件改动会自动写入上层目录的index.js文件中(如果不需要此特性,可以在ignored中写入/index/忽略)
用法
const path = require('path')
const AutoExport = require('auto-export-plugin')
module.exports = {
entry: './index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader'
}
]
},
plugins: [
new AutoExport({
dir: ['src', 'constants', 'utils']
})
]
}
二、原理解析
- 运用babel将被改动文件内容解析成ast, 通过ast找到export
- 对同级目录index.js文件做ast转换, 插入收集到的export
- 将index.js转换过的ast重新生产代码,写入index.js文件
三、源码解读
预备知识
AST即抽象语法树,我们写的每行代码, 每个字符都可以解析成AST。
// test.js
export const AAA = 2
整个文件解析成的AST(省去部分结构)如下
{
"type": "File"
"program": {
...
"body": [{
// ExportNamedDeclaration即为export语句
"type": "ExportNamedDeclaration",
"declaration": {
"type": "VariableDeclaration",
"declarations": [{
"type": "VariableDeclarator",
...
"id": {
"type": "Identifier",
// name即为我们导出的变量明 AAA
"name": "AAA",
...
},
"init": {
"type": "NumericLiteral",
// value即为我们导出的变量值
"value": 2
...
}
}],
"kind": "const"
}
}],
},
}
你会留意到 AST 的每一层都拥有如下相同的结构, 每一层称之为一个节点
{
type: 'xxxxx',
....
}
我们从某个文件导入时只会引入变量名,如import {AAA} from './test'
。因此我们只需收集当前文件所有导出的变量名(如:“AAA”),无需关注导出的变量值(如:“2”)。
一开始我的想法是对ast的body做遍历处理, 用’==='做类型判断,遇到ExportNamedDeclaration类型就收集变量名。
后来发现文档上有更便捷的方式,对每个节点都有两个hook: enter、exit, 在访问到该节点和离开该节点时执行,在这两个hook中可以对节点进行插入、删除、替换节点的操作。
源码来了(部分)
完整源码地址 auto-export-plugin
以下代码省去了部分片段, 可以对照完整源码进行解读。
获取改动文件的exportNames
getExportNames(filename) {
const ast = this.getAst(filename);
let exportNameMap = {};
traverse(ast, {
// 主要处理export const a = 1这种写法
ExportNamedDeclaration(path) {
if (t.isVariableDeclaration(path.node.declaration)) {
...
}
},
// 处理 export function getOne(){}写法
FunctionDeclaration(path) {
if (t.isExportNamedDeclaration(path.parent)) {
...
}
},
// 处理const A = 1; export { A }这种写法
ExportSpecifier(path) {
const name = path.node.exported.name;
exportNameMap[name] = name;
},
// 处理export default写法, 如果是export default会用文件名作为变量名
ExportDefaultDeclaration() {
...
}
});
return exportNameMap;
}
这样就会拿到对应文件对所有export变量名。
目前只想到了4种对export语句写法(如还有其他写法麻烦留言告知)。这里考虑到一个文件中可能变量声明语句较多但不一定都是export,所以对于
export const a = 1
这种写法,没有采用像其他3种方式一样单独对类型做处理,而是在ExportNamedDeclaration中进一步做判断并处理
写入index文件
autoWriteIndex(filepath, isDelete = false) {
// 根据变动文件的路径找到对应的dir
const dirName = path.dirname(filepath);
const fileName = getFileName(filepath);
// 遍历该目录下的所有文件, 如果存在index.js则通过ast转换,
// 如果不存在直接创建index.js并写入
fs.readdir(dirName, {
encoding: 'utf8',
}, (err, files) => {
let existIndex = false;
if (!err) {
files.forEach(file => {
if (file === 'index.js') {
existIndex = true;
}
});
if (!existIndex) {
...
let importExpression = `import { ${exportNames.join(', ')} } from './${fileName}'`;
...
const data = `
${importExpression}\n
export default {
${Object.values(nameMap).join(', \n')}
}
`;
fs.writeFileSync(`${dirName}/index.js`, data);
} else {
// 通过对index.js的ast做转换处理写入exportName
this.replaceContent(`${dirName}/index.js`, filepath, nameMap);
}
}
});
}
如果index.js文件存在则对它的ast做替换、插入、删除处理
replaceContent(indexpath, filePath, nameMap) {
...
traverse(indexAst, {
Program(path) {
const first = path.get('body.0');
// 因为js语法要求import语句必须写在文件最顶部,
// 所以如果index.js文件的第一个语句不是import语句,说明当前文件不存在import
// 需要创建import语句并插入文件第一个语句中
if (!t.isImportDeclaration(first)) {
const specifiers = self.createImportSpecifiers(nameMap);
path.unshiftContainer('body', self.createImportDeclaration(specifiers)(relPath));
}
// 如果不存在export default语句,需要创建并插入body下
const bodys = path.get('body')
if (!bodys.some(item => t.isExportDefaultDeclaration(item))) {
path.pushContainer('body', self.createExportDefaultDeclaration(Object.values(nameMap)))
}
},
ImportDeclaration: {
enter(path) {
if (!firstImportKey) {
firstImportKey = path.key;
}
// 如果存在改动文件的import语句, 比如改动的是test文件, index中原来存在`import { xx } from './test'`这样
// 的语句,需要将原来import的变量名替换掉
if (path.node.source.value === relPath && !importSetted) {
// 记录旧的export变量名。这里记录有两个作用
// 1.用旧的exportName去和新的exportName做对比, 如果相同,则无需修改index.js文件。
// 2.在后面的ExportDefaultDeclaration语句时,需要将旧的exportNames中的变
// 量全部删除掉(因为可能某些export语句再原文件中已经删除或者重命名了), 并且把新的exportName加到export default中去。
oldExportNames = path.node.specifiers.reduce((prev, cur) => {
if (t.isImportSpecifier(cur) || t.isImportDefaultSpecifier(cur)) {
return [...prev, cur.local.name];
}
return prev;
}, []);
importSetted = true
path.replaceWith(self.createImportDeclaration(specifiers)(relPath));
}
},
exit(path) {
// 当每个ImportDeclaration执行完enter之后, 如果没有进入enter内部逻辑,说
// 明当前node不是改动文件的import语句, 所以判断下一条node是不是import语句,
// 如果是,继续进入下一条import语句的enter,继续进行;
// 如果不是,则说明当前index.js文件中不存在变动文件的导入语句, 在其后面插入import语句
const pathKey = path.key;
const nextNode = path.getSibling(pathKey + 1);
if (!importSetted && !_.isEmpty(nameMap) && nextNode && !t.isImportDeclaration(nextNode)) {
...
path.insertAfter(self.createImportDeclaration(specifiers)(relPath));
}
}
},
ExportDefaultDeclaration(path) {
// 写入export default时会再次访问ExportDefaultDeclaration, 加exportSetted判断防止造成死循环
if (changed && !exportSetted && t.isObjectExpression(path.node.declaration)) {
...
path.replaceWith(self.createExportDefaultDeclaration(allProperties));
}
}
});
...
const output = generator(indexAst);
fs.writeFileSync(indexpath, output.code);
}
代码中做了优化的部分
- 对改动文件的export出的变量名缓存在
this.cacheExportNameMap = {};
,这样如果文件改动部分不是export相关的改动(比如新定义了一个函数或变量但是并没有export出去),就不会对index文件做转换处理。
四、总结
在写插件、打包、发布npm的过程中,遇到了很多平时写业务代码所不能遇见的问题,也进一步了解了webpack和node,包括发布npm。 也算是没有白浪费时间。
后续文章会把遇到的问题总结出来,敬请期待。
读到这里,如果对你有点帮助的话,烦请给个star, 多谢?, 欢迎提issue和PR。
好了先不说了,一大堆需求等着呢,我该去写了?。
参考文档