深入Babel,這一篇就夠了

Turling_hu發表於2018-12-25

前言

既然標題說了是深入Babel,那我們就不說Babel各種用法了,什麼babel-core,babel-runtime,babel-loader……如果你想了解這一部分內容,這類文章很多,推薦最近看到的一篇:一口(很長的)氣了解 babel,可以說是相當詳實完備了。

言歸正傳,這篇文章主要是去了解一下Babel是怎麼工作的,Babel外掛是怎麼工作的,以及怎麼去寫Babel外掛,相信你看完之後一定會有一些收穫。

那我們開始吧!

抽象語法樹(AST)

要了解Babel的工作原理,那首先需要了解抽象語法樹,因為Babel外掛就是作用於抽象語法樹。首先我們編寫的程式碼在編譯階段解析成抽象語法樹(AST),然後經過一系列的遍歷和轉換,然後再將轉換後的抽象語法樹生成為常規的js程式碼。下面這幅圖(來源)可以表示Babel的工作流程:

深入Babel,這一篇就夠了
我們先說AST,程式碼解析成AST的目的就是方便計算機更好地理解我們的程式碼。這裡我們先寫一段程式碼:

function add(x, y) {
    return x + y;
}

add(1, 2);
複製程式碼

然後將程式碼解析成抽象語法樹(線上工具),表示成JSON形式如下:

{
  "type": "Program",
  "start": 0,
  "end": 52,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 40,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 12,
        "name": "add"
      },
      "expression": false,
      "generator": false,
      "params": [
        {
          "type": "Identifier",
          "start": 13,
          "end": 14,
          "name": "x"
        },
        {
          "type": "Identifier",
          "start": 16,
          "end": 17,
          "name": "y"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 19,
        "end": 40,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 25,
            "end": 38,
            "argument": {
              "type": "BinaryExpression",
              "start": 32,
              "end": 37,
              "left": {
                "type": "Identifier",
                "start": 32,
                "end": 33,
                "name": "x"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 36,
                "end": 37,
                "name": "y"
              }
            }
          }
        ]
      }
    },
    {
      "type": "ExpressionStatement",
      "start": 42,
      "end": 52,
      "expression": {
        "type": "CallExpression",
        "start": 42,
        "end": 51,
        "callee": {
          "type": "Identifier",
          "start": 42,
          "end": 45,
          "name": "add"
        },
        "arguments": [
          {
            "type": "Literal",
            "start": 46,
            "end": 47,
            "value": 1,
            "raw": "1"
          },
          {
            "type": "Literal",
            "start": 49,
            "end": 50,
            "value": 2,
            "raw": "2"
          }
        ]
      }
    }
  ],
  "sourceType": "module"
}
複製程式碼

這裡你會發現抽象語法樹中不同層級有著相似的結構,比如:

{
    "type": "Program",
    "start": 0,
    "end": 52,
    "body": [...]
}
複製程式碼
{
    "type": "FunctionDeclaration",
    "start": 0,
    "end": 40,
    "id": {...},
    "body": {...}
}
複製程式碼
{
    "type": "BlockStatement",
    "start": 19,
    "end": 40,
    "body": [...]
}
複製程式碼

像這樣的結構叫做節點(Node)。一個AST是由多個或單個這樣的節點組成,節點內部可以有多個這樣的子節點,構成一顆語法樹,這樣就可以描述用於靜態分析的程式語法。

節點中的type欄位表示節點的型別,比如上述AST中的"Program"、"FunctionDeclaration"、"ExpressionStatement"等等,當然每種節點型別會有一些附加的屬性用於進一步描述該節點型別。

Babel的工作流程

