做爲一名前端菜🐔,平常工做就是寫各類業務代碼, 看着大佬們寫的小工具、插件啥的,羨慕不已。 偶然想到要不也寫個插件試試?試試就試試,抱着試試看的態度,開始了。javascript
第一次寫,有不當之處還望各位大佬指正。前端
看圖node
良辰:在左邊test文件中寫export語句時, 會自動在右邊的index文件中導出。沒錯!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。
好了先不說了,一大堆需求等着呢,我該去寫了😄。
源碼部分有更改, 請看這裏
參考文檔