做爲一名前端菜🐔,平常工做就是寫各類業務代碼, 看着大佬們寫的小工具、插件啥的,羨慕不已。 偶然想到要不也寫個插件試試?試試就試試,抱着試試看的態度,開始了。javascript
第一次寫,有不當之處還望各位大佬指正。
看圖java
良辰:在左邊test文件中寫export語句時, 會自動在右邊的index文件中導入並加入到export default語句中。node
沒錯!webpack
橘子:這樣免得本身再去手動引入到index中去, 能提高很多開發效率。git
沒錯!!github
主要應用場景web
相似上圖的文件目錄npm
插件安裝緩存
npm i auto-export-plugin -D
插件功能
用法
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](https://github.com/layne0625/...
)
如下代碼省去了部分片斷, 能夠對照完整源碼進行解讀。
獲取改動文件的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));
this.cacheExportNameMap = {};
,這樣若是文件改動部分不是export相關的改動(好比新定義了一個函數或變量可是並無export出去),就不會對index文件作轉換處理。TODO List
在寫插件、打包、發佈npm的過程當中,遇到了不少平時寫業務代碼所不能碰見的問題,也進一步瞭解了webpack和node,包括髮布npm。 也算是沒有白浪費時間。
讀到這裏,若是對你有點幫助的話,煩請給個[star](https://github.com/layne0625/...
), 多謝😄, 歡迎提issue和PR。
好了先不說了,一大堆需求等着呢,我該去寫了😄。
參考文檔