手寫一個本身的babel

babel 背景介紹

babel 是一個前端的代碼轉換工具,目的是爲了讓開發者使用ECMA最新的標準甚至一些在stage階段的提案功能,而不用過多考慮運行環境的兼容性。javascript

近些年得益於js社區的活躍,ES版本從15年開始每一年都會發佈一個新的版本,截止目前最新的版本是ECMAScript 2019,已是第10個版本了,可是目前最新的chrome還沒有徹底支持ES6的全部功能,好比模塊方面的功能,而babel的出現可讓前端開發工程師們用最新的語法去coding,由它來保證在瀏覽器中代碼的轉換,換句話說它可讓咱們用最新的標準寫代碼,而不用考慮瀏覽器的兼容~前端

若是想了解更多關於babel的內容,請移步這裏,還有中文版文檔java

上回分析的基礎配置篇在這裏node

babel 原理

babel 能夠轉化ES6的語法到ES5,以下git

// Babel 輸入: ES2015 箭頭函數
[1, 2, 3].map((n) => n + 1);

// Babel 輸出: ES5 語法實現的同等功能
[1, 2, 3].map(function(n) {
  return n + 1;
});
複製代碼

全部被babel處理的js內容都會經歷3個大的流程:github

  1. 詞法分析,解析爲tokens數組
  2. 語法分析
    • 轉化tokens到對象的帶有語法信息的ast[抽象語法樹]
    • 根據ast轉化爲對應的須要的新的ast
  3. 生成,將新的ast生成爲可執行的代碼,並輸出

實現一個本身的的babel

本文實現的babel fork 自 the-super-tiny-compiler,內容基本同樣,重在但願能夠對沒有時間閱讀代碼的同窗起個快速理解的做用,後續若是有時間,會寫一個和js相關的轉換代碼chrome

步驟

  1. input -> tokens [tokenizer]
  2. tokens -> ast [parser]
  3. ast -> newAst [transformer]
  4. newAst -> output [codeGenerator]

例子

const code = '(add 2 (add 4 2))'; // 初始的code
    const expectCode = 'add(2, add(4,2))'; // 指望轉換結果
複製代碼

預期過程

  1. tokenizer result [詞法分析結果]
const tokens = [
    { type: 'paren', value: '(' },
    { type: 'name', value: 'add' },
    { type: 'number', value: '2' },
    { type: 'paren', value: '(' },
    { type: 'name', value: 'add' },
    { type: 'number', value: '4' },
    { type: 'number', value: '2' },
    { type: 'paren', value: ')' },
    { type: 'paren', value: ')' }
    ]
複製代碼
  1. parser result [語法分析結果,構建ast]
const ast = {
    "type": "Program",
    "body": [
        {
            "type": "CallExpression",
            "name": "add",
            "params": [
                { "type": "NumericLiteral", "value": "2" },
                {
                    "type": "CallExpression",
                    "name": "add",
                    "params": [{ "type": "NumericLiteral", "value": "4" }, { "type": "NumericLiteral", "value": "2" }]
                }
            ]
        }
    ]
}
複製代碼
  1. transformer result [轉換原始ast到新的目標ast格式,語法轉化結果]
const newAst = {
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "CallExpression",
                "callee": { "type": "Identifier", "name": "add" },
                "arguments": [
                    { "type": "NumberLiteral", "value": "2" },
                    {
                        "type": "CallExpression",
                        "callee": { "type": "Identifier", "name": "add" },
                        "arguments": [
                            { "type": "NumberLiteral", "value": "4" },
                            { "type": "NumberLiteral", "value": "2" }
                        ]
                    }
                ]
            }
        }
    ]
}
複製代碼
  1. output result [生成預期code]
const output = 'add(2, add(4, 2));'
複製代碼

實現

詞法分析階段[tokenizer]

解析源代碼,生成tokensexpress

