手写一个自己的babel

3,088 阅读10分钟

babel 背景介绍

babel 是一个前端的代码转换工具,目的是为了让开发者使用ECMA最新的标准甚至一些在stage阶段的提案功能,而不用过多考虑运行环境的兼容性。

近些年得益于js社区的活跃,ES版本从15年开始每年都会发布一个新的版本,截止目前最新的版本是ECMAScript 2019,已经是第10个版本了,但是目前最新的chrome尚未完全支持ES6的所有功能,比如模块方面的功能,而babel的出现可以让前端开发工程师们用最新的语法去coding,由它来保证在浏览器中代码的转换,换句话说它可以让我们用最新的标准写代码,而不用考虑浏览器的兼容~

如果想了解更多关于babel的内容,请移步这里,还有中文版文档

上回分析的基础配置篇在这里

babel 原理

babel 可以转化ES6的语法到ES5,如下

// Babel 输入: ES2015 箭头函数
[1, 2, 3].map((n) => n + 1);

// Babel 输出: ES5 语法实现的同等功能
[1, 2, 3].map(function(n) {
  return n + 1;
});

所有被babel处理的js内容都会经历3个大的流程:

  1. 词法分析,解析为tokens数组
  2. 语法分析
    • 转化tokens到对象的带有语法信息的ast[抽象语法树]
    • 根据ast转化为对应的需要的新的ast
  3. 生成,将新的ast生成为可执行的代码,并输出

实现一个自己的的babel

本文实现的babel fork 自 the-super-tiny-compiler,内容基本一样,重在希望可以对没有时间阅读代码的同学起个快速理解的作用,后续如果有时间,会写一个和js相关的转换代码

步骤

  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]

解析源代码,生成tokens

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串~

如果对你有点帮助,希望顺手给个赞,您的赞是我坚持输出的动力,谢谢~