上面那幅圖已經描述了Babel的工作流程,下面我們再詳細描述一下。Babel 的三個主要處理步驟分別是: 解析(parse),轉換(transform),生成(generate)。

  • 解析

    將程式碼解析成抽象語法樹(AST),每個js引擎(比如Chrome瀏覽器中的V8引擎)都有自己的AST解析器,而Babel是通過Babylon實現的。在解析過程中有兩個階段:詞法分析語法分析,詞法分析階段把字串形式的程式碼轉換為令牌(tokens)流,令牌類似於AST中節點;而語法分析階段則會把一個令牌流轉換成 AST的形式,同時這個階段會把令牌中的資訊轉換成AST的表述結構。

  • 轉換

    在這個階段,Babel接受得到AST並通過babel-traverse對其進行深度優先遍歷,在此過程中對節點進行新增、更新及移除操作。這部分也是Babel外掛介入工作的部分。

  • 生成

    將經過轉換的AST通過babel-generator再轉換成js程式碼,過程就是深度優先遍歷整個AST,然後構建可以表示轉換後程式碼的字串。

這部分更詳細的可以檢視Babel手冊。而值得注意的是,babel的外掛有兩種,一種是語法外掛,這類外掛是在解析階段輔助解析器(Babylon)工作;另一類外掛是轉譯外掛,這類外掛是在轉換階段參與進行程式碼的轉譯工作,這也是我們使用babel最常見也最本質的需求。這篇文章主要關注的也是babel的轉譯外掛。

為了瞭解Babel在遍歷時處理AST的具體過程,我們還需要了解下面幾個重要知識點。

Visitor

當Babel處理一個節點時,是以訪問者的形式獲取節點資訊,並進行相關操作,這種方式是通過一個visitor物件來完成的,在visitor物件中定義了對於各種節點的訪問函式,這樣就可以針對不同的節點做出不同的處理。我們編寫的Babel外掛其實也是通過定義一個例項化visitor物件處理一系列的AST節點來完成我們對程式碼的修改操作。舉個例子:

我們想要處理程式碼中用來載入模組的import命令語句

import { Ajax } from '../lib/utils';
複製程式碼

那麼我們的Babel外掛就需要定義這樣的一個visitor物件:

visitor: {
            Program: {
                enter(path, state) {
                    console.log('start processing this module...');
                },
                exit(path, state) {
                    console.log('end processing this module!');
                }
            },
    	    ImportDeclaration (path, state) {
            	console.log('processing ImportDeclaration...');
            	// do something
            }
	}
複製程式碼

當把這個外掛用於遍歷中時,每當處理到一個import語句,即ImportDeclaration節點時,都會自動呼叫ImportDeclaration()方法,這個方法中定義了處理import語句的具體操作。ImportDeclaration()都是在進入ImportDeclaration節點時呼叫的,我們也可以讓外掛在退出節點時呼叫方法進行處理。

visitor: {
            ImportDeclaration: {
                enter(path, state) {
                    console.log('start processing ImportDeclaration...');
                    // do something
                },
                exit(path, state) {
                    console.log('end processing ImportDeclaration!');
                    // do something
                }
            },
	}
複製程式碼

當進入ImportDeclaration節點時呼叫enter()方法,退出ImportDeclaration節點時呼叫exit()方法。上面的Program節點(Program節點可以通俗地解釋為一個模組節點)也是一樣的道理。值得注意的是,AST的遍歷採用深度優先遍歷,所以上述import程式碼塊的AST遍歷的過程如下:

─ Program.enter() 
  ─ ImportDeclaration.enter()
  ─ ImportDeclaration.exit()
─ Program.exit() 
複製程式碼

所以當建立訪問者時你實際上有兩次機會來訪問一個節點。

ps: 有關AST中各種節點型別的定義可以檢視Babylon手冊:github.com/babel/babyl…

Path

從上面的visitor物件中,可以看到每次訪問節點方法時,都會傳入一個path引數,這個path引數中包含了節點的資訊以及節點和所在的位置,以供對特定節點進行操作。具體來說Path 是表示兩個節點之間連線的物件。這個物件不僅包含了當前節點的資訊,也有當前節點的父節點的資訊,同時也包含了新增、更新、移動和刪除節點有關的其他很多方法。具體地,Path物件包含的屬性和方法主要如下:

── 屬性      
  - node   當前節點
  - parent  父節點
  - parentPath 父path
  - scope   作用域
  - context  上下文
  - ...
