入門babel--實現一個es6的class轉換器

寒東設計師發表於2018-04-03

      babel是一個轉碼器,目前開發react、vue專案都要使用到它。它可以把es6+的語法轉換為es5,也可以轉換JSX等語法等,實際上他能通過自定義外掛的方式完成任意轉換。
      我們在專案中都是通過配置外掛和預設(多個外掛的集合)來轉換特定程式碼,例如env、stage-0等。那麼這些庫是如何實現的呢,下面就通過一個小例子探究一下--把es6的class轉換為es5。

文章結構:

webpack環境配置

      大家應該都配置過babel-core這個loader,實際上它的作用只是提供babel的核心Api,我們的程式碼轉換其實都是通過外掛來實現的。
      接下來我們不用第三方的外掛,自己實現一個es6類轉換外掛。先執行以下幾步初始化一個專案:

  • npm install webpack webpack-cli babel-core -D
  • 新建一個webpack.config.js
  • 配置webpack.config.js

      如果我們的外掛名字想叫transform-class,需要在webpack配置中做如下配置:

入門babel--實現一個es6的class轉換器

      接下來我們在node_modules中新建一個babel-plugin-transform-class的資料夾來寫外掛的邏輯(如果是真實專案,你需要編寫這個外掛併發布到npm倉庫),如下圖:

入門babel--實現一個es6的class轉換器

      紅色區域是我新建的資料夾,它上面是一個標準的外掛的專案結構,為了方便我的外掛只寫了核心的index.js檔案。

如何編寫bable外掛

      babel外掛其實是通過AST(抽象語法樹)實現的。
      babel幫助我們把js程式碼轉換為AST,然後允許我們修改,最後再把它轉換成js程式碼。
      那麼就涉及到兩個問題:js程式碼和AST之間的對映關係是什麼?如何替換或者新增AST?

好,先介紹一個工具:astexplorer.net:

      這個工具可以把一段程式碼轉換為AST:

入門babel--實現一個es6的class轉換器
      如圖,我們寫了一個es6的類,然後網頁的右邊幫我們生成了一個AST,其實就是把每一行程式碼變成了一個物件,這樣我們就實現了一個對映。

再介紹一個文件:babel-types:

      這是建立AST節點的Api文件。
      比如,我們想建立一個類,先到astexplorer.net中轉換,發現類對應的AST型別是ClassDeclaration。好,我們去文件中搜尋,發現呼叫下面的api就可以建立這樣一個節點:

入門babel--實現一個es6的class轉換器
      同理,建立其他節點也是一樣的道理。有了上面這兩個東西,我們就可以做任何轉換了。

      下面我們開始真正編寫一個外掛,分為以下幾步:

  • 在index.js中export一個函式
  • 函式中返回一個物件,物件有一個visitor引數(必須叫visitor)
  • 通過astexplorer.net查詢出class對應的AST節點為ClassDeclaration
  • 在vistor中設定一個捕獲函式ClassDeclaration,意思是我要捕獲js程式碼中所有ClassDeclaration節點
  • 編寫邏輯程式碼,完成轉換

      上面的步驟對應成程式碼:

module.exports = function ({ types: t }) {
    return {
        visitor: {
            ClassDeclaration(path) {
                //在這裡完成轉換
            }
        }
    };
}
複製程式碼

      程式碼中有兩個引數,第一個{types:t}東西是從引數中解構出變數t,它其實就是babel-types文件中的t(下圖紅框),我們就是用這個t建立節點:

入門babel--實現一個es6的class轉換器

      第二個引數path,它是捕獲到的節點對應的資訊,我們可以通過path.node獲得這個節點的AST,在這個基礎上進行修改就能完成了我們的目標。

如何把es6的class轉換為es5的類

上面都是預備工作,真正的邏輯從現在才開始,我們先考慮兩個問題:
  1. 我們要做如下轉換,首先把es6的類,轉換為es5的類寫法(也就是普通函式),我們觀察到,很多程式碼是可以複用的,包括函式名字、函式內部的程式碼塊等。

入門babel--實現一個es6的class轉換器

  1. 如果不定義class中的constructor方法,JavaScript引擎會自動為它新增一個空的constructor()方法,這需要我們做相容處理。
接下來我們開始寫程式碼,思路是:
  • 拿到老的AST節點
  • 建立一個陣列用來盛放新的AST節點(雖然原class只是一個節點,但是替換後它會被若干個函式節點取代)
  • 初始化預設的constructor節點(上文提到,class中有可能沒有定義constructor)
  • 迴圈老節點的AST物件(會迴圈出若干個函式節點)
  • 判斷節點的型別是不是constructor,如果是,通過老資料建立一個普通函式節點,並更新預設constructor節點
  • 處理其餘不是constructor的節點,通過老資料建立prototype型別的函式,並放到es5Fns
  • 迴圈結束,把constructor節點也放到es5Fns
  • 判斷es5Fns的長度是否大於1,如果大於1使用replaceWithMultiple這個API更新AST
module.exports = function ({ types: t }) {
    return {
        visitor: {
            ClassDeclaration(path) {
                //拿到老的AST節點
                let node = path.node
                let className = node.id.name
                let classInner = node.body.body
                //建立一個陣列用來成盛放新生成AST
                let es5Fns = []
                //初始化預設的constructor節點
                let newConstructorId = t.identifier(className)
                let constructorFn = t.functionDeclaration(newConstructorId, [t.identifier('')], t.blockStatement([]), false, false)
                //迴圈老節點的AST物件
                for (let i = 0; i < classInner.length; i++) {
                    let item = classInner[i]
                    //判斷函式的型別是不是constructor
                    if (item.kind == 'constructor') {
                        let constructorParams = item.params.length ? item.params[0].name : []
                        let newConstructorParams = t.identifier(constructorParams)
                        let constructorBody = classInner[i].body
                        constructorFn = t.functionDeclaration(newConstructorId, [newConstructorParams], constructorBody, false, false)
                    } 
                    //處理其餘不是constructor的節點
                    else {
                        let protoTypeObj = t.memberExpression(t.identifier(className), t.identifier('prototype'), false)
                        let left = t.memberExpression(protoTypeObj, t.identifier(item.key.name), false)
                        //定義等號右邊
                        let prototypeParams = classInner[i].params.length ? classInner[i].params[i].name : []
                        let newPrototypeParams = t.identifier(prototypeParams)
                        let prototypeBody = classInner[i].body
                        let right = t.functionExpression(null, [newPrototypeParams], prototypeBody, false, false)
                        let protoTypeExpression = t.assignmentExpression("=", left, right)
                        es5Fns.push(protoTypeExpression)
                    }

                }
                //迴圈結束,把constructor節點也放到es5Fns中
                es5Fns.push(constructorFn)
                //判斷es5Fns的長度是否大於1
                if (es5Fns.length > 1) {
                    path.replaceWithMultiple(es5Fns)
                } else {
                    path.replaceWith(constructorFn)
                }
            }
        }
    };
}

複製程式碼

優化繼承

      其實,類還涉及到繼承,思路也不復雜,就是判斷AST中有沒有superClass屬性,如果有的話,我們需要多新增一行程式碼Bird.prototype = Object.create(Parent),當然別忘了處理super關鍵字。

打包後程式碼

入門babel--實現一個es6的class轉換器
      執行npm start打包後,我們看到打包後的檔案裡class語法已經成功轉換為一個個的es5函式。

結尾

      現在一個類轉換器就寫完了,希望能對大家瞭解babel有一點幫助。

參考內容

github-babel外掛開發指南
babel-types

相關文章