從零開始寫Babel插件

t04041143 7年前發布 | 33K 次閱讀 前端開發 Babel 前端技術

在現代Web前端開發中,離不開JavaScript es6/7,而 ES6/7 中最常用的語法翻譯當屬 Babel 了。

這篇文章將帶讀者從零開始開發一個自定義的Babel插件。

Babel是什么

Babel 使用 babylon 解析 JavaScript 代碼,得到抽象語法樹(Abstract Syntax Tree,后文簡稱 AST)。

同時也可以使用 babel-generator ,輸入一個合法的 AST,還原成 JavaScript 代碼

代碼如下:

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)

AST

ast 在這是指將 JavaScript 代碼進行解析得到的抽象語法樹(數據結構)。

如代碼

const key = 'value'

解析產生的 AST 如下圖所示

建議使用 AST Explorer 在線預覽 AST

Plugin 和 Preset

我們在使用 Babel 的時候,通常需要配置一些預設(presets)和插件(plugins)。

{
  "presets": ["env"],
  "plugins": ["async-to-generator"]
}

其實,preset是一堆plugin的結合,那么plugin又是什么呢?

如下圖,plugin 會轉換 AST,對 AST 進行處理,從而也能夠影響到產生出來的 JS Code。

在后文中學習了開發 Babel 插件后,將闡述一下 Babel plugin 和 preset 的執行過程和順序。

開發 Babel 插件

了解了 Babel 插件的概念后,讓我們動手擼一個 Babel 插件吧!

情景再現

在使用構建工具 Webpack 開發大型項目的時候,我們可能通常需要 import 一大串依賴

import a from 'a'
import b from 'b'
import c from 'c'
// ...

// code here

但是在開發的邏輯中可能只需要用到其中的一丟丟依賴,比如 a ,那么依賴 b c 都是“無效”的依賴。

注意:無效只是相對而言的,因為在 'b', 'c' 依賴中可能會執行一些副作用的邏輯。如設置全局變量,環境變量,做些初始化工作…

在優化項目的時候,就需要考慮到去除掉無效的 import 語句了,這樣可以一定程度上加快程序執行速度,縮小打包出來的 bundle 大小。

開發插件!

不想偷懶的墨魚不是好程序員!對于上面的問題,可以通過開發 Babel 插件來實現,減少我們的人力工作量。

程序思路

1. 根據 import ... 語法,得到 imported 變量名集合

2. 過濾掉使用過的 imported 變量名

3. 移除沒有使用到的 import ... 語句

思路總是很簡單,但只有真正實現過的人才知道里面的具體種種。

Babel 插件返回一個 function ,入參為 babel 對象,返回 Object。

其中 pre , post 分別在進入/離開 AST 的時候觸發,所以一般分別用來做初始化/刪除對象的操作

module.exports = (babel) => {
  return {
    pre(path) {
      this.runtimeData = {}
    },
    visitor: {},
    post(path) {
      delete this.runtimeData
    }
  }
}

然后是 visitor 訪問者對象。

先看個簡單的例子:

如需要將如下代碼中的 x 變量重命名為 y

const x = 'x'
alert(x)

visitor 書寫為:

const visitor = {
  Identifier(path, data) {
    if (path.node.name === 'x') {
      path.node.name = 'y'
    }
  }
}

輸出為:

const y = 'x'
alert(y)

可以看出,visitor 是 Object 類型,其中的 key 對應 AST 中的各個節點的 type, path.node 是 AST 中的節點數據。

簡單了解 visitor 后,開始我們的開發吧!

得到 imported 變量名集合

我們需要關心 import 語句有:

import lodash from 'loadsh'
import { extend, cloneDeep as clone } from 'lodash'

而對于 import 'babel-polyfill' 語句,則不關心。

以 import { extend, cloneDeep as clone } from 'lodash' 為例,得到的 AST 為:

其中的數組 specifiers 為:

所以我們只需要得到 specifiers 中的 local.name 即可,單為了后續對該 AST 結點進行操作(刪除),所以也需要存儲結點信息,如下代碼:

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
}

以上代碼將返回

[
  { path: NodePath, name: 'extend' },
  { path: NodePath, name: 'clone' }
]

得到該條 import 語句的引入的變量數組后,還需要存儲一份 import 語句的 NodePath,為了后續操作(刪除)

{
  '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' }
  }
}

去除使用過的 imported 變量名

在去除使用過的 imported 變量名之前,需要明確一點:

在 ES6 標準中,import 中定義的變量名是不能被重新定義的,如下代碼是不被允許的。

import _ from 'lodash'
const _ = 'hello'

那么什么情況下 extend 是被使用的呢?

extend = 'extend'
[ extend ]
{ key: extend }
extend - 2
extend / 2
extend > 2
extend <= 2
extend['key']()
extend.key = 233
extend.key < 233
// ...

情況太多了 :disappointed_relieved:

既然正面列舉被使用的情況比較復雜,那何不逆向思維,考慮 extend 沒被使用的情況呢?

const extend = 'value'
{ extend: 'value' }
ref.extend
class A {
  extend() {}
  extend = 233
}

果然情況就好多了嘛 ??

于是,去除使用過的 imported 變量名也可以歡快地完成啦!

移除沒有使用到的 import ... 語句

  1. 遍歷最終得到的沒有使用到變量集合 A;
  2. 如果 item 中的 children 中每一個 name 都存在于 A 中,刪除 item.parent 結點,否則只刪除 item.data.path 結點;

打完收工!

完成了上面一系列的分析后,得到的最終插件代碼大概這個樣子:

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
  }
}

以上代碼咋看一下邏輯的確沒問題。

但是!搭配 preset-es2015 使用時,將會不能正確刪除未使用的變量名或者 import 語句。

報錯: NodePath has been removed so is read-only.

因為 es2015 中會將 import 語句進行替換,相當于存儲的 NodePath 已經被刪除了。

關于Babel中plugin和preset的執行順序,官方的解釋如下:

Plugins run before Presets.

Plugin ordering is first to last.

Preset ordering is reversed (last to first).

既然 Plugins run before Presets,那為什么還會有上訴的問題呢?

Babel的核心開發人員 @hzoo 做出下列解釋:

Plugins do go before presets, but it just adds the same visitors first before merging them.

意思是,Babel 在處理 plugins 的時候,會將 visitor 里面各個對應的單元統一合并,然后再按照插件的順序去執行。

所以在執行到 post() 方法時,其實es2015中的插件已經將 import 語句替換了 :disappointed_relieved:

那么該問題如何解決呢?

可以 AST 最外層的 Program 結點遍歷 path,邏輯同上。

最終代碼為:

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
    }
  }
}

 

參考資料

 

 

來自:http://eux.baidu.com/blog/2017/12/how-to-write-babel-plugin

 

 本文由用戶 t04041143 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!