假如面試官問你Babel的原理該怎麼回答

腹黑的可樂發表於2023-01-09

1. 什麼是 Babel

簡單地說,Babel 能夠轉譯 ECMAScript 2015+ 的程式碼,使它在舊的瀏覽器或者環境中也能夠執行。

// es2015 的 const 和 arrow function
const add = (a, b) => a + b;

// Babel 轉譯後
var add = function add(a, b) {
  return a + b;
};

Babel 的功能很純粹。我們傳遞一段原始碼給 Babel,然後它返回一串新的程式碼給我們。就是這麼簡單,它不會執行我們的程式碼,也不會去打包我們的程式碼。
它只是一個編譯器。

大名鼎鼎的 Taro 也是利用 Babel 將 React 語法轉化成小程式模板。

2. Babel的包構成

核心包

  • babel-core:babel轉譯器本身,提供了babel的轉譯API,如babel.transform等,用於對程式碼進行轉譯。像webpack的babel-loader就是呼叫這些API來完成轉譯過程的。
  • babylon:js的詞法解析器,AST生成
  • babel-traverse:用於對AST(抽象語法樹,想了解的請自行查詢編譯原理)的遍歷,主要給plugin用
  • babel-generator:根據AST生成程式碼

功能包

  • babel-types:用於檢驗、構建和改變AST樹的節點
  • babel-template:輔助函式,用於從字串形式的程式碼來構建AST樹節點
  • babel-helpers:一系列預製的babel-template函式,用於提供給一些plugins使用
  • babel-code-frames:用於生成錯誤資訊,列印出錯誤點原始碼幀以及指出出錯位置
  • babel-plugin-xxx:babel轉譯過程中使用到的外掛,其中babel-plugin-transform-xxx是transform步驟使用的
  • babel-preset-xxx:transform階段使用到的一系列的plugin(官方寫好的外掛)
  • babel-polyfill:JS標準新增的原生物件和API的shim,實現上僅僅是core-js和regenerator-runtime兩個包的封裝
  • babel-runtime:功能類似babel-polyfill,一般用於library或plugin中,因為它不會汙染全域性作用域

工具包

babel-cli:babel的命令列工具,透過命令列對js程式碼進行轉譯

babel-register:透過繫結node.js的require來自動轉譯require引用的js程式碼檔案

babel8 將包名變為了@babel

3. 原理

Babel 轉換 JS 程式碼可以分成以下三個大步驟:

  • Parser(解析):此過程接受轉換之前的原始碼,輸出 AST(抽象語法樹)。在 Babel 中負責此過程的包為 babel/parser;
  • Transform(轉換):此過程接受 Parser 輸出的 AST(抽象語法樹),輸出轉換後的 AST(抽象語法樹)。在 Babel 中負責此過程的包為 @babel/traverse;
  • Generator(生成):此過程接受 Transform 輸出的新 AST,輸出轉換後的原始碼。在 Babel 中負責此過程的包為 @babel/generator。

所以AST相關知識,你應該預先就瞭解了

babel是一個轉譯器,感覺相對於編譯器compiler,叫轉譯器transpiler更準確,因為它只是把同種語言的高版本規則翻譯成低版本規則,而不像編譯器那樣,輸出的是另一種更低階的語言程式碼。
但是和編譯器類似,babel的轉譯過程也分為三個階段:parsing、transforming、generating,以ES6程式碼轉譯為ES5程式碼為例,babel轉譯的具體過程如下:

(1)code --> AST

第一步就是把我們寫的 ES6 程式碼字串轉換成 ES6 AST

那轉換的工具為 babel 的 parser

怎麼轉換的你就理解為正常的轉 AST,簡單的例子會放到結尾

(2)Transform

這一步做的事情,就是操作 AST。 將 ES6 的 AST 操作 JS 轉換成 ES5 的 AST

Transform 會遍歷AST,在此過程中會對 AST 結構進行新增、移除、更新等操作,當然這些操作依賴開發者提供的外掛。Babel 對每一個 AST 節點都提供了「進入節點 enter」 與 「退出節點 exit」 兩個時機,第三方開發者可以利用這兩個時機對舊 AST 做操作。值得一提的是,Transform 步驟是 Babel 最複雜的部分,也是第三方外掛能大顯身手的地方。

這一步是最重要的地方,類似webpack,外掛plugins就是在這裡生效,也可以自己手寫外掛加入其中。

