做为一名前端菜🐔,平常工做就是写各类业务代码, 看着大佬们写的小工具、插件啥的,羡慕不已。 偶然想到要不也写个插件试试?试试就试试,抱着试试看的态度,开始了。javascript
第一次写,有不当之处还望各位大佬指正。前端
看图node
没错!webpack
橘子:这样免得本身再去手动引入到index中去, 能提高很多开发效率。git
没错!!github
主要应用场景web
插件安装npm
npm i auto-export-plugin -D
复制代码
插件功能缓存
若是是非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']
})
]
}
复制代码
插件有更新, 更新部分请看这里
预备知识
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);
}
复制代码
代码中作了优化的部分
this.cacheExportNameMap = {};
,这样若是文件改动部分不是export相关的改动(好比新定义了一个函数或变量可是并无export出去),就不会对index文件作转换处理。在写插件、打包、发布npm的过程当中,遇到了不少平时写业务代码所不能碰见的问题,也进一步了解了webpack和node,包括发布npm。 也算是没有白浪费时间。
后续文章会把遇到的问题总结出来,敬请期待。
读到这里,若是对你有点帮助的话,烦请给个star, 多谢😄, 欢迎提issue和PR。
好了先不说了,一大堆需求等着呢,我该去写了😄。
源码部分有更改, 请看这里
参考文档