手寫一個 babel 插件

在前文《babel是怎樣工做的》中介紹了 Bable 中的的AST,此次我們給 bable 寫一個插件,文中會覆蓋大部份的用法,若是你對某些細節不是很明白,能夠去看一下官方的 Babel 手冊,中文版在這裏:javascript

https://github.com/jamiebuild...前端

不過有的部分尚未翻譯完。java

訪問節點

首先要找到要修改的節點,假設咱們要幫一個特定的函數 myFunction 加上調試用的信息,在這裏只加上文件名就好了,而這個 myFunction 長成這樣:node

function myFunction(data, optionalFilename)

使用者能夠本身在 optionalFilename 加上想要的名字,或者用插件加上去,第一步就是打開 AST Explorer(https://astexplorer.net/)並寫一個簡單的測試程序來肯定 AST 應該是什麼樣的:git

myFunction(foo, __filename)

結果是這樣:程序員

image.png

因爲咱們要可以判斷使用者傳入的幾個參數,也要能肯定使用者是在調用咱們的函數,因此應該在 CallExpression 中進行處理:es6

// babel 的 plugin 能夠用 module.exports 或 es6 的 export default 
// 函數的第一個參數是使用者正在使用的 `@babel/core`
module.exports = function ({ types: t }) {
  return {
    name: 'add-debug-information', // plugin 的名字,個加不加都行
    // pre(state) {}, // 要處理一個新的檔案時會調用這個函數
    // post(state) {}, // 文件處理完成時要調用的函數
    visitor: {
      CallExpression(path) {
        console.log(path) // 這樣就能夠獲得 CallExpression
      },
      // babel 能夠在進入或是離開節點時調用 plugin 的函數,不過由於一般會須要在進入節點時處理,
      // 因此 babel 讓使用者能夠簡寫成上面那樣,若是要在進入和離開時存取節點的話要寫成像下面這樣
      // CallExpression: {
      //   enter() { // 進入時
      //   },
      //   leave() { // 離開時
      //   },
      // }
    }
  }
}

判斷是否爲目標節點

下一步就是要判斷是否是咱們要作處理的節點了,這裏先只簡單的判斷兩個條件,函數名是 myFunction 而且只能用一個參數:github

// 這裏只寫 CallExpression 的內容
if (
  t.isIdentifier(path.node.callee, { name: 'myFunction' }) && // 判斷函數名是 `myFunction` ,這裏的 t 就是 babel 傳來的 types
  // 另外也能夠直接判斷 node 的 name,好比:t.isIdentifier(path.node.callee) && path.node.callee.name === 'myFunction'
  path.node.arguments.length < 2) { // 肯定沒有傳入第二個參數
  // 處理目標節點
}

若是要判斷的目標比較複雜,目前也沒有比較好的方法,只能這樣比較。另外由於 babel 中只能拿到到 AST 信息,若是要判斷類型等幾乎是沒有什麼辦法的,因此實際在寫插件時必須考慮全部合理的寫法,若是真的沒辦法處理時必定要要告訴使用者必須按照某種格式寫,不然不會被處理面試

修改節點

在已經找到目標目標的前提下,要把文件名加入到參數中。這裏直接加入 node 中的 `__filename 變量,這個變量在 node 的模塊中是那個原始碼文件的文件名。segmentfault

// 在上面的 if 中
path.pushContainer('arguments', t.identifier({ name: '__filename' }))
// 若是要加載開頭,能夠用 unshiftContainer

那麼爲何要用 pushContainer 修改 AST 的內容呢?直接用 push 加到 arguments 中不行嗎?這裏最大的差異在於 plugin 新增了節點,若是有上游的添加、刪除等改變,babel 也必需要便利新的節點,因此要用 babel 的 API 讓它知道有節點被改變了。

完整的代碼以下:

module.exports = function ({ types: t }) {
  
  return {
    name: 'add-debug-information',
    visitor: {
      CallExpression(path) {
        if (t.isIdentifier(path.node.callee, { name: 'myFunction' }) && path.node.arguments.length > 1) {
          path.pushContainer('arguments', t.identifier('__filename'))
        }
      }
    }
  }
}

接下來再來看看其餘例子。

移除節點

假如要在正式環境把除錯信息移除的話,就把 myFunction 第二個之後的參數都移除掉:

module.exports = function ({ types: t }) {
  return {
    visitor: {
      CallExpression(path) {
        if (t.isIdentifier(path.node.callee, {name: 'myFunction'})) {
          while (path.node.arguments.length > 1) { // 只要參數數量超過 1 個
            path.get(`arguments.1`).remove() //就把第二個參數移除,而下一個會補上來,因此下一次循環會再移除掉下一個
          }
        }
      }
    }
  };
}

pathget 能夠用於獲取指定位置的 Path 對象,可用於處理特定的子節點。

替換節點和 template

此次需求變成了在代碼中加上對 NODE_ENV 的判斷,若是是生產環境就不要除錯信息,結果像這樣:

// 原來
myFunction(data)

// 變爲
process.env.NODE_ENV === 'production' ? myFunction(data) : myFunction(data, __filename)
一般上面的代碼在正式環境中不會真的多出一個判斷,由於通常 bundler 會把 NODE_ENV 換成字串常量,而後再由 minifier 移除掉不須要的部分。

由於要產出的代碼變多了,此次就用 template

module.exports = function ({ types: t, template }) {
  // 這裏用到的 `%%data%%` 表明稍後咱們能夠放節點去取代那個位置,只須要用兩個 `%` 包起來便可,
  // 這個是 babel 7.4 之後才支持的語法,若是想支持之前的版本,就要把它改爲 `DATA` (必定要全大寫)
  // template 的返回值是一個函數
  const tpl = template(`process.env.NODE_ENV === 'production'
  ? myFunction(%%data%%)
  : myFunction(%%data%%, %%source%%);`)
  
  // 用來標記已經遍歷過的節點,用 Symbol 能夠防止產生命名上的衝突
  const visited = Symbol()
  
  return {
    visitor: {
      CallExpression(path) {
        // 檢查節點是否遍歷過
        if (path.node[visited]) {
          return
        }

        if (t.isIdentifier(path.node.callee, {name: 'myFunction'})) {
          // 替換節點
          path.replaceWith(
            // tpl 是一個函數,只須要把 placeholder 的部份傳進去,就會返回 AST
            tpl({
              // 這裏要避免使用者沒有傳入第一個參數的狀況,否則後面的參數會變成第一個參數
              // 也能夠拋出 error 或者讓 myFunction 在運行時進行判斷
              data: path.node.arguments[0] || t.identifier('undefined'),
              // 若是使用者本身提供了除錯信息,那麼就用使用者提供的,否則就用 __filename
              source: path.node.arguments[1] || t.identifier('__filename')
            })
          )
          // 把節點下的 `myFunction` 都標記爲遍歷過
          path.node.consequent[visited] = true
          path.node.alternate[visited] = true
        }
      }
    }
  }
}

前面說過,要是新加入節點的話,babel 也會去遍歷它,而咱們加入的節點中就包含了要處理的目標節點,若是不進行特殊處理的話就會一直無限的遍歷下去,因此要給添加的節點加上本身的標記,這樣就能夠避免重複處理。

拋出 error

在上一個例子中,爲了要避免使用者少傳參數而給了默認值,那若是要在少傳參數時拋出錯誤又要怎麼作呢。

module.exports = function ({ types: t, template }) {
  // 和上一個例子差很少
  const tpl = template(`process.env.NODE_ENV === 'production'
  ? myFunction(%%data%%)
  : myFunction(%%data%%, %%source%%);`)
  
  const visited = Symbol()
  
  // 建立一個函數來幫助拋出 error
  function throwMissingArgument(path) {
    // 這裏用 path 上的 buildCodeFrameError ,這樣顯示的時候就可以標記有問題的代碼在什麼地方
    throw path.buildCodeFrameError('`myFunction` required at least 1 argument')
  }
  
  return {
    visitor: {
      CallExpression(path) {
        if (path.node[visited]) {
          return
        }

        if (t.isIdentifier(path.node.callee, {name: 'myFunction'})) {
          path.replaceWith(
            tpl({
              // 這裏改用 throwMissingArgument
              data: path.node.arguments[0] || throwMissingArgument(path),
              source: path.node.arguments[1] || t.identifier('__filename')
            })
          )
          path.node.consequent[visited] = true
          path.node.alternate[visited] = true
        }
      }
    }
  }
}

若是沒傳參數的話應該會看到 babel 輸出了這樣的 error

code.js: `myFunction` expect at least 1 argument
> 1 |   myFunction()
    |   ^^^^^^^^^^^^

到此爲止,咱們終於寫出了本身的第一個 babel 插件。

173382ede7319973.gif


本文首發微信公衆號:前端先鋒

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章


歡迎繼續閱讀本專欄其它高贊文章:


相關文章
相關標籤/搜索