── 方法
  - get   當前節點
  - findParent  向父節點搜尋節點
  - getSibling 獲取兄弟節點
  - replaceWith  用AST節點替換該節點
  - replaceWithMultiple 用多個AST節點替換該節點
  - insertBefore  在節點前插入節點
  - insertAfter 在節點後插入節點
  - remove   刪除節點
  - ...
複製程式碼

具體的可以檢視babel-traverse

這裡我們繼續上面的例子,看看path引數的node屬性包含哪些資訊:

visitor: {
	ImportDeclaration (path, state) { 
    	   console.log(path.node);
    	   // do something
	}
   }
複製程式碼

列印結果如下:

Node {
  type: 'ImportDeclaration',
  start: 5,
  end: 41,
  loc: 
   SourceLocation {
     start: Position { line: 2, column: 4 },
     end: Position { line: 2, column: 40 } },
  specifiers: 
   [ Node {
       type: 'ImportSpecifier',
       start: 14,
       end: 18,
       loc: [SourceLocation],
       imported: [Node],
       local: [Node] } ],
  source: 
   Node {
     type: 'StringLiteral',
     start: 26,
     end: 40,
     loc: SourceLocation { start: [Position], end: [Position] },
     extra: { rawValue: '../lib/utils', raw: '\'../lib/utils\'' },
     value: '../lib/utils'
    }
}


複製程式碼

可以發現除了type、start、end、loc這些常規欄位,ImportDeclaration節點還有specifiers和source這兩個特殊欄位,specifiers表示import匯入的變數組成的節點陣列,source表示匯出模組的來源節點。這裡再說一下specifier中的imported和local欄位,imported表示從匯出模組匯出的變數,local表示匯入後當前模組的變數,還是有點費解,我們把import命令語句修改一下:

import { Ajax as ajax } from '../lib/utils';
複製程式碼

然後繼續列印specifiers第一個元素的local和imported欄位:

Node {
  type: 'Identifier',
  start: 22,
  end: 26,
  loc: 
   SourceLocation {
     start: Position { line: 2, column: 21 },
     end: Position { line: 2, column: 25 },
     identifierName: 'ajax' },
  name: 'ajax' }
Node {
  type: 'Identifier',
  start: 14,
  end: 18,
  loc: 
   SourceLocation {
     start: Position { line: 2, column: 13 },
     end: Position { line: 2, column: 17 },
     identifierName: 'Ajax' },
  name: 'Ajax' }
複製程式碼

這樣就很明顯了。如果不使用as關鍵字,那麼imported和local就是表示同一個變數的節點了。

State

State是visitor物件中每次訪問節點方法時傳入的第二個引數。如果看Babel手冊裡的解釋,可能還是有點困惑,簡單來說,state就是一系列狀態的集合,包含諸如當前plugin的資訊、plugin傳入的配置引數資訊,甚至當前節點的path資訊也能獲取到,當然也可以把babel外掛處理過程中的自定義狀態儲存到state物件中。

Scopes(作用域)

這裡的作用域其實跟js說的作用域是一個道理,也就是說babel在處理AST時也需要考慮作用域的問題,比如函式內外的同名變數需要區分開來,這裡直接拿Babel手冊裡的一個例子解釋一下。考慮下列程式碼:

function square(n) {
  return n * n;
}
複製程式碼

我們來寫一個把 n 重新命名為 x 的visitor。

visitor: {
	    FunctionDeclaration(path) {
                const param = path.node.params[0];
                paramName = param.name;
                param.name = "x";
             },
            
            Identifier(path) {
                if (path.node.name === paramName) {
                  path.node.name = "x";
                }
             }
	}
複製程式碼

對上面的例子程式碼這段訪問者程式碼也許能工作,但它很容易被打破:

function square(n) {
  return n * n;
}
var n = 1;
複製程式碼

上面的visitor會把函式square外的n變數替換成x,這顯然不是我們期望的。更好的處理方式是使用遞迴,把一個訪問者放進另外一個訪問者裡面。