Transform 過程採用的是典型的 訪問者模式 不熟悉的同學可以瞭解一下。

我們可以看到 AST 中有很多相似的元素,它們都有一個 type 屬性,這樣的元素被稱作節點。一個節點通常含有若干屬性,可以用於描述 AST 的部分資訊。

比如這是一個最常見的 Identifier 節點:

{
    type: 'Identifier',
    name: 'add'
}

表示這是一個識別符號。

所以,操作 AST 也就是操作其中的節點,可以增刪改這些節點,從而轉換成實際需要的 AST。
Babel 對於 AST 的遍歷是深度優先遍歷,對於 AST 上的每一個分支 Babel 都會先向下遍歷走到盡頭,然後再向上遍歷退出剛遍歷過的節點,然後尋找下一個分支。

參考 前端進階面試題詳細解答

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration", // 變數宣告
      "declarations": [ // 具體宣告
        {
          "type": "VariableDeclarator", // 變數宣告
          "id": {
            "type": "Identifier", // 識別符號(最基礎的)
            "name": "add" // 函式名
          },
          "init": {
            "type": "ArrowFunctionExpression", // 箭頭函式
            "id": null,
            "expression": true,
            "generator": false,
            "params": [ // 引數
              {
                "type": "Identifier",
                "name": "a"
              },
              {
                "type": "Identifier",
                "name": "b"
              }
            ],
            "body": { // 函式體
              "type": "BinaryExpression", // 二項式
              "left": { // 二項式左邊
                "type": "Identifier",
                "name": "a"
              },
              "operator": "+", // 二項式運算子
              "right": { // 二項式右邊
                "type": "Identifier",
                "name": "b"
              }
            }
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "module"
}

根節點我們就不說了,從 declarations 裡開始遍歷:

  1. 宣告瞭一個變數,並且知道了它的內部屬性(id、init),然後我們再以此訪問每一個屬性以及它們的子節點。
  2. id 是一個 Idenrifier,有一個 name 屬性表示變數名。
  3. 之後是 init,init 也有好幾個內部屬性:
  4. type 是ArrowFunctionExpression,表示這是一個箭頭函式表示式
  5. • params 是這個箭頭函式的入參,其中每一個引數都是一個 Identifier 型別的節點;
  6. • body 屬性是這個箭頭函式的主體,這是一個 BinaryExpression 二項式:left、operator、right,分別表示二項式的左邊變數、運算子以及右邊變數。

這是遍歷 AST 的白話形式,再看看 Babel 是怎麼做的:

Babel 會維護一個稱作 Visitor 的物件,這個物件定義了用於 AST 中獲取具體節點的方法。

Visitor

Babel 遍歷 AST 其實會經過兩次節點:遍歷的時候和退出的時候,所以實際上 Babel 中的 Visitor 應該是這樣的:

var visitor = {
    Identifier: {
        enter() {
            console.log('Identifier enter');
        },
        exit() {
            console.log('Identifier exit');
        }
    }
};

比如我們拿這個 visitor 來遍歷這樣一個 AST:

params: [ // 引數
    {
        "type": "Identifier",
        "name": "a"
    },
    {
        "type": "Identifier",
        "name": "b"
    }
]

過程可能是這樣的...

  • 進入 Identifier(params[0])
  • 走到盡頭
  • 退出 Identifier(params[0])
  • 進入 Identifier(params[1])
  • 走到盡頭
  • 退出 Identifier(params[1])

當然,Babel 中的 Visitor 模式遠遠比這複雜...

回到上面的,箭頭函式是 ES5 不支援的語法,所以 Babel 得把它轉換成普通函式,一層層遍歷下去,找到了 ArrowFunctionExpression 節點,這時候就需要把它替換成 FunctionDeclaration 節點。所以,箭頭函式可能是這樣處理的:

import * as t from "@babel/types";

var visitor = {
    ArrowFunction(path) {
        path.replaceWith(t.FunctionDeclaration(id, params, body));
    }
};

(3) Generate(程式碼生成)

上一步是將 ES6 的 AST 操作 JS 轉換成 ES5 的 AST

這一步就是將 ES5 的AST 轉換成 ES5 程式碼字串

經過上面兩個階段,需要轉譯的程式碼已經經過轉換,生成新的 AST 了,最後一個階段理所應當就是根據這個 AST 來輸出程式碼。

Babel 是深度優先遍歷。

Generator 可以看成 Parser 的逆向操作,根據新的 AST 生成程式碼,其實就是生成字串,這些字串本身沒有意義,是編譯器賦予了字串意義才變成我們所說的「程式碼」。Babel 會深度優先遍歷整個 AST,然後構建可以表示轉換後程式碼的字串。

class Generator extends Printer {
  constructor(ast, opts = {}, code) {
    const format = normalizeOptions(code, opts);
    const map = opts.sourceMaps ? new SourceMap(opts, code) : null;
    super(format, map);
    this.ast = ast;
  }
  ast: Object;
  generate() {
    return super.generate(this.ast);
  }
}

經過這三個階段,程式碼就被 Babel 轉譯成功了。

4. 簡單實現

以 const add = (a, b) => a + b 為例,轉化完成後應該變成 function add(a,b) {return a + b}。

定義待轉化的程式碼字串:

/** * 待轉化的程式碼 */
const codeString = 'const add = (a, b) => a + b';

(1)ES6 code --> AST

生成AST是需要進行字串詞法分析和語法分析的

首先進行詞法分析

/**
 * Parser 過程-詞法分析
 * @param codeString 待轉化的字串
 * @returns Tokens 令牌流
 */
function tokens(codeString) {
    let tokens = []; //存放 token 的陣列

    let current = 0; //當前的索引
    while (current < codeString.length) {
        let char = codeString[current];

        //先處理括號
        if (char === '(' || char === ')') {
            tokens.push({
                type: 'parens',
                value: char
            });
            current++;
            continue;
        }

        //處理空格,空格可能是多個連續的,所以需要將這些連續的空格一起放到token陣列中
        const WHITESPACE = /\s/;
        if (WHITESPACE.test(char)) {
            let value = '';
            while (current < codeString.length && WHITESPACE.test(char)) {
                value = value + char;
                current++;
                char = codeString[current];
            }
            tokens.push({
                type: 'whitespace',
                value: value
            });
            continue;
        }

        //處理連續數字,數字也可能是連續的,原理同上
        let NUMBERS = /[0-9]/;
        if (NUMBERS.test(char)) {
            let value = '';
            while (current < codeString.length && NUMBERS.test(char)) {
                value = value + char;
                current++;
                char = codeString[current];
            }
            tokens.push({
                type: 'number',
                value: value
            });
            continue;
        }

        //處理識別符號,識別符號一般以字母、_、$開頭的連續字元
        const LETTERS = /[a-zA-Z\$\_]/;
        if (LETTERS.test(char)) {
            let value = '';
            //識別符號
            while (current < codeString.length && /[a-zA-Z0-9\$\_]/.test(char)) {
                value = value + char;
                current++;
                char = codeString[current];
            }
            tokens.push({
                type: 'identifier',
                value: value
            });
            continue;
        }

        //處理 , 分隔符
        const COMMA = /,/;
        if (COMMA.test(char)) {
            tokens.push({
                type: ',',
                value: ','
            });
            current++;
            continue;
        }

        //處理運算子
        const OPERATOR = /=|\+|>/;
        if (OPERATOR.test(char)) {
            let value = '';
            while (OPERATOR.test(char)) {
                value += char;
                current++;
                char = codeString[current];
            }
            //如果存在 => 則說明遇到了箭頭函式
            if (value === '=>') {
                tokens.push({
                    type: 'ArrowFunctionExpression',
                    value,
                });
                continue;
            }

            tokens.push({
                type: 'operator',
                value
            });
            continue;
        }
        throw new TypeError(`還未加入此字元處理 ${char}`);
    }
    return tokens;
}

語法分析

/** * Parser 過程-語法分析 * @param tokens 令牌流 * @returns AST */
const parser = tokens => {
    // 宣告一個全時指標,它會一直存在
    let current = -1;

    // 宣告一個暫存棧,用於存放臨時指標
    const tem = [];

    // 指標指向的當前token
    let token = tokens[current];

    const parseDeclarations = () => {

        // 暫存當前指標
        setTem();

        // 指標後移
        next();

        // 如果字元為'const'可見是一個宣告
        if (token.type === 'identifier' && token.value === 'const') {
            const declarations = {
                type: 'VariableDeclaration',
                kind: token.value
            };

            next();

            // const 後面要跟變數的,如果不是則報錯
            if (token.type !== 'identifier') {
                throw new Error('Expected Variable after const');
            }

            // 我們獲取到了變數名稱
            declarations.identifierName = token.value;

            next();

            // 如果跟著 '=' 那麼後面應該是個表示式或者常量之類的,這裡我們們只支援解析函式
            if (token.type === 'operator' && token.value === '=') {
                declarations.init = parseFunctionExpression();
            }

            return declarations;
        }
    };

    const parseFunctionExpression = () => {
        next();

        let init;
        // 如果 '=' 後面跟著括號或者字元那基本判斷是一個表示式
        if (
            (token.type === 'parens' && token.value === '(') ||
            token.type === 'identifier'
        ) {
            setTem();
            next();
            while (token.type === 'identifier' || token.type === ',') {
                next();
            }

            // 如果括號後跟著箭頭,那麼判斷是箭頭函式表示式
            if (token.type === 'parens' && token.value === ')') {
                next();
                if (token.type === 'ArrowFunctionExpression') {
                    init = {
                        type: 'ArrowFunctionExpression',
                        params: [],
                        body: {}
                    };

                    backTem();

                    // 解析箭頭函式的引數
                    init.params = parseParams();

                    // 解析箭頭函式的函式主體
                    init.body = parseExpression();
                } else {
                    backTem();
                }
            }
        }

        return init;
    };

    const parseParams = () => {
        const params = [];
        if (token.type === 'parens' && token.value === '(') {
            next();
            while (token.type !== 'parens' && token.value !== ')') {
                if (token.type === 'identifier') {
                    params.push({
                        type: token.type,
                        identifierName: token.value
                    });
                }
                next();
            }
        }

        return params;
    };

    const parseExpression = () => {
        next();
        let body;
        while (token.type === 'ArrowFunctionExpression') {
            next();
        }

        // 如果以(開頭或者變數開頭說明不是 BlockStatement,我們以二元表示式來解析
        if (token.type === 'identifier') {
            body = {
                type: 'BinaryExpression',
                left: {
                    type: 'identifier',
                    identifierName: token.value
                },
                operator: '',
                right: {
                    type: '',
                    identifierName: ''
                }
            };
            next();

            if (token.type === 'operator') {
                body.operator = token.value;
            }

            next();

            if (token.type === 'identifier') {
                body.right = {
                    type: 'identifier',
                    identifierName: token.value
                };
            }
        }

        return body;
    };

    // 指標後移的函式
    const next = () => {
        do {
            ++current;
            token = tokens[current]
                ? tokens[current]
                : {type: 'eof', value: ''};
        } while (token.type === 'whitespace');
    };

    // 指標暫存的函式
    const setTem = () => {
        tem.push(current);
    };

    // 指標回退的函式
    const backTem = () => {
        current = tem.pop();
        token = tokens[current];
    };

    const ast = {
        type: 'Program',
        body: []
    };

    while (current < tokens.length) {
        const statement = parseDeclarations();
        if (!statement) {
            break;
        }
        ast.body.push(statement);
    }
    return ast;
};

可以大概認為,轉成AST的過程中就是不斷的迴圈、正則、識別符號比對等一系列的操作

(2) Transform

const traverser = (ast, visitor) => {

    // 如果節點是陣列那麼遍歷陣列
    const traverseArray = (array, parent) => {
        array.forEach((child) => {
            traverseNode(child, parent);
        });
    };

    // 遍歷 ast 節點
    const traverseNode = (node, parent) => {
        const methods = visitor[node.type];

        if (methods && methods.enter) {
            methods.enter(node, parent);
        }

        switch (node.type) {
            case 'Program':
                traverseArray(node.body, node);
                break;

            case 'VariableDeclaration':
                traverseArray(node.init.params, node.init);
                break;

            case 'identifier':
                break;

            default:
                throw new TypeError(node.type);
        }

        if (methods && methods.exit) {
            methods.exit(node, parent);
        }
    };
    traverseNode(ast, null);
};

/**
 * Transform 過程
 * @param ast 待轉化的AST
 * 此函式會呼叫traverser,傳入自定義的visitor完成AST轉化
 */
const transformer = (ast) => {
    // 新 ast
    const newAst = {
        type: 'Program',
        body: []
    };

    // 此處在ast上新增一個 _context 屬性,與 newAst.body 指向同一個記憶體地址,traverser函式操作的ast_context都會賦值給newAst.body
    ast._context = newAst.body;

    traverser(ast, {
        VariableDeclaration: {
            enter(node, parent) {
                let functionDeclaration = {
                    params: []
                };
                if (node.init.type === 'ArrowFunctionExpression') {
                    functionDeclaration.type = 'FunctionDeclaration';
                    functionDeclaration.identifierName = node.identifierName;
                    functionDeclaration.params = node.init.params;
                }
                if (node.init.body.type === 'BinaryExpression') {
                    functionDeclaration.body = {
                        type: 'BlockStatement',
                        body: [{                            type: 'ReturnStatement',                            argument: node.init.body                        }],
                    };
                }
                parent._context.push(functionDeclaration);
            }
        },
    });

    return newAst;
};

(3) generate

/** * Generator 過程 * @param node 新的ast * @returns 新的程式碼 */
const generator = (node) => {
    switch (node.type) {
        // 如果是 `Program` 結點,那麼我們會遍歷它的 `body` 屬性中的每一個結點,並且遞迴地
        // 對這些結點再次呼叫 codeGenerator,再把結果列印進入新的一行中。
        case 'Program':
            return node.body.map(generator)
                .join('\n');

        // 如果是FunctionDeclaration我們分別遍歷呼叫其引數陣列以及呼叫其 body 的屬性
        case 'FunctionDeclaration':
            return 'function' + ' ' + node.identifierName + '(' + node.params.map(generator) + ')' + ' ' + generator(node.body);

        // 對於 `Identifiers` 我們只是返回 `node` 的 identifierName
        case 'identifier':
            return node.identifierName;

        // 如果是BlockStatement我們遍歷呼叫其body陣列
        case 'BlockStatement':
            return '{' + node.body.map(generator) + '}';

        // 如果是ReturnStatement我們呼叫其 argument 的屬性
        case 'ReturnStatement':
            return 'return' + ' ' + generator(node.argument);

        // 如果是ReturnStatement我們呼叫其左右節點並拼接
        case 'BinaryExpression':
            return generator(node.left) + ' ' + node.operator + ' ' + generator(node.right);

        // 沒有符合的則報錯
        default:
            throw new TypeError(node.type);

    }
};

(4) 整個流程串聯起來,完成呼叫鏈

let token = tokens(codeString);
let ast = parser(token);
let newAST = transformer(ast);
let newCode = generator(newAST);

console.log(newCode);

5. 其他擴充套件知識

此外,還要注意很重要的一點就是,babel只是轉譯新標準引入的語法,比如ES6的箭頭函式轉譯成ES5的函式;而新標準引入的新的原生物件,部分原生物件新增的原型方法,新增的API等(如Proxy、Set等),這些babel是不會轉譯的。需要使用者自行引入polyfill來解決

plugins

外掛應用於babel的轉譯過程,尤其是第二個階段transforming,如果這個階段不使用任何外掛,那麼babel會原樣輸出程式碼。

我們主要關注transforming階段使用的外掛,因為transform外掛會自動使用對應的詞法外掛,所以parsing階段的外掛不需要配置。

presets

如果要自行配置轉譯過程中使用的各類外掛,那太痛苦了,所以babel官方幫我們做了一些預設的外掛集,稱之為preset,這樣我們只需要使用對應的preset就可以了。以JS標準為例,babel提供瞭如下的一些preset:

• es2015

• es2016

• es2017

• env

es20xx的preset只轉譯該年份批准的標準,而env則代指最新的標準,包括了latest和es20xx各年份
另外,還有 stage-0到stage-4的標準成形之前的各個階段,這些都是實驗版的preset,建議不要使用。

polyfill

polyfill是一個針對ES2015+環境的shim,實現上來說babel-polyfill包只是簡單的把core-js和regenerator runtime包裝了下,這兩個包才是真正的實現程式碼所在(後文會詳細介紹core-js)。

使用babel-polyfill會把ES2015+環境整體引入到你的程式碼環境中,讓你的程式碼可以直接使用新標準所引入的新原生物件,新API等,一般來說單獨的應用和頁面都可以這樣使用。

runtime

polyfill和runtime的區別(必看)

直接使用babel-polyfill對於應用或頁面等環境在你控制之中的情況來說,並沒有什麼問題。但是對於在library中使用polyfill,就變得不可行了。因為library是供外部使用的,但外部的環境並不在library的可控範圍,而polyfill是會汙染原來的全域性環境的(因為新的原生物件、API這些都直接由polyfill引入到全域性環境)。這樣就很容易會發生衝突,所以這個時候,babel-runtime就可以派上用場了。

相關文章