記開發一個webpack插件的心路歷程

做爲一名前端菜🐔,平常工做就是寫各類業務代碼, 看着大佬們寫的小工具、插件啥的,羨慕不已。 偶然想到要不也寫個插件試試?試試就試試,抱着試試看的態度,開始了。javascript

第一次寫,有不當之處還望各位大佬指正。前端

1、插件介紹。

auto-export-pluginjava

看圖node

良辰:在左邊test文件中寫export語句時, 會自動在右邊的index文件中導出。

沒錯!webpack

橘子:這樣免得本身再去手動引入到index中去, 能提高很多開發效率。git

沒錯!!github


主要應用場景web

相似上圖的文件目錄

插件安裝npm

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']
    })
  ]
}
複製代碼

2、原理解析

  1. 運用babel將被改動文件內容解析成ast, 經過ast找到export
  2. 對同級目錄index.js文件作ast轉換, 插入收集到的export
  3. 將index.js轉換過的ast從新生產代碼,寫入index.js文件

插件有更新, 更新部分請看這裏

3、源碼解讀

預備知識

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文件作轉換處理。

4、總結

在寫插件、打包、發佈npm的過程當中,遇到了不少平時寫業務代碼所不能碰見的問題,也進一步瞭解了webpack和node,包括髮布npm。 也算是沒有白浪費時間。

後續文章會把遇到的問題總結出來,敬請期待。

讀到這裏,若是對你有點幫助的話,煩請給個star, 多謝😄, 歡迎提issue和PR。

好了先不說了,一大堆需求等着呢,我該去寫了😄。


源碼部分有更改, 請看這裏

參考文檔

相關文章
相關標籤/搜索