visitor: {
           FunctionDeclaration(path) {
	       const updateParamNameVisitor = {
                  Identifier(path) {
                    if (path.node.name === this.paramName) {
                      path.node.name = "x";
                    }
                  }
                };
                const param = path.node.params[0];
                paramName = param.name;
                param.name = "x";
                path.traverse(updateParamNameVisitor, { paramName });
            },
	}
複製程式碼

到這裡我們已經對Babel工作流程大概有了一些瞭解,下面我們再說一下Babel的工具集。

Babel的工具集

Babel 實際上是一組模組的集合,在上面介紹Babel工作流程中也都提到過。

Babylon

“Babylon 是 Babel的解析器。最初是從Acorn專案fork出來的。Acorn非常快,易於使用,並且針對非標準特性(以及那些未來的標準特性) 設計了一個基於外掛的架構。”。這裡直接引用了手冊裡的說明,可以說Babylon定義了把程式碼解析成AST的一套規範。引用一個例子:

import * as babylon from "babylon";
const code = `function square(n) {
  return n * n;
}`;

babylon.parse(code);
// Node {
//   type: "File",
//   start: 0,
//   end: 38,
//   loc: SourceLocation {...},
//   program: Node {...},
//   comments: [],
//   tokens: [...]
// }
複製程式碼

babel-traverse

babel-traverse用於維護操作AST的狀態,定義了更新、新增和移除節點的操作方法。之前也說到,path引數裡面的屬性和方法都是在babel-traverse裡面定義的。這裡還是引用一個例子,將babel-traverse和Babylon一起使用來遍歷和更新節點:

import * as babylon from "babylon";
import traverse from "babel-traverse";

const code = `function square(n) {
  return n * n;
}`;

const ast = babylon.parse(code);

traverse(ast, {
  enter(path) {
    if (
      path.node.type === "Identifier" &&
      path.node.name === "n"
    ) {
      path.node.name = "x";
    }
  }
});
複製程式碼

babel-types

babel-types是一個強大的用於處理AST節點的工具庫,“它包含了構造、驗證以及變換AST節點的方法。該工具庫包含考慮周到的工具方法,對編寫處理AST邏輯非常有用。”這個工具庫的具體的API可以參考Babel官網:babeljs.io/docs/en/bab…

這裡我們還是用import命令來演示一個例子,比如我們要判斷import匯入是什麼型別的匯入,這裡先寫出三種形式的匯入:

import { Ajax } from '../lib/utils';
import utils from '../lib/utils';
import * as utils from '../lib/utils';
複製程式碼

在AST中用於表示上面匯入的三個變數的節點是不同的,分別叫做ImportSpecifier、ImportDefaultSpecifier和ImportNamespaceSpecifier。具體可以參考這裡。 如果我們只對匯入指定變數的import命令語句做處理,那麼我們的babel外掛就可以這樣寫:

