babel 的编译过程

1,663 阅读12分钟

前言

通过对 babel 的编译流程的书写,巩固自己对 babel 的理解,如果能带给你一点收获,那就更好啦~

基本介绍

babel 是一个转译器,它能把新语法写的代码转化为目标环境支持的语法环境,并且对目标环境不支持的 api 自动 polyfill。

image.png

babel 是巴别塔的意思。当时人类联合起来兴建希望能通往天堂的高塔,为了阻止人类的计划,上帝让人类说不同的语言,使人类相互之间不能沟通,计划因此失败,人类自此各散东西。此事件,为世上出现不同语言和种族提供解释。这座塔就是巴别塔。

- 来自《圣经》的典故

通过这个典故,转变到在前端的视角中,我们也不难发现 babel 有以下用途:

1. 对 esnext/typescript 转译,兼容目标浏览器

  • 常见的有 @babel/preset-env、polyfill 等

2. 对一些特殊功能的代码转化

  • 例如自动埋点、函数加参等。

3. 对代码进行分析

  • 常见的有 eslint 或者混淆代码等

总体流程

image.png

babel 的编译总体流程主要分成三个阶段:

  • 1 parse 阶段: 通过 @babel/parser 将代码转化为 AST
  • 2 transform 阶段: 通过 @babel/traverse 对 AST 进行操作
  • 3 generate 阶段: 通过 @babel/generator 将 AST 转化为源代码

parse 阶段

parse 阶段的目的是把源代码字符串转化成机器能够理解的 AST,这个过程分成词法分析、语法分析

比如

let name = 'feng'; 

这样一段源码,我们要先把它分成一个个不能细分的单词(token),也就是 let, name, =, 'feng',这个过程是词法分析,按照单词的构成规则来拆分字符串成单词。

将 token 进行递归组装,生成 AST,这个过程称为语法分析,根据不同的语法结构,组装不同的对象,生成语法树

image.png

在这个阶段中,我们可以通过 @babel/parser 将源代码转化为 AST 语法树

image.png

如上图所示,parse的第一个参数 sourceCode 表示源代码,第二个参数代表配置项(sourceType 表示代码解析应采用的模式,传入 ‘unambiguous’ 表示自动判断代码解析格式。而plugins传入一个数组,表示要启动的插件)。 具体可以查看 babel 文档 👉 传送门

最终编译出来的 AST 如下所示:

image.png

再此推荐一个网址 👉 astexplorer.net/, 可以在线查看源代码转化为 AST 的结果。

transform 阶段

对 parse 阶段生成的 AST 进行遍历,针对不同的节点进行操作

在这个阶段中,我们可以通过 @babel/traverse 操作 AST

假设一个场景,想给项目中所有的 console.log 插入 3 个参数,这 3 个参数分别是当前的文件名、log对应的行和列。

这时候,我们可以先通过 @babel/parser 对源代码转化为 AST,通过 @babel/traverse 操作 AST,找到对应的log,并在log中插入文件名以及当前代码对应的行、列信息。

代码如下:

const traverse = require('@babel/traverse').default;
const types = require('@babel/types');

const targetCalleeName = ['log'].map(item => `console.${item}`);
traverse(ast, {
    CallExpression(path, state) {
        // const calleeName = generate(path.node.callee).code; // callee 是一个 MemberExpression 表达式
        const calleeName = path.get('callee').toString(); // arguments.callee 会指向函数本身
        if (targetCalleeName.includes(calleeName)) {
            const { line, column } = path.node.loc.start;
            path.node.arguments.unshift(types.stringLiteral(`filename: (${line}, ${column})`));
        }
    } 
})

通过在线编译网址 astexplorer.net/ 我们不难发现,console.log 在 AST 上表示为 CallExpression, 也就是表达式。 image.png

而 traverse 接收俩个参数,第一个是传入的 AST,第二个是 visitor,visitor包含了进入节点的方法(enter)、 操作节点的方法、退出节点的方法(exit),例如上面例子中的 CallExpression,在遍历 AST 时,如果遇到了 CallExpression 节点,便会执行 visitor 对应的方法,如果匹配到是 console.log, 那么就会在 console.log 的参数中插入包含文件名以及对应行列的 AST 节点。

