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

栏目: JavaScript · 发布时间: 5年前

内容简介:作为一名前端菜:chicken:,日常工作就是写各种业务代码, 看着大佬们写的小工具、插件啥的,羡慕不已。 偶然想到要不也写个插件试试?试试就试试,抱着试试看的态度,开始了。第一次写,有不当之处还望各位大佬指正。

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

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

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

一、插件介绍。

auto-export-plugin

看图

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

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

没错!

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

没错!!

主要应用场景

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

类似上图的文件目录

插件安装

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']
    })
  ]
}
复制代码

二、原理解析

  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);
  }
复制代码

代码中做了优化的部分

  • 对文件的改动和删除事件,做了防抖处理, 防止频繁执行
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文件中
记开发一个webpack插件的心路历程
  • 扩展对commonjs的支持

四、总结

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

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

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

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

参考文档


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

反应式设计模式

反应式设计模式

Roland Kuhn、Brian Hanafee、Jamie Allen / 何品、邱嘉和、王石冲、林炜翔审校 / 清华大学出版社 / 2019-1-1 / 98.00 元

《反应式设计模式》介绍反应式应用程序设计的原则、模式和经典实践,讲述如何用断路器模式将运行缓慢的组件与其他组件隔开、如何用事务序列(Saga)模式实现多阶段事务以及如何通过分片模式来划分数据集,分析如何保持源代码的可读性以及系统的可测试性(即使在存在许多潜在交互和失败点的情况下)。 主要内容 ? “反应式宣言”指南 ? 流量控制、有界一致性、容错等模式 ? 得之不易的关于“什么行不通”的经验 ? ......一起来看看 《反应式设计模式》 这本书的介绍吧!

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

图片转BASE64编码
图片转BASE64编码

在线图片转Base64编码工具

html转js在线工具
html转js在线工具

html转js在线工具