const tokenizer = input => {
    let current = 0; // 記錄起始位置
    const tokens = []; // 存放tokens
    while (current < input.length) {
        let char = input[current];
        if (char === '(' || char === ')') {
            // 遇到括號,轉爲type是paren的token
            tokens.push({
                type: 'paren',
                value: char
            });
            current++; // 遞增一位
            continue;
        }
        if (/\s/.test(char)) {
            // 空格無論,直接跳過 【空格在這裏對咱們沒有實際的意義,可是須要current下標跳過】
            current++;
            continue;
        }
        if (/[0-9]/.test(char)) {
            // 若是是數字,用while來遍歷到下一位不是數字的位置
            let value = ''; // 用來放這個數字
            while (/[0-9]/.test(char)) {
                value += char;
                char = input[++current];
            }
            tokens.push({
                type: 'number',
                value
            });
            continue;
        }
        if (char === "'") {
            // 這裏只處理 '' 包圍的字符串,和處理數字的思路是同樣的
            let value = ''; // 用來存放字符串
            char = input[++current];
            while (char !== "'") {
                value += char;
                char = input[++current];
            }
            char = input[++current];
            tokens.push({
                type: 'string',
                value
            });
            continue;
        }

        const LETTERS = /[a-z]/i; // 處理name
        if (LETTERS.test(char)) {
            // 處理連續的字母,變量的名稱,也所以把type定義爲name
            let value = '';
            while (LETTERS.test(char)) {
                value += char;
                char = input[++current];
            }
            tokens.push({
                type: 'name',
                value
            });
            continue;
        }
        // 若是都匹配不到,咱們就認爲沒法解析~
        throw new TypeError('I dont know what this character is: ' + char);
    }

    // 返回處理好的tokens
    return tokens;
};
複製代碼

語法分析階段[parser]

構建原始ast

解析第一步生成的tokens爲對應的ast抽象語法樹小程序

const parser = tokens => {
    let current = 0;
    // walk 函數 用來 parse token到對應的 【語法】 類型
    function walk() {
        let token = tokens[current];
        if (token.type === 'number') {
            current++;
            return {
                type: 'NumericLiteral',
                value: token.value
            };
        }
        if (token.type === 'string') {
            current++;
            return {
                type: 'StringLiteral',
                value: token.value
            };
        }
        // 遇到括號的時候,咱們認爲開始進行方法調用的轉換了
        if (token.type === 'paren' && token.value === '(') {
            token = tokens[++current]; // 跳過 '(' 自己這個token,它只是個標記,在這裏沒有實際做用
            let node = {
                // 定義一個node節點,類型就是 CallExpression
                type: 'CallExpression',
                name: token.value, // 咱們認爲括號接下來的token 放的就是這個方法的 name
                params: [] // 默認方法的參數是一個空數組
            };
            token = tokens[++current]; // 跳過name這個token

            // 遍歷name以後的token,必定要遇到方法調用的結束符號 '(' 爲止
            while (token.type !== 'paren' || (token.type === 'paren' && token.value !== ')')) {
                node.params.push(walk()); // 在'('和')' 中間全部的token,咱們都認爲是當前方法調用時候的參數,所以遞歸解析就能夠了~
                token = tokens[current]; // 這裏每次從新獲取一下當前的token,爲了拋出錯誤的時候用到
            }
            current++; // 和前面同樣,處理完當前current 加一位到下一個
            return node; // 返回當前構建好的node
        }

        throw new TypeError(token.type);
    }

    // 定義原始的ast結構
    const ast = {
        type: 'Program', // 入口開始咱們稱爲 Program
        body: []
    };

    // 遍歷tokens
    while (current < tokens.length) {
        ast.body.push(walk()); // 遞歸tokens,填充ast
    }
    return ast; // 返回填充結束的ast~
};
複製代碼
生成新的ast

根據原始ast轉化爲新的ast數組