创建 AST 节点我们可以通过 @babel/types 提供的工具方法,例如 types.stringLiteral 可以创建字符串字面量。

当需要批量创建 AST 节点时,推荐使用 @babel/template。

genertate 阶段

将 AST 转化为源代码,并生成 source-map。

在这个阶段,我们可以通过 @babel/generate 将操作后的 AST 转化为源代码

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const types = require('@babel/types');


const sourceCode = `
    console.log(1);

    function info() {
        console.info(2);
    }

    export default class People {
        say () {
            console.debug(3);
        }
        render () {
            return <div>{console.error(4)}</div>
        }
    }
`
const ast = parser.parse(sourceCode, {
    sourceType: 'unambiguous', // 不区分module还是script
    plugins: ['jsx']
});

// console.log(ast);
const targetCalleeName = ['log', 'info', 'error', 'debug'].map(item => `console.${item}`);
// traverse(ast, {
//     CallExpression(path, state) {
//         if (types.isMemberExpression(path.node.callee) 
//             && path.node.callee.object.name === 'console'
//             && ['log', 'info', 'error', 'debug'].includes(path.node.callee.property.name)) {
//                 const { line, column } = path.node.loc.start;
//                 path.node.arguments.unshift(types.stringLiteral(`filename: (${line}, ${column})`));
//             }
//     }
// })
traverse(ast, {
    CallExpression(path, state) {
        // const calleeName = generate(path.node.callee).code; // callee 是一个 MemberExpression 表达式
        const calleeName = path.get('callee').toString(); // arguments.callee 会指向函数本身
        if (targetCalleeName.includes(calleeName)) {
            const { line, column } = path.node.loc.start;
            path.node.arguments.unshift(types.stringLiteral(`filename: (${line}, ${column})`));
        }
    } 
})

const {code} = generate(ast);
console.log(code);

转化后的结果如下所示:

image.png

至此,我们在大体粗略了解了 babel 编译过程的同时,也实现了一个函数加参的效果。

常见的 AST

想要对 AST 进行操作,了解常见的 AST 节点类型是必不可少滴。

Literal - 字面量

比如 let name = 'feng'; 这里的 'feng' 就是字符串字面量

常见的字面量有:

image.png

babel 通过 xxLiteral 来抽象这部分内容

Identifier - 标识符

标识符是由字母数字下划线美元符号组成,常见的的有变量名、属性名、参数名等各种声明和引用的名字

image.png

Statement - 语句

可以独立执行的单位。 语句是代码执行的最小单位。

image.png

Declaration - 声明语句

声明语句是一种特殊的语句,例如声明了一个变量、函数、class、import、export 等

image.png

Expression - 表达式

执行后会有返回值,也是和语句的区别之一

image.png

当表达式作为语句执行时,会在外层包裹 ExpressStatement

image.png

Class - 类

整个 class 的内容是 ClassBody。属性是 ClassProperty, 方法是 ClassMethod,可以通过 kind 属性去区分是构造函数还是方法

image.png

import - 导入

有三种类型的 Specifier: importSpecifier、importDefaultSpecifier、importNamespaceSpecifier

image.png

可以看出在 importDeclaration(声明语句)下包含着各种 import Specifier

export - 导出

有三种类型导出声明语句: ExportNamedDeclaration、ExportDefaultDeclaration、ExportAllDeclaration

image.png

Program & Directive - 根节点 & 属性

program 是代表整个程序的节点,它有 body 属性代表程序体,存放 statement(语句)数组,就是具体执行的语句的集合。还有 directives 属性,存放 Directive 节点,比如"use strict" 这种指令会使用 Directive 节点表示。

image.png

File - 最外层节点

babel 的 AST 最外层节点是 File,它有 program、comments、tokens 等属性,分别存放 Program 程序体、注释、token 等,是最外层节点。 image.png

实现一个简易的 babel

