webpack
,nodejs
,babel
之類的工具的人來說,看到大量的配置檔案後很多人都會看懵
很多人就乾脆不管這些東西,直接上手寫業務程式碼,把這些構建工具就相當於黑科技
,我們把所有的檔案都經過這些工具最終生成一個或者幾個打包後的檔案,其中關於優化和程式碼轉換問題其實一大部分都是在這些配置裡面的。如果我們不去了解其中的一部分原理,後面遇到很多問題(如打包後檔案體積過大
)時候都是束手無策,而且萬一哪天構建工具出現問題時候可能連工作都開展不下去了。
既然我們日常都要用到,最好的方式就是去研究一下這些工具的原理的作用,讓這些工具成為我們手中的利器,而不是工作上的絆腳石,而且這些工具的設計者都是頂級的工程師,當你敲開壁壘探究內部祕密時候,我相信你會感受到其中的程式設計之美。
這裡我們去探索一下babel
的原理
babel 是什麼?
Babel · The compiler for writing next generation JavaScript
6to5
你在npm
上可以看到這樣一個包名字是6to5, 光看名字可能會讓人感覺到很詫異,名字看起來可能有點奇怪,其實babel
在開始的時候名字就是這個。簡單粗暴es6 -> es5
,一下子就看懂了babel
是用來幹啥的,但是很明顯這不是一個好名字,這個名字會讓人感覺到es6
普及之後這個庫就沒用了,為了保持活力這個庫可能要不停的修改名字。下面是babel
作者一次分享中假設如果按這個命名法則可能出現的名稱
很明顯發生這種情況是很不合理的,團隊內部經過大量討論後,最終選擇了babel
,這與電影銀河系漫遊指南中的Babel fish相應,也有關係到聖經中的一個故事Tower of Babel。(ps.優秀的人總是也很有情懷。)
babel is the new jQuery
redux
的作者曾說過這樣一句話,可以換一種理解為
1 |
babel : AST :: jQuery : DOM |
babel
對於 AST
就相當於 jQuery
對於 DOM
, 就是說babel
給予了我們便捷查詢和修改 AST
的能力。(AST -> Abstract Syntax Tree) 抽象語法樹 後面會講到。
什麼要用babel轉換程式碼
我們之前做一些相容都會都會接觸一些 Polyfill
的概念,比如如果某個版本的瀏覽器不支援 Array.prototype.find
方法,但是我們的程式碼中有用到Array
的find
函式,為了支援這些程式碼,我們會人為的加一些相容程式碼
1 2 3 4 5 6 |
if (!Array.prototype.find) { Object.defineProperty(Array.prototype, 'find', { // 實現程式碼 ... }); } |
對於這種情況做相容也很好實現,引入一個 Polyfill
檔案就可以了,但是有一些情況我們使用到了一些新語法,或者一些其他寫法
1 2 3 4 |
// 箭頭函式 var a = () => {} // jsx var Component = () => <div /> |
這種情況靠 Polyfill
, 因為一些瀏覽器根本就不識別這些程式碼,這時候就需要把這些程式碼轉換成瀏覽器識別的程式碼。babel
就是做這個事情的。
babel做了哪些事情
為了轉換我們的程式碼,babel
做了三件事
Parser
解析我們的程式碼轉換為AST
。Transformer
利用我們配置好的plugins/presets
把Parser
生成的AST
轉變為新的AST
。Generator
把轉換後的AST
生成新的程式碼從圖上看Transformer
佔了很大一塊比重,這個轉換過程就是babel
中最複雜的部分,我們平時配置的plugins/presets
就是在這個模組起作用。
從簡單的說起
可以看到要想搞懂babel
, 就是去了解上面三個步驟都是在幹什麼,我們先把比較容易看懂的地方開始瞭解一下。
Parser 解析
解析步驟接收程式碼並輸出 AST
,這其中又包含兩個階段詞法分析和語法分析。詞法分析階段把字串形式的程式碼轉換為 令牌(tokens)
流。語法分析階段會把一個令牌流轉換成 AST
的形式,方便後續操作。
Generator 生成
程式碼生成步驟把最終(經過一系列轉換之後)的 AST 轉換成字串形式的程式碼,同時還會建立原始碼對映(source maps)。程式碼生成其實很簡單:深度優先遍歷整個 AST,然後構建可以表示轉換後程式碼的字串。
babel的核心內容
看起來babel
的主要工作都集中在把解析生成的AST
經過plugins/presets
然後去生成新的AST
這上面了。
AST抽象語法樹
我們一直在提到AST
它究竟是什麼呢,既然它的名字叫做抽象語法樹
,我們可以想象一下如果把我們的程式用樹狀表示會是什麼樣呢。
1 2 |
var a = 1 + 1 var b = 2 + 2 |
我們想象一下要表示上述程式碼應該是什麼樣子,首先必須有東西可以表示這些具體的宣告
,變數
,常量
的具體資訊,比如(這棵樹上肯定有二個變數,變數名是a和b,肯定有兩個運算語句,操作符是 + )
,有了這些資訊還不夠,我們必須建立起它們之間的關係,比如一個宣告語句,宣告型別是 var, 左側是變數, 右側是表示式
。有了這些資訊我們就可以還原這個程式,這也是把程式碼解析成AST
時候所做的事情,對應上面我們說的詞法分析
和 語法分析
。
在AST
中我們用node
(節點)來表示各個程式碼片段,比如我們上面程式整體就是一個節點Program
節點(所有的 AST 根節點都是 Program 節點),因為它下面有兩條語句所以它的 body
屬性上就兩個宣告節點VariableDeclaration
。所以上面程式的AST
就類似這樣
可以看到在節點上用各個的屬性去表示各種資訊以及程式之間的關係,那這些節點每一個叫什麼名字,都用哪些屬性名呢?我們可以在說明文件上找到這些說明。
關於介面
看這個文件時候我們可以看到說明大多是類似這種
1 2 3 4 |
interface Node { type: string; loc: SourceLocation | null; } |
這裡提到interface
這個我們在其他語言中是比較常見的,比如Node
規定了type
和loc
屬性,如果其他節點繼承自Node
,那麼它也會實現type
和loc
屬性就是說繼承自Node
的節點也會有這些屬性,基本所有節點都繼承自Node
,所以我們基本可以看到loc
這個屬性loc
表示個一些位置資訊。
節點單位
我們程式很多地方都會被拆分成一個個的節點,節點裡面也會套著其他的節點,我們在文件中可以看到AST
結構的各個 Node
節點都很細微,比如我們宣告函式,函式就是一個節點FunctionDeclaration
,函式名和形參那麼引數都是一個變數節點Identifier
。生成的節點往往都很複雜,我們可以藉助astexplorer來幫助我們分析AST
結構。
影像展示
有了上面這些概念我們已經可以大概瞭解AST
的概念,以及各個模組代表的含義,假設我們有這樣一個程式,我們用圖形簡易的分析下它的結構
1 2 3 |
function square (n) { return n * n } |
節點遍歷
經過一番努力我們終於瞭解了AST
以及其中內容的含義,但是這一部分基本不需要我們做什麼,babel
會藉助Babylon幫我們生成我們需要的AST
結構。我們更多要去做的是去修改和改變Babylon
生成的這個抽象語法樹。
babel
拿到抽象語法樹後會使用babel-traverse
進行遞迴的樹狀遍歷,對於每一個節點都會向下遍歷到盡頭,然後向上遍歷退出分支去尋找下一個分支。這樣確保我們能找到任何一個節點,也就是能訪問到我們程式碼的任何一個部分。可是我們要怎麼去完成修改操作呢,babel
給我們提供了下面這兩個概念。
visitor
我們已經知道babel
會遍歷節點組成的抽象語法樹,每一個節點都會有自己對應的type
,比如變數節點Identifier
等。我們需要給babel
提供一個visitor
物件,在這個物件上面我們以這些節點的type
做為key
,已一個函式作為值,類似如下,
1 2 3 4 5 6 7 8 9 10 |
const visitor = { Identifier: { enter() { console.log('traverse enter a Identifier node!') }, exit() { console.log('traverse exit a Identifier node!') } } } |
這樣在遍歷進入到對應到節點時候,babel
就會去執行對應的enter
函式,向上遍歷退出對應節點時候,babel
就會去執行對應的exit
函式,接著上面的程式碼我們可以做一個測試
1 2 3 4 5 6 7 8 9 10 11 12 |
const babel = require('babel-core') const code = `var a = b + c + d` // 如果plugins是個函式則返回的物件要有visitor屬性,如果是個物件則直接定義visitor屬性 const MyVisitor = { visitor } babel.transform(code, { plugins: [MyVisitor] }) |
我們執行對應程式碼可以看到上面enter
和exit
函式分別執行了四次
1 2 3 |
traverse enter a Identifier node! traverse exit a Identifier node! ... x4 |
從上面簡單的程式碼上也可以看到a,b,c,d
四個變數,它們應該屬於同一級別的節點樹上,所以遍歷時候會分別進入對應節點然後退出再去下一個節點。
Paths
我們通過visitor
可以在遍歷到對應節點執行對應的函式,可是要修改對應節點的資訊,我們還需要拿到對應節點的資訊以及節點和所在的位置(即和其他節點間的關係)
, visitor
在遍歷到對應節點執行對應函式時候會給我們傳入path
引數,輔助我們完成上面這些操作。注意 Path
是表示兩個節點之間連線的物件,而不是當前節點,我們上面訪問到了Identifier
節點,它傳入的 path
引數看起來是這樣的
1 2 3 4 5 6 7 8 9 10 11 |
{ "parent": { "type": "VariableDeclarator", "id": {...}, .... }, "node": { "type": "Identifier", "name": "..." } } |
從上面我們可以看到 path
表示兩個節點之間的連線,通過這個物件我們可以訪問到節點、父節點以及進行一系列跟節點操作相關的方法。我們修改一下上面的 visitor
函式
1 2 3 4 5 6 7 8 9 10 |
const visitor = { Identifier: { enter(path) { console.log('traverse enter a Identifier node the name is ' + path.node.name) }, exit(path) { console.log('traverse exit a Identifier node the name is ' + path.node.name) } } } |
在執行一下上面的程式碼就可以看到name
列印出來的依次是a
,b
,c
,d
。這樣我們就有可以修改操作我們需要改變的節點了。另外path
物件上還包含新增、更新、移動和刪除節點有關的其他很多方法,我們可以通過文件去了解。
一些有用的工具
babel
為了方便我們開發,在每一個環節都有很多人性化的定義也提供了很多實用性的工具,比如之前我們在定義visitor
時候分別定義了enter
,exit
函式,可很多時候我們其實只用到了一次在enter
的時候做一些處理就行了。所以我們如果我們直接定義節點的key
為函式,就相當於定義了enter
函式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const visitor = { Identifier(){ // dosmting } } // 等同於 ↓ ↓ ↓ ↓ ↓ ↓ const visitor = { Identifier: { enter() { // dosmting } } } |
上面我們還提到了plugins是函式的情況,其實我們寫的差距一般都是一個函式,這個入口函式上babel
也會穿入一個babel-types
,這是一個用於AST
節點的 Lodash
式工具庫(類似lodash
對於js
的幫助), 它包含了構造、驗證以及變換 AST 節點的方法。 該工具庫包含考慮周到的工具方法,對編寫處理AST邏輯非常有用。
實際運用
假如我們有如下程式碼
1 2 3 4 |
const a = 3 * 103.5 * 0.8 log(a) const b = a + 105 - 12 log(b) |
我們發現這裡把console.log
簡寫成了log
,為了讓這些程式碼可以執行,我們現在用babel
裝置去轉換一下這些程式碼。
改變log函式呼叫本身
既然是console.log
沒有寫全,我們就改變這個log
函式呼叫的地方,把每一個log
替換成console.log
,我們看一下log(*)
屬於函式執行語句,相對應的節點就是CallExpression
,我們看下它的結構
1 2 3 4 5 6 |
interface CallExpression <:Expression { type: "CallExpression"; callee: Expression | Super | Import; arguments: [ Expression | SpreadElement ]; optional: boolean | null; } |
callee
是我們函式執行的名稱,arguments
就是我們穿入的引數,引數我們不需要改變,只需要把函式名稱改變就好了,之前的callee
是一個變數,我們現在要把它變成一個表示式(取物件屬性值的表示式)
,我們看一下手冊可以看到是一個MemberExpression
型別的值,這裡也可以藉助之前提到的網站astexplorer來幫助我們分析。有了這些資訊我們就可以去實現我們的目的了,我們這裡手動引入一下babel-types
輔助我們建立新的節點
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
const babel = require('babel-core') const t = require('babel-types') const code = ` const a = 3 * 103.5 * 0.8 log(a) const b = a + 105 - 12 log(b) ` const visitor = { CallExpression(path) { // 這裡判斷一下如果不是log的函式執行語句則不處理 if (path.node.callee.name !== 'log') return // t.CallExpression 和 t.MemberExpression分別代表生成對於type的節點,path.replaceWith表示要去替換節點,這裡我們只改變CallExpression第一個引數的值,第二個引數則用它自己原來的內容,即本來有的引數 path.replaceWith(t.CallExpression( t.MemberExpression(t.identifier('console'), t.identifier('log')), path.node.arguments )) } } const result = babel.transform(code, { plugins: [{ visitor: visitor }] }) console.log(result.code) |
執行後我們可以看到結果
1 2 3 4 |
const a = 3 * 103.5 * 0.8; console.log(a); const b = a + 105 - 12; console.log(b); |
直接在模組中宣告log
我們已經知道每一個模組都是一個對於的AST
,而AST
根節點是 Program
節點,下面的語句都是body
上面的子節點,我們只要在body
頭宣告一下log
變數,把它定義為console.log
,後面這樣使用就也正常了。
這裡簡單的修改下visitor
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const visitor = { Program(path) { path.node.body.unshift( t.VariableDeclaration( 'var', [t.VariableDeclarator( t.Identifier('log'), t.MemberExpression(t.identifier('console'), t.identifier('log')) )] ) ) } } |
執行後生成的程式碼為
1 2 3 4 5 6 |
var log = console.log; const a = 3 * 103.5 * 0.8; log(a); const b = a + 105 - 12; log(b); |
總結
到這裡我們已經簡單的分析程式碼,修改一些抽象語法樹上的內容來達到我們的目的,但是還是有很多中情況還沒考慮進去,而babel
現階段不僅僅代表著去轉換es6
程式碼之類的功能,實際上我們自己可以寫出很多有意思的外掛,歡迎來了解babel
,按照自己的想法寫一些外掛或者去貢獻一些程式碼,相信在這個過程中你收穫的絕對比你想象中的要更多!