// 拿到ast後,就能夠進行下一步的「轉化」流程了,可是在這以前咱們須要先設計一個traverser方法,這個方法會對生成好的ast進行深度優先的遞歸遍歷,同時還能夠提供一個visitor參數用來「訪問」每個語法類型的遍歷過程,有了visitor就能夠在「訪問」到當前type類型的node的時候,對當前的node進行一些額外的處理了,這也是babel插件的工做原理,其實仍是在對ast的轉化過程當中進行的處理~

const traverser = (ast, visitor) => {
    // traverseArray 會遍歷array,對每個item進行traverseNode轉化
    function traverseArray(array, parent) {
        array.forEach(el => {
            traverseNode(el, parent);
        });
    }

    // 轉化過程,接受2個參數 當前轉化的node 和 父節點parent
    function traverseNode(node, parent) {
        // 先看下提供的visitor中有沒有當前node.type類型的method
        // 從babel的官網中能夠看到method能夠直接經過 [type](){}這種function的類型進行定義,也能夠經過一個config,包含enter 和 exit的形式來定義~
        // 樹的遍歷過程當中,咱們對每個節點會有2次訪問的機會,第一次是進入,第二次是退出,對應這裏的 enter 和 exit
        const method = visitor[node.type];
        if (typeof method === 'function') {
            // 若是method是方法,直接調用,並送入node和parent
            method(node, parent);
        } else if (method && method.enter) {
            // 若是是enter,則調用enter~
            method.enter(node, parent);
        }
        switch (
            node.type // 對每一個type進行轉化
        ) {
            case 'Program':
                traverseArray(node.body, node); // 若是是Program,咱們知道他下面的子節點都在body中,所以咱們經過traverseArray把子節點的數組和當前節點送進去
                break;
            case 'CallExpression':
                traverseArray(node.params, node); // 若是是CallExpression,咱們知道CallExpression的子節點聲名在params這個數組中,一樣的對子節點進行遞歸轉化
                break;
            case 'NumericLiteral': // 當遇到 NumericLiteral 和 StringLiteral 的時候,它們都沒有子節點了,所以直接break就能夠了
            case 'StringLiteral':
                break;
            default:
                throw new TypeError(node.type); // 一樣,找不到就丟出去一個錯誤
        }
        if (typeof method === 'function') {
            // 每一個節點在遍歷結束後,咱們須要調用visitor的exit方法,相似於enter的調用
            method(node, parent);
        } else if (method && method.exit) {
            method.exit(node, parent);
        }
    }

    traverseNode(ast, null); // 遞歸的啓動,咱們的第一個節點就是ast,它是沒有父節點的,所以第二個參數給null~
};

