记开发一个webpack插件的心路历程

3,160 阅读3分钟

作为一名前端菜🐔,日常工作就是写各种业务代码, 看着大佬们写的小工具、插件啥的,羡慕不已。 偶然想到要不也写个插件试试?试试就试试,抱着试试看的态度,开始了。

第一次写,有不当之处还望各位大佬指正。

一、插件介绍。

auto-export-plugin

看图

良辰:在左边test文件中写export语句时, 会自动在右边的index文件中导出。

没错!

橘子:这样省得自己再去手动引入到index中去, 能提升不少开发效率。

没错!!


主要应用场景

类似上图的文件目录

插件安装


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']
    })
  ]
}

二、原理解析

  1. 运用babel将被改动文件内容解析成ast, 通过ast找到export
  2. 对同级目录index.js文件做ast转换, 插入收集到的export
  3. 将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);
  }

代码中做了优化的部分

  • 对改动文件的export出的变量名缓存在this.cacheExportNameMap = {};,这样如果文件改动部分不是export相关的改动(比如新定义了一个函数或变量但是并没有export出去),就不会对index文件做转换处理。

四、总结

在写插件、打包、发布npm的过程中,遇到了很多平时写业务代码所不能遇见的问题,也进一步了解了webpack和node,包括发布npm。 也算是没有白浪费时间。

后续文章会把遇到的问题总结出来,敬请期待。

读到这里,如果对你有点帮助的话,烦请给个star, 多谢😄, 欢迎提issue和PR。

好了先不说了,一大堆需求等着呢,我该去写了😄。


源码部分有更改, 请看这里

参考文档