通过前面的学习,我们不仅大体了解了 babel 的编译过程也了解 AST 节点的相关知识,接下来我们将实现一个简易的 babel ,加深对 babel 的理解。

image.png

实现一个 parser

如何使用 @babel/parser

通过官方文档,我们可以了解到,parser 接收俩个参数,第一个参数表示源代码,第二个参数表示解析的配置,例如:

  • sourceType: 以什么方式进行解析
  • plugins: 需要额外加载的插件

image.png

已知条件

babel 的 parser 并不是完全自己实现的,而是基于 acorn 并通过继承插件的方式进行拓展, 因此如果我们想实现一个 parser 插件只需要继承 acorn 的 Parser 类,并根据需要进行拓展即可。如下实现一个 parser 插件:

image.png

思路

  • 1 获取到用户传入的配置项,根据配置项去加载对应的 parser 插件
  • 2 通过继承的方式对 acorn 的 parser 进行拓展
/**
 * babel 的 parser 是基于 acorn 进行拓展的, 例如 Literal 拓展成 StringLiteral、NumberLiteral 等。
 * 同时实现了 jsx、typescript、flow 等语法插件的拓展
 */
const acorn = require("acorn");

const syntaxPlugins = {
    'literal': require('./plugins/literal'),
    'cvteKeyword': require('./plugins/cvteKeyword')
}

const defaultOptions = {};

/**
 * babel 中的 parser 通过继承的方式去拓展插件
 * @param {*} code 
 * @param {*} options 
 * @returns 
 */
function parse(code, options) {
    const resolvedOptions = Object.assign({}, defaultOptions, options);
    
    const newParser = resolvedOptions.plugins && resolvedOptions.plugins.reduce((Parser, pluginName) => {
        let plugin = syntaxPlugins[pluginName];
        return plugin ? Parser.extend(plugin) : plugin; // Parser.extend 内部会调用 plugin 方法并传入 Parser
    }, acorn.Parser) || acorn.Parser;

    return newParser.parse(code, {
        locations: true, // 保留 AST 源码中的位置信息,生成 sourcemap 的时候会用 (源码位置信息会存储在 loc 属性上)
    })
}

module.exports = {
    parse
}

实现一个 traverse

如何使用 @babel/traverse

通过下图可知,traverse方法接受俩个参数,第一个参数是 AST, 在深度遍历 AST 时,会判断节点的类型是否有匹配的函数,有则执行对应的回调函数 image.png

已知条件

traverse 是遍历 AST,并且在遍历的过程中判断节点的类型是否有对应的函数,在函数里面实现对 AST 的增删改。

思路

  • 1 对 AST 进行深度优先遍历
  • 2 在遍历的过程中,根据类型调用不同的 visitor 函数

在遍历的过程中,我们还需要判断当前节点下的哪些子节点可以继续遍历,因此我们需要借助一个 Map 帮助我们记录哪些节点的子节点是需要继续遍历的

image.png

知道了哪些节点需要遍历,我们就可以对 AST 进行深度优先遍历,并且如传入的函数有匹配的节点,则执行函数。

/**
 *
 * example:
    traverse(ast, {
        enter: xxx,
        Identifier(node) {
            node.name = 'b';
        },
        exit: xxx,
    });
 * @param {*} node - AST节点
 * @param {*} visitor - 用户自定义的visitor对象
 * @param {*} parent - 父节点 用来记录节点对应的路径
 * @param {*} parentPath - 路径
 * @param {*} key - 当前节点对应到父节点的属性
 * @param {*} listKey - 如果父节点的属性是数组,listKey对应下标
 */
const traverse = function(node, visitor, parent, parentPath, key, listKey) {
    const defination = visitorKeys.get(node.type);

    let visitorFn = visitor && visitor[node.type];
    const path = new NodePath(node, parent, parentPath, key, listKey);

    visitor.enter && visitor.enter(path);


    if (typeof visitorFn === 'function') {
        visitorFn(path);
    }

    if (node.__shouldSkip) {
        delete node.__shouldSkip;
        return;
    }
    
    if (defination && defination.visitor) {
        defination.visitor.forEach(key => {
            const prop = node[key];
            if (Array.isArray(prop)) {
                prop.forEach((childNode, childIndex) => {
                    traverse(childNode, visitor, node, path, key, childIndex);
                })
            } else {
                traverse(prop, visitor, node, path, key);
            }
        })
    }

    visitor.exit && visitor.exit(path);
}