// 接下來是對ast進行默認的轉化的過程,參數就是ast
const transformer = ast => {
    const newAst = {
        // 轉化後的結果會保存到一個新的newAst中去
        type: 'Program',
        body: []
    };
    // 這裏是一個hack,真正的實現比這個要複雜,咱們爲了實例簡單,經過_context來傳遞新老ast之間的上下文的對應關係
    // 由於雖然咱們遍歷的是舊的ast,可是須要轉化的結果保存到新的ast中,所以存在一個節點之間的對應關係
    // 咱們給舊的ast加一個_context屬性,而且讓它指向實際的新的ast的body
    // 這裏指向body是由於最外層的type類型是Program 而且他們子節點都是body,所以我只要後面修改parent的_context,就會更新到新的ast的body中
    ast._context = newAst.body;
    traverser(ast, {
        // 利用 traverser 開始遍歷
        NumericLiteral: {
            enter(node, parent) {
                // 遇到 NumericLiteral 類型,轉化爲新的帶有語法意義的node,並經過 parent._context push 到新的ast對應的節點中
                // 請注意這裏的用詞 parent._context 是ast對應的新節點,不會一直是第一層的body節點
                // ----- 分割線 ----- 第一次看到這裏請繼續往下看代碼,忽略掉下面緊挨着的【第一次先忽略】的註釋
                // [第一次先忽略]
                // ok,這裏咱們繼續遍歷到 NumericLiteral 的時候,可能已經遍歷到了 CallExpression 下的子節點 params 中,可是這個時候,請注意:
                // parent._context 指向的是 expression.arguments 這個節點哦,這就解釋了這裏是如何經過 parent._context hack了新老ast的對應關係的指向
                parent._context.push({
                    type: 'NumberLiteral',
                    value: node.value
                });
            }
        },
        StringLiteral: {
            // StringLiteral 同上
            enter(node, parent) {
                parent._context.push({
                    type: 'StringLiteral',
                    value: node.value
                });
            }
        },
        CallExpression: {
            // 這裏要注意,遇到方法調用表達式的時候,咱們要轉爲新的表達式類型
            enter(node, parent) {
                let expression = {
                    // 定義新的調用方法的語法node格式
                    type: 'CallExpression',
                    callee: {
                        // 存放方法名稱
                        type: 'Identifier',
                        name: node.name
                    },
                    arguments: [] // 存放調用參數
                };
                // 由於 Program 類型下的子節點都在body中,咱們用 _context 來指向新的body,這裏的原理是同樣的
                // 類型 CallExpression 的全部子節點在 expression.arguments 所以咱們把當前node的 _context 指向 expression.arguments
                // 請回到上面的 【第一次先忽略】 節點所在的註釋部分
                node._context = expression.arguments;
                if (parent.type !== 'CallExpression') {
                    // 當父節點的類型不在是 CallExpression 的時候,咱們知道這個表達式部分已經結束了
                    expression = {
                        type: 'ExpressionStatement',
                        expression: expression
                    };
                }
                parent._context.push(expression); // 把結果經過 parent._context 保存到新ast的對應節點中去
            }
        }
    });

    return newAst; // 遞歸結束後 返回新的ast
};
複製代碼

生成最後的output代碼

根據新的ast,生成咱們處理事後的代碼

const codeGenerator = node => {
    // 區分不一樣的類型,進行不一樣的 generator
    switch (node.type) {
        case 'Program':
            return node.body.map(codeGenerator).join('\n'); // 對於 Program 只須要加上換行符
        case 'ExpressionStatement':
            return codeGenerator(node.expression) + ';'; // 對於表達式,遞歸表達式的expression,而後加上分號 保證代碼容許的正確性
        case 'CallExpression':
            // 對於方法調用要注意下,咱們的初始的name token被放到callee屬性中,同時 params被放到arguments中,所以須要遞歸這2個屬性
            // 同時對於 arguments 要先加括號,再用 , 隔開每一項 以便於生成(x,y,z)這樣的調用格式
            return codeGenerator(node.callee) + '(' + node.arguments.map(codeGenerator).join(', ') + ')';
        case 'Identifier':
            return node.name; // 對於變量直接返回name就能夠
        case 'NumberLiteral':
            return node.value; // 對於數字返回value
        case 'StringLiteral':
            return '"' + node.value + '"'; // 對於字符串須要加上雙引號,切記咱們在轉碼,不是在運行~
        default:
            throw new Error(node.type); // 沒法解析的 拋出錯誤
    }
};
複製代碼

到此,咱們的迷你版babel就實現好了,來測試一下:

const code = '(add 2 (add 4 2))'; // 初始的code

const ast = parser(tokenizer(code));
const newAst = transformer(ast);
const output = codeGenerator(newAst);
console.log(output); // 'add(2, add(4,2))'
複製代碼

完美,真正的babel源碼中會處理不少細節的問題,可是從原理來說,你們都是相通的~,有興趣的話能夠去翻翻babel的源碼~

總結

  1. babel的思想仍是要掌握,好比最近很火的各類小程序框架,你們都會基於微信的代碼去作轉換,若是你不理解babel,可能對轉換的過程就不是很懂~
  2. 對js來講萬物皆對象,可是,對babel來講,萬物都是字符,咱們的轉化過程當中,input 和 output 都是字符,轉換隻是將input串修改爲指望的output串~

若是對你有點幫助,但願順手給個贊,您的贊是我堅持輸出的動力,謝謝~

相關文章
相關標籤/搜索