记开发一个webpack插件的心路历程
栏目: JavaScript · 发布时间: 5年前
内容简介:作为一名前端菜:chicken:,日常工作就是写各种业务代码, 看着大佬们写的小工具、插件啥的,羡慕不已。 偶然想到要不也写个插件试试?试试就试试,抱着试试看的态度,开始了。第一次写,有不当之处还望各位大佬指正。
作为一名前端菜:chicken:,日常工作就是写各种业务代码, 看着大佬们写的小 工具 、插件啥的,羡慕不已。 偶然想到要不也写个插件试试?试试就试试,抱着试试看的态度,开始了。
第一次写,有不当之处还望各位大佬指正。
一、插件介绍。
看图
良辰:在左边test文件中写export语句时, 会自动在右边的index文件中导入并加入到export default语句中。
没错!
橘子:这样省得自己再去手动引入到index中去, 能提升不少开发效率。
没错!!
主要应用场景
类似上图的文件目录
插件安装
npm i auto-export-plugin -D 复制代码
插件功能
- 文件改动时, 搜索当前文件的export语句,在同级目录index文件中自动导出。
- 文件删除时, 自动从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); } 复制代码
代码中做了优化的部分
- 对文件的改动和删除事件,做了防抖处理, 防止频繁执行
this.watcher.on('change', _.debounce(this.handleChange.bind(this), 1000)) .on('unlink', _.debounce(this.handleDeleteFile.bind(this), 1000)); 复制代码
- 对改动文件的export出的变量名缓存在
this.cacheExportNameMap = {};
,这样如果文件改动部分不是export相关的改动(比如新定义了一个函数或变量但是并没有export出去),就不会对index文件做转换处理。
TODO List
- 扩展对类似下图多级目录的支持, 自动导出到外层index文件中
- 扩展对commonjs的支持
四、总结
在写插件、打包、发布npm的过程中,遇到了很多平时写业务代码所不能遇见的问题,也进一步了解了webpack和node,包括发布npm。 也算是没有白浪费时间。
后续文章会把遇到的问题总结出来,敬请期待。
读到这里,如果对你有点帮助的话,烦请给个 star , 多谢:smile:, 欢迎提issue和PR。
好了先不说了,一大堆需求等着呢,我该去写了:smile:。
参考文档
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 三方对接心路历程
- 心路历程:爬虫实战——从数据到产品
- 围观:基于事件机制的内部解耦之心路历程
- 一个前端自学者从面试被吊打,到拿 offer 的心路历程
- 学习新的计算机知识为什么这么难?看看我的心路历程
- 鹅厂7年终有离开之日,记离职鹅厂最后30天的真实心路历程
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Vue.js前端开发
陈陆扬 / 人民邮电出版社 / 2017-2-1 / CNY 45.00
本书分为10章,包括简介、基础特性、指令、过滤器、过渡、组件、状态管理、常用插件、工程实例和Weex打包。本书从简单的单个实例和基础语法,到工程实例,将系统地讲述Vue.js在项目中的适用场景和具体操作。本书的特点在于案例详实,使读者体会到框架的优点和便捷之处,提升开发效率,最后能将Vue.js运用到实际项目中,避免纸上谈兵的尴尬。一起来看看 《Vue.js前端开发》 这本书的介绍吧!