在执行对应的回调函数时,会传入俩个参数 path 和 state。 path 不仅存储了各个节点的关联关系,还有一些工具方法,例如 path.isIdentifier 可以判断节点是否是标识符、对节点进行删除、替换、跳过节点的遍历以及查找父节点等,因此我们需要实现一个 path。

  • 实现 path 的思路:

    前提: path 可以简单的理解是链表,是在深度优先遍历的过程中,不断地去关联父子path

    做法: 在遍历的过程中,创建一个节点 NodePath, 不断地去关联path

  • 代码如下:

const types = require('../types');
const Scope = require('./Scope');

/**
 * 每个节点对应的 path
 */
module.exports = class NodePath {
    constructor(node, parent, parentPath, key, listKey) {
        this.node = node;
        this.parent = parent; // node 节点
        this.parentPath = parentPath; // NodePath
        this.key = key;
        this.listKey = listKey;

        Object.keys(types).forEach(key => { // 给 NodePath 绑定 节点类型的判断函数
            if (key.startsWith('is')) {
                this[key] = types[key].bind(this, node);
            }
        })
    }

    // 需要用到的时候采取获取 scope
    get scope () {
        if (this._scope) {
            return this._scope;
        }

        const isBlock = this.isBlock();
        const parentScope = this.parentPath && this.parentPath.scope;
        this._scope = isBlock ? new Scope(parentScope, this) : parentScope;
        return this._scope;
    }
    
    replaceWith(node) {
        if (this.listKey !== undefined) {
            this.parent[this.key].splice(this.listKey, 1, node);
        } else {
            this.parent[this.listKey] = node;
        }
    }
    remove() {
        if (this.listKey !== undefined) {
            this.parent[this.key].splice(this.listKey, 1);
        } else {
            this.parent[this.listKey] = null;
        }
    }
    findParent(callback) {
        let curPath = this.parentPath;
        while(curPath && !callback(curPath)) {
            curPath = curPath.parentPath;
        }
        return curPath;
    }
    find(callback) {
        let curPath = this;
        while(curPath && !callback(curPath)) {
            curPath = curPath.parentPath
        }
        return curPath;
    }
    skip() {
        this.node._shouldSkip = true; // 给节点设置字段,如有标记则跳过子节点遍历
    }
    traverse (visitor) {
        const traverse = require('../index');
        const defination = types.visitorKeys.get(this.node.type); // 判断

        if (defination.visitor) {
            defination.visitor.forEach(key => {
                const prop = this.node[key];
                if (Array.isArray(prop)) { // 例如参数名有可能是一个数组
                    prop.forEach((childNode, childIndex) => {
                        traverse(childNode, visitor, this.node, this, key, childIndex)
                    })
                } else {
                    traverse(prop, visitor, this.node, this, key)
                }
            })
        }
    }
    /**
     * 判断是否是能生成作用域的节点
     */
    isBlock() {
        return types.visitorKeys.get(this.node.type)?.isBlock;
    }
}

path下有个属性 scope,path.scope 记录着整条作用域链,包括声明的变量和对该声明的引用

能生成 scope 的 AST 叫做 block,比如 FunctionDeclaration 就是一个 block

scope 中记录着 bindings, 也就是声明,每个声明会记录在哪儿声明的,哪里引用的

  • 实现 scope 的思路: 做法: 遇到 block 节点,创建 scope 的时候,遍历作用域中的所有声明(VariableDeclaration、FunctionDeclaration), 记录该 binding 到 scope 中。

  • 代码如下:

// scope 中记录着 binding, 也就是声明,每个声明会记录在哪儿声明的,哪里引用的
class Binding {
    constructor(id, path) {
        this.id = id;
        this.path = path;
        this.referenced = false; // 判断该声明是否有被引用,并在哪儿被引用
        this.referencePaths = [];
    }
}

