从零开始写Babel插件
t04041143
7年前
<p>在现代Web前端开发中,离不开JavaScript es6/7,而 ES6/7 中最常用的语法翻译当属 Babel 了。</p> <p>这篇文章将带读者从零开始开发一个自定义的Babel插件。</p> <h2>Babel是什么</h2> <p>Babel 使用 babylon 解析 JavaScript 代码,得到抽象语法树(Abstract Syntax Tree,后文简称 AST)。</p> <p>同时也可以使用 babel-generator ,输入一个合法的 AST,还原成 JavaScript 代码</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/f6a08776351f9cd5a36917aad0fef00c.png"></p> <p>代码如下:</p> <pre> cosnt babel = require('babel-core') const code = ` import e from './where' const [ a, b, c ] = [ 1, 2, 3 ] ` const { ast } = babel.transform(code, { ast: true }) const generate = require('babel-generator') const { code: codeFromBabel } = generate(ast)</pre> <h3>AST</h3> <p>ast 在这是指将 JavaScript 代码进行解析得到的抽象语法树(数据结构)。</p> <p>如代码</p> <pre> const key = 'value'</pre> <p>解析产生的 AST 如下图所示</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/39b47c55c6129c44582badccd9b50e6d.png"></p> <p>建议使用 <a href="/misc/goto?guid=4959755711055774030" rel="nofollow,noindex">AST Explorer</a> 在线预览 AST</p> <h2>Plugin 和 Preset</h2> <p>我们在使用 Babel 的时候,通常需要配置一些预设(presets)和插件(plugins)。</p> <p>如</p> <pre> { "presets": ["env"], "plugins": ["async-to-generator"] }</pre> <p>其实,preset是一堆plugin的结合,那么plugin又是什么呢?</p> <p>如下图,plugin 会转换 AST,对 AST 进行处理,从而也能够影响到产生出来的 JS Code。</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/71edadc36a1bed13094ada3323fe9514.png"></p> <p>在后文中学习了开发 Babel 插件后,将阐述一下 Babel plugin 和 preset 的执行过程和顺序。</p> <h2>开发 Babel 插件</h2> <p>了解了 Babel 插件的概念后,让我们动手撸一个 Babel 插件吧!</p> <h3>情景再现</h3> <p>在使用构建工具 Webpack 开发大型项目的时候,我们可能通常需要 import 一大串依赖</p> <pre> import a from 'a' import b from 'b' import c from 'c' // ... // code here</pre> <p>但是在开发的逻辑中可能只需要用到其中的一丢丢依赖,比如 a ,那么依赖 b c 都是“无效”的依赖。</p> <p>注意:无效只是相对而言的,因为在 'b', 'c' 依赖中可能会执行一些副作用的逻辑。如设置全局变量,环境变量,做些初始化工作…</p> <p>在优化项目的时候,就需要考虑到去除掉无效的 import 语句了,这样可以一定程度上加快程序执行速度,缩小打包出来的 bundle 大小。</p> <h3>开发插件!</h3> <p>不想偷懒的墨鱼不是好程序员!对于上面的问题,可以通过开发 Babel 插件来实现,减少我们的人力工作量。</p> <p>程序思路</p> <p>1. 根据 import ... 语法,得到 imported 变量名集合</p> <p>2. 过滤掉使用过的 imported 变量名</p> <p>3. 移除没有使用到的 import ... 语句</p> <p>思路总是很简单,但只有真正实现过的人才知道里面的具体种种。</p> <p>Babel 插件返回一个 function ,入参为 babel 对象,返回 Object。</p> <p>其中 pre , post 分别在进入/离开 AST 的时候触发,所以一般分别用来做初始化/删除对象的操作</p> <pre> module.exports = (babel) => { return { pre(path) { this.runtimeData = {} }, visitor: {}, post(path) { delete this.runtimeData } } }</pre> <p>然后是 visitor 访问者对象。</p> <p>先看个简单的例子:</p> <p>如需要将如下代码中的 x 变量重命名为 y</p> <pre> const x = 'x' alert(x)</pre> <p>visitor 书写为:</p> <pre> const visitor = { Identifier(path, data) { if (path.node.name === 'x') { path.node.name = 'y' } } }</pre> <p>输出为:</p> <pre> const y = 'x' alert(y)</pre> <p>可以看出,visitor 是 Object 类型,其中的 key 对应 AST 中的各个节点的 type, path.node 是 AST 中的节点数据。</p> <p>简单了解 visitor 后,开始我们的开发吧!</p> <p>得到 imported 变量名集合</p> <p>我们需要关心 import 语句有:</p> <pre> import lodash from 'loadsh' import { extend, cloneDeep as clone } from 'lodash'</pre> <p>而对于 import 'babel-polyfill' 语句,则不关心。</p> <p>以 import { extend, cloneDeep as clone } from 'lodash' 为例,得到的 AST 为:</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/269ebb971b6d757b1067d088932239be.png"></p> <p>其中的数组 specifiers 为:</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/8302fc1530ca2b6b298260a76fd57cbe.png"></p> <p>所以我们只需要得到 specifiers 中的 local.name 即可,单为了后续对该 AST 结点进行操作(删除),所以也需要存储结点信息,如下代码:</p> <pre> function getSpecifierIdentifiers(specifiers = [], withPath = false) { const collection = [] function loop(path, index) { const node = path.node const item = { path, name: node.local.name } switch (node.type) { case 'ImportDefaultSpecifier': case 'ImportSpecifier': collection.push(item) break; } } specifiers.forEach(loop) return collection }</pre> <p>以上代码将返回</p> <pre> [ { path: NodePath, name: 'extend' }, { path: NodePath, name: 'clone' } ]</pre> <p>得到该条 import 语句的引入的变量数组后,还需要存储一份 import 语句的 NodePath,为了后续操作(删除)</p> <pre> { 'extend': { parent: path, // `import` 语句的 NodePath children: [ { path: NodePath, name: 'extend' }, { path: NodePath, name: 'clone' } ], data: { path: NodePath, name: 'extend' } }, 'clone': { parent: path, children: [ { path: NodePath, name: 'extend' }, { path: NodePath, name: 'clone' } ], data: { path: NodePath, name: 'clone' } } }</pre> <p>去除使用过的 imported 变量名</p> <p>在去除使用过的 imported 变量名之前,需要明确一点:</p> <p>在 ES6 标准中,import 中定义的变量名是不能被重新定义的,如下代码是不被允许的。</p> <pre> import _ from 'lodash' const _ = 'hello'</pre> <p>那么什么情况下 extend 是被使用的呢?</p> <pre> extend = 'extend' [ extend ] { key: extend } extend - 2 extend / 2 extend > 2 extend <= 2 extend['key']() extend.key = 233 extend.key < 233 // ...</pre> <p>情况太多了 :disappointed_relieved:</p> <p>既然正面列举被使用的情况比较复杂,那何不逆向思维,考虑 extend 没被使用的情况呢?</p> <pre> const extend = 'value' { extend: 'value' } ref.extend class A { extend() {} extend = 233 }</pre> <p>果然情况就好多了嘛 ��</p> <p>于是,去除使用过的 imported 变量名也可以欢快地完成啦!</p> <p>移除没有使用到的 import ... 语句</p> <ol> <li>遍历最终得到的没有使用到变量集合 A;</li> <li>如果 item 中的 children 中每一个 name 都存在于 A 中,删除 item.parent 结点,否则只删除 item.data.path 结点;</li> </ol> <h3>打完收工!</h3> <p>完成了上面一系列的分析后,得到的最终插件代码大概这个样子:</p> <pre> module.exports = { pre() { this.runtimeData = {} } visitor: { ImportDeclaration(path, data) { const locals = getSpecImport(path); if (locals) { locals.forEach((pathData, index, all) => { const {name} = pathData this.runtimeData[name] = { parent: path, children: all, data: pathData } }) // 跳过当前path的子节点的向下遍历 // 为了防止遍历 import 语句中的 Identifier path.skip() } }, Identifier() { // 书写步骤2逻辑,删除使用过的Identifier }, JSXIdentifier() { // 书写步骤2逻辑,删除使用过的Identifier } }, post() { // 书写步骤3逻辑 delete this.runtimeData } }</pre> <p>以上代码咋看一下逻辑的确没问题。</p> <p>但是!搭配 preset-es2015 使用时,将会不能正确删除未使用的变量名或者 import 语句。</p> <p>报错: NodePath has been removed so is read-only.</p> <p>因为 es2015 中会将 import 语句进行替换,相当于存储的 NodePath 已经被删除了。</p> <p>关于Babel中plugin和preset的执行顺序,官方的解释如下:</p> <p>Plugins run before Presets.</p> <p>Plugin ordering is first to last.</p> <p>Preset ordering is reversed (last to first).</p> <p>既然 Plugins run before Presets,那为什么还会有上诉的问题呢?</p> <p>Babel的核心开发人员 @hzoo 做出下列解释:</p> <p>Plugins do go before presets, but it just adds the same visitors first before merging them.</p> <p>意思是,Babel 在处理 plugins 的时候,会将 visitor 里面各个对应的单元统一合并,然后再按照插件的顺序去执行。</p> <p style="text-align: center;"><img src="https://simg.open-open.com/show/f2c1863f49360eba38ea3f0fbc5d0d15.png"></p> <p>所以在执行到 post() 方法时,其实es2015中的插件已经将 import 语句替换了 :disappointed_relieved:</p> <p>那么该问题如何解决呢?</p> <p>可以 AST 最外层的 Program 结点遍历 path,逻辑同上。</p> <p>最终代码为:</p> <pre> const traverseObject = { ImportDeclaration(path, data) { // ... }, Identifier() { // ... }, JSXIdentifier() { // ... } } module.exports = function (babel) { return { pre(path) { this.runtimeData = {} }, visitor: { Program(path, data) { // 在最外层的 Program 遍历 path path.traverse(traverseObject, { runtimeData: this.runtimeData }) handleRemovePath(this.runtimeData) } }, post() { delete this.runtimeData } } }</pre> <p> </p> <h2>参考资料</h2> <ul> <li><a href="/misc/goto?guid=4959755711174220276" rel="nofollow,noindex">Discussion: Fix Plugin Ordering #5623</a></li> <li><a href="/misc/goto?guid=4959674369255397296" rel="nofollow,noindex">babel handbook</a></li> </ul> <p> </p> <p> </p> <p>来自:http://eux.baidu.com/blog/2017/12/how-to-write-babel-plugin</p> <p> </p>