function plugin () {
	return ({ types }) => ({
	    visitor: {
	        ImportDeclaration (path, state) { 
        	    const specifiers = path.node.specifiers;
        	    specifiers.forEach((specifier) => {
	                if (!types.isImportDefaultSpecifier(specifier) && !types.isImportNamespaceSpecifier(specifier)) {
            	        // do something
            	    }
    	        })
            }
        }
    }
複製程式碼

到這裡,關於Babel的原理差不多都講完了,下面我們嘗試寫一個具體功能的Babel外掛。

Babel外掛實踐

這裡我們嘗試實現這樣一個功能:當使用UI元件庫時,我們常常只會用到元件庫中的部分元件,就像這樣:

import { Select, Pagination } from 'xxx-ui';
複製程式碼

但是這樣卻引入了整個元件庫,那麼打包的時候也會把整個元件庫的程式碼打包進去,這顯然是不太合理的,所以我們希望能夠在打包的時候只打包我們需要的元件。

Let's do it!

首先我們需要告訴Babel怎麼找到對應元件的路徑,也就是說我們需要自定義一個規則告訴Babel根據指定名稱載入對應元件,這裡我們定義一個方法:

"customSourceFunc": componentName =>(`./xxx-ui/src/components/ui-base/${componentName}/${componentName}`)}
複製程式碼

這個方法作為這個外掛的配置引數,可以配置到.babelrc(準確來說是.babelrc.js)或者babel-loader裡面。 接下來我們需要定義visitor物件,有了之前的鋪墊,這裡直接上程式碼:

visitor: {
	ImportDeclaration (path, { opts }) {
	    const specifiers = path.node.specifiers;
	    const source = path.node.source;

            // 判斷傳入的配置引數是否是陣列形式
	    if (Array.isArray(opts)) {
	        opts.forEach(opt => {
	            assert(opt.libraryName, 'libraryName should be provided');
	        });
	        if (!opts.find(opt => opt.libraryName === source.value)) return;
	    } else {
	        assert(opts.libraryName, 'libraryName should be provided');
	        if (opts.libraryName !== source.value) return;
	    }

	    const opt = Array.isArray(opts) ? opts.find(opt => opt.libraryName === source.value) : opts;
	    opt.camel2UnderlineComponentName = typeof opt.camel2UnderlineComponentName === 'undefined'
	        ? false
	        : opt.camel2UnderlineComponentName;
	    opt.camel2DashComponentName = typeof opt.camel2DashComponentName === 'undefined'
	        ? false
	        : opt.camel2DashComponentName;

	    if (!types.isImportDefaultSpecifier(specifiers[0]) && !types.isImportNamespaceSpecifier(specifiers[0])) {
	        // 遍歷specifiers生成轉換後的ImportDeclaration節點陣列
    		const declarations = specifiers.map((specifier) => {
	            // 轉換元件名稱
                    const transformedSourceName = opt.camel2UnderlineComponentName
                	? camel2Underline(specifier.imported.name)
                	: opt.camel2DashComponentName
            		    ? camel2Dash(specifier.imported.name)
            		    : specifier.imported.name;
    		    // 利用自定義的customSourceFunc生成絕對路徑,然後建立新的ImportDeclaration節點
                    return types.ImportDeclaration([types.ImportDefaultSpecifier(specifier.local)],
                	types.StringLiteral(opt.customSourceFunc(transformedSourceName)));
                });
                // 將當前節點替換成新建的ImportDeclaration節點組
    		path.replaceWithMultiple(declarations);
    	}
    }
}
複製程式碼

其中opts表示的就是之前在.babelrc.js或babel-loader中傳入的配置引數,程式碼中的camel2UnderlineComponentName和camel2DashComponentName可以先不考慮,不過從字面上也能猜到是什麼功能。這個visitor主要就是遍歷模組內所有的ImportDeclaration節點,找出specifier為ImportSpecifier型別的節點,利用傳入customSourceFunc得到其絕對路徑的匯入方式,然後替換原來的ImportDeclaration節點,這樣就可以實現元件的按需載入了。

我們來測試一下效果,

const babel = require('babel-core');
const types = require('babel-types');

const plugin = require('./../lib/index.js');

const visitor = plugin({types});

const code = `
    import { Select as MySelect, Pagination } from 'xxx-ui';
    import * as UI from 'xxx-ui';
`;

const result = babel.transform(code, {
    plugins: [
        [
            visitor,
            {
                "libraryName": "xxx-ui",
                "camel2DashComponentName": true,
                "customSourceFunc": componentName =>(`./xxx-ui/src/components/ui-base/${componentName}/${componentName}`)}
            }
        ]
    ]
});

console.log(result.code);
// import MySelect from './xxx-ui/src/components/ui-base/select/select';
// import Pagination from './xxx-ui/src/components/ui-base/pagination/pagination';
// import * as UI from 'xxx-ui';

複製程式碼

這個Babel外掛已釋出到npm,外掛地址:www.npmjs.com/package/bab…

有興趣的也可以檢視外掛原始碼:github.com/hudingyu/ba… 原始碼裡面有測試例子,可以自己clone下來跑跑看,記得先build一下。

其實這個外掛算是乞丐版的按需載入外掛,ant-design的按需載入外掛babel-plugin-import實現了更完備的方案,也對React做了特殊優化,後續有時間我會對這個外掛做一次原始碼的分析。

差不多就是這樣了。

以上。

相關文章