// Scope 代表作用域,作用域内会有多个声明和引用全部存储在 binding 中
class Scope {
    constructor(parentScope, path) {
        this.parent = parentScope;
        this.binding = {};
        this.path = path;
      
        // 注册 binding 设置作用域
        path.traverse({
            VariableDeclarator: (childPath) => {
                this.registerBinding(childPath.node.id.name, childPath);
            },
            FunctionDeclaration: (childPath) => {
                this.registerBinding(childPath.node.id.name, childPath);
                childPath.skip();
            }
        })

        path.traverse({
            Identifier: childPath => {
                if (!childPath.findParent(p => p.isVariableDeclarator())) {
                    const id = childPath.node.name;
                    const bindings = this.getBinding(id);
                    if (bindings) { // 找到了对应的作用域
                        bindings.referenced = true; // 该声明有被引用
                        bindings.referencePaths.push(childPath); // 引用的路径
                    }
                }
            }
        })
    }

    registerBinding(id, path) {
        this.binding[id] = new Binding(id, path);
    }

    getOwnBinding(id) {
        return this.binding[id];
    }
    
    getBinding(id) {
        let res = this.getOwnBinding(id);
        if (res === undefined && this.parent) {
            res = this.parent.getOwnBinding(id);
        }
        return res;
    }
    hasBinding(id) {
        return !!this.getBinding(id);
    }
}

module.exports = Scope;

这里我们也可以大概了解了 tree-shaking 的原理,在 AST 遍历的时候,遇到 VariableDeclarator 节点会去存储哪些变量是声明好的,当遇到 Identifier 节点的时候,会找到对应的声明并标记已使用,而未被标记已使用的变量声明将会被移除掉。

实现一个 generator

generator 可以将 AST 转化为代码,并生成source-map。

思路

  • 1 根据不同的 AST 类型输出不同的字符串
  • 2 在输出的过程中记录目标代码的行和列,通过目标行列和源代码的行列关联关系生成source-map
const { SourceMapGenerator } = require('source-map');

// Printer 类打印每种 AST 的打印逻辑
class Printer {
    constructor(source, fileName) {
        this.buf = '';
        this.printLine = 1; // 打印第几行
        this.printColumn = 0; // 第几列

        this.sourceMapGenerator = new SourceMapGenerator({
            file: fileName + ".map.json"
        });

        this.fileName = fileName;
        this.sourceMapGenerator.setSourceContent(fileName, source); // sourcemap 需要指定源文件名
    }
    /**
     * 将源代码转化为 AST 时, 在 AST 中的节点会记录源代码的行/列
     * 
     * 通过 ast 打印成目标代码的时候,会记录相对应的 目标代码的行列
     * 
     * 这样的话就会有目标代码行列和源代码的行列的映射关系
     * @param {*} node
     * @memberof Printer
     */
    addMapping(node) {
        if (node.loc) { // 可以从
            this.sourceMapGenerator.addMapping({
                generated: { // 目标节点和源代码行列的映射
                    line: this.printLine,
                    column: this.printColumn
                },
                source: this.fileName,
                original: node.loc && node.loc.start
            })
        }
    }

    space() {
        this.buf += ' ';
        this.printColumn++;
    }

    nextLine() {
        this.buf += '\n';
        this.printLine++;
        this.printColumn = 0;
    }

    Program(node) {
        this.addMapping(node);
        node.body.forEach(item => {
            this[item.type](item) + ';';
            this.printColumn++;
            this.nextLine();
        });
    }

    ExpressionStatement(node) {
        this.addMapping(node);

        this[node.expression.type](node.expression);

    }

    VariableDeclaration(node) {
        if (!node.declarations.length) {
            return;
        }

        this.addMapping(node);
        
        this.buf += node.kind; // let/const/var
        this.space();
        node.declarations.forEach((declaration, index) => {
            if (index != 0) {
                this.buf += ',';
                this.printColumn++;
            }
            this[declaration.type](declaration);
        });
        this.buf += ';';
        this.printColumn++;
    }

    VariableDeclarator(node) {
        this.addMapping(node);
        this[node.id.type](node.id); // 标识符
        this.buf += '=';
        this.printColumn++;
        this[node.init.type](node.init); // 字面量
    }

    Identifier(node) {
        this.addMapping(node);
        this.buf += node.name;
    }

    FunctionDeclaration(node) {
        this.addMapping(node);

        this.buf += `function ${node.id.name}(${node.params.map(item => item.name).join(',')}){`;
        this.nextLine();
        this[node.body.type](node.body);
        this.buf += '}';
        this.nextLine();
    }

    // 函数调用
    CallExpression(node) {
        this.addMapping(node);
        this[node.callee.type](node.callee);
        this.buf += '(';
        node.arguments.forEach((item, index) => {
            if (index > 0) this.buf += ', ';
            this[item.type](item);
        })
        this.buf += ')';
    }

    // 表达式语句
    ExpressStatement(node) {
        this.addMapping(node);
        this[node.expression.type](node.expression);
    }

    // return 语句
    ReturnStatement(node) {
        this.addMapping(node);
        this.buf += 'return ';
        node.argument && this[node.argument.type](node.argument);
    }

    BlockStatement(node) {
        this.addMapping(node);
        
        node.body.forEach(item => {
            this.buf += '    ';
            this.printColumn += 4;
            this[item.type](item);
            this.nextLine();
        })
    }

    BinaryExpression(node) {
        this.addMapping(node);
        this[node.left.type](node.left);
        this.buf += node.operator;
        this[node.right.type](node.right);
    }

    NumericLiteral(node) {
        this.addMapping(node);
        this.buf += node.value;
    }

    MemberExpression(node) {
        this.addMapping(node);
        this[node.object.type](node.object);
        this.buf += '.';
        this[node.property.type](node.property);
    }
}

class Generator extends Printer {
    constructor(source, fileName) {
        super(source, fileName);
    }

    generate(node) {
        this[node.type](node); // 疯狂递归

        return {
            code: this.buf,
            map: this.sourceMapGenerator.toString(), // 生成 source-map
        }
    }
}

function generate(node, source, fileName) {
    return new Generator(source, fileName).generate(node);
}

module.exports = generate;

实现一个 core 包

@babel/core 是 parser、traverse、generate 三者的集大成者,了解到在 babel 中是先执行 plugin 在执行 preset,plugin 从左到右执行,而 preset 从右到左执行。

const types = require('@babel/types');
const parser = require('../parser');
const traverse = require('../traverse');
const generate = require('../generator');
const template = require('@babel/template').default;

/**
 * 实现一个 transformSync
 * 注意: 
 *   1 先执行 plugin 后执行 preset
 *   2 plugin 从前往后执行
 *   3 preset 从后往前执行
 * @param {*} code 
 * @param {*} options 
 */
function transformSync(code, options) {
    const ast = parser.parse(code, options.parserOpts); // parser 是通过继承的方式拓展

    const pluginAPI = {
        types,
        template
    }

    const visitors = {};

    options.plugins && options.plugins.forEach(([plugin, options]) => {
        const res = plugin(pluginAPI, options);
        Object.assign(visitors, res.visitor); // 简单合并
    });

    options.presets && options.presets.reverse().forEach(([plugin, options]) => {
        const res = plugin(pluginAPI, options);
        Object.assign(visitors, res.visitor);
    });

    traverse(ast, visitors);

    return generate(ast, code, options.fileName);
}

module.exports = {
    transformSync
}

从plugin的实现我们也不难发现,实现一个babel插件其实就是一个函数,这个函数的第一个参数是一系列的工具类,如@babel/types 或者 @babel/template,第二个参数是插件设置的参数。例如先前函数插入参数的例子中,如果有 babel 插件的形式书写如下:

image.png

image.png

总结

通过对 babel 整体编译过程的书写,巩固了对 babel 的理解,我想在未来,babel会结合图像识别、语音识别等技术解放程序员双手,更加提高开发效率。