深刻Babel,這一篇就夠了

前言

既然標題說了是深刻Babel,那咱們就不說Babel各類用法了,什麼babel-core,babel-runtime,babel-loader……若是你想了解這一部份內容,這類文章不少,推薦最近看到的一篇:一口(很長的)氣了解 babel,能夠說是至關詳實完備了。node

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

那咱們開始吧!github

抽象語法樹(AST)

要了解Babel的工做原理,那首先須要瞭解抽象語法樹,由於Babel插件就是做用於抽象語法樹。首先咱們編寫的代碼在編譯階段解析成抽象語法樹(AST),而後通過一系列的遍歷和轉換,而後再將轉換後的抽象語法樹生成爲常規的js代碼。下面這幅圖(來源)能夠表示Babel的工做流程: ajax

咱們先說AST,代碼解析成AST的目的就是方便計算機更好地理解咱們的代碼。這裏咱們先寫一段代碼:

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

add(1, 2);
複製代碼

而後將代碼解析成抽象語法樹(在線工具),表示成JSON形式以下:express

{
  "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"
}
複製代碼

這裏你會發現抽象語法樹中不一樣層級有着類似的結構,好比:npm

{
    "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)。bash

  • 解析babel

    將代碼解析成抽象語法樹(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作了特殊優化,後續有時間我會對這個插件作一次源碼的分析。

差很少就是這樣了。

以上。

相關文章
相關標籤/搜索