深入babel原理与实现

404 阅读19分钟

一、babel是什么?

新一代JavaScript编译器(转译器)

babel原名6to5,顾名思义,是将es6转为es5,但随着es7、8、9...诞生,不再适用,改名babel

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

二、babel有什么用?

为什么要用babel?

  • 语法转译:将 esnext、typescript、flow 等转译到目标环境支持的 js
  • 代码静态分析:linter、文档生成、压缩混淆
  • 函数插桩(函数中自动插入一些代码,例如埋点代码)、自动国际化等特定用途代码的转化;小程序转译工具 taro,就是基于 babel 的 api 来实现的

三、babel如何工作?

babel本质是一个JavaScript编译器,所以在介绍babel之前,需要先对编译原理知识做一点了解

编译原理回顾

编译,其实就是把源代码变成目标代码的过程。如果源代码编译后要在操作系统上运行,那目标代码就是汇编代码,我们再通过汇编和链接的过程形成可执行文件,然后通过加载器加载到操作系统里执行。如果编译后是在解释器里执行,那目标代码就可以不是汇编代码,而是一种解释器可以理解的中间形式的代码即可。

熟悉编译原理的同学知道,编译的流程大致可以分为以下的步骤:

image.png

更完整的来讲,每个步骤可以分为下面的工作:

1. 词法分析

源代码只是一长串字符而已。编译的第一步,就是要像读文章一样,先把里面的单词和标点符号识别出来。程序里面的单词叫做 Token,它可以分成关键字、标识符、字面量、操作符号等多个种类。把字符串转换为 Token 的这个过程,就叫做词法分析。

image.png

比如 let name = 'smart'; 这样一段源码,我们要先把它分成一个个不能细分的单词(token),也就是 let, name, =, 'smart'

一个简易的词法分析器的原理大概是这样的(有限自动机):

image.png

2. 语法分析

词法是识别一个个的单词,而语法分析就是在词法分析的基础上识别出程序的语法结构,举个🌰,“我喜欢又聪明又勇敢的你”,可以这样表示:

image.png

我们可以通过遍历词法分析生成的token,当遇到不同的关键字时,依次生成一个树状的语法树,这样的算法叫做递归下降算法,V8编译器也是使用的这个算法。

image.png

语法分析生成的产物叫做抽象语法树AST,对于前端开发者而言,编译流程中接触最多的也是这个结构(后面会进行详细解释):

image.png

3. 语义分析

分析整个句子在上下文中的含义,让计算机真正理解这段代码的用途,比如:在语法分析阶段,对于int b = a + 3这样一条语句,无论 a 是否提前声明过,在语法上都是正确的。而在实际的计算机语言中,如果引用某个变量,这个变量就必须是已经声明过的。同时,当前这行代码,要处于变量 a 的作用域中才行,这就是语义分析中的引用消解,除此之外还有闭包分析控制流检查也都是在语义分析阶段。

image.png

更详细的语义分析解读:语义分析:让程序符合语义规则

这里提供一张思维导图:

image.png

AST

在梳理babel工作流程之前,我们先再了解一下AST(abstract syntax tree),即抽象语法树。

不同的语法分析器有不同的标准,JS parser 的 AST 大多是 estree 标准。我们可以通过AST explorer来实际体验一下。

babel生成的语法树节点主要有这些分类:标识符 Identifer、各种字面量 xxLiteral、各种语句 xxStatement,各种声明语句 xxDeclaration,各种表达式 xxExpression,以及 Class、Modules、File、Program、Directive、Comment。具体可以参考estree 标准 标准。这里对常用的Literal和Declaration做一个示意:

img img

babel工作流程

首先强烈推荐神光的Babel 插件通关秘籍真的学到很多

image.png

babel作为一个js编译器,其工作原理也是遵循编译原理常规流程的,主要包含了代码的解析(parse)、转换(transform)、生成(generate)三个阶段,如下图:

img

相比于一门语言的编译器而言,babel应该只能算是一个转译器,当然转移器也是编译器的一种,只是一般语言编译器做的事往往是把高级语言编译成字节码、机器码等更低级的语言,而且最难的点在于进行语义分析,这点babel涉及不多;而babel更多做的是一个转译工作,往往只需要对语法分析的产物进行修改,再重新生成js代码即可,比如将ts、flow转换为js(或者说,我们通过对babel生成的AST进行分析,算是做了一点语义分析的工作?)。并且babel的语法分析功能也是依赖于开源项目acorn实现,相对于实现一个编译器而言要简单许多。

这三步主要做了以下事情:

  • parse:通过 parser 把源码转成抽象语法树(AST),包含了词法和语法分析

img

  • transform:遍历 AST,调用各种 transform 插件对 AST 进行增删改,开发者接触最多的是这一部分,下面会详细讲这个遍历过程

img

  • generate:把转换后的 AST 打印成目标代码,并生成 sourcemap

img

babel的遍历过程

visitor 模式(访问者模式)是 23 种经典设计模式中的一种。当被操作的对象结构比较稳定,而操作对象的逻辑经常变化的时候,通过分离逻辑和对象结构,使得他们能独立扩展。这就是 visitor 模式的思想。

babel的核心部分,也是我们最常接触到的就是对语法树节点的遍历,当遍历到某个节点时会触发对应的钩子,允许开发者通过开发插件的形式,在每个钩子执行自定义操作,对AST进行修改:

5768a7c151914586ab2a5b09b698b4d7_tplv-k3u1fbpfcp-watermark

每一个遍历到的AST路径在babel中被称为path,path主要有以下属性和方法,不用一一记忆,大概知道作用即可:

  • path.node 当前 AST 节点
  • path.parent 父 AST 节点
  • path.parentPath 父 AST 节点的 path
  • path.scope 作用域
  • get(key) 获取某个属性的 path
  • set(key, node) 设置某个属性的值
  • getSibling(key) 获取某个下标的兄弟节点
  • getNextSibling() 获取下一个兄弟节点
  • getPrevSibling() 获取上一个兄弟节点
  • getAllPrevSiblings() 获取之前的所有兄弟节点
  • getAllNextSiblings() 获取之后的所有兄弟节点
  • find(callback) 从当前节点到根节点来查找节点(包括当前节点),调用 callback(传入 path)来决定是否终止查找
  • findParent(callback) 从当前节点到根节点来查找节点(不包括当前节点),调用 callback(传入 path)来决定是否终止查找
  • isXxx(opts) 判断当前节点是否是某个类型,可以传入属性和属性值进一步判断,比如path.isIdentifier({name: 'a'})
  • assertXxx(opts) 同 isXxx,但是不返回布尔值,而是抛出异常
  • insertBefore(nodes) 在之前插入节点,可以是单个节点或者节点数组
  • insertAfter(nodes) 在之后插入节点,可以是单个节点或者节点数组
  • replaceWith(replacement) 用某个节点替换当前节点
  • replaceWithMultiple(nodes) 用多个节点替换当前节点
  • replaceWithSourceString(replacement) 解析源码成 AST,然后替换当前节点
  • remove() 删除当前节点
  • traverse(visitor, state) 遍历当前节点的子节点,传入 visitor 和 state(state 是不同节点间传递数据的方式)
  • skip() 跳过当前节点的子节点的遍历
  • stop() 结束所有遍历

插件和presets

babel 是微内核架构,就是因为核心只实现了编译流程,具体的转换功能都是通过插件来实现。

babel提供了基础的解析、遍历、转换,以及大量的工具API,但是我们要实现对代码的修改,需要依赖于插件,这是babel应用层面的核心要素,也是开发者接触最多的地方。

babel官网提供了一个插件开发的示例代码

export default function() {
 return {
   visitor: {
     Identifier(path) {
       const name = path.node.name;
       // reverse the name: JavaScript -> tpircSavaJ
       path.node.name = name
        .split("")
        .reverse()
        .join("");
    },
  },
};
}

我们可以通过一个通过babel插件来实现自动埋点的例子来简单体验一下。

假如我们希望在每个方法执行时都进行一个埋点(sourceCode.js):

function a () {
   // 插入埋点代码
   console.log('aaa');
}
​
class B {
   bb() {
       // 插入埋点代码
       return 'bbb';
  }
}
​
// 转换为{}包裹并插入埋点代码
const c = () => 'ccc';
​
const d = function () {
   // 插入埋点代码
   console.log('ddd');
}

可以定义一个auto-track-plugin,其中的具体逻辑我写了详细的注释:

const { declare } = require('@babel/helper-plugin-utils')
const importModule = require('@babel/helper-module-imports')
​
const autoTrackPlugin = declare((api, options, dirname) => {
 // 版本检查
 api.assertVersion(7)
 return {
   visitor: {
     Program: { // 程序进入时
       enter(path, state) {
         path.traverse({
           // 遍历导入声明
           ImportDeclaration(curPath) {
             const requirePath = curPath.get('source').node.value
             // 如果已经导入了目标代码,就记录 id 到 state
             // 这个id其实就是方法名: import track from 'tracker' 中的track
             // 但是导出方式分为default import 和 namespace import 取 id 的方式不一样,需要分别处理下
             if (requirePath === options.trackerPath) {
               const specifierPath = curPath.get('specifiers.0')
               if (specifierPath.isImportSpecifier()) {
                 state.trackerImportId = specifierPath.toString()
              } else if (specifierPath.isImportNamespaceSpecifier()) {
                 state.trackerImportId = specifierPath.get('local').toString()
              }
               // 不再需要继续处理
               path.stop()
            }
          }
        })
         // 未导入过目标代码
         if (!state.trackerImportId) {
           // 添加导入语句, 假设需要导入的埋点包为tracker
           state.trackerImportId = importModule.addDefault(path, 'tracker', {
             nameHint: path.scope.generateUid('tracker')
          }).name
           // 生成埋点调用AST
           state.trackerAST = api.template.statement(`${state.trackerImportId}()`)()
        }
      }
    },
     // 当遍历访问到 类方法,箭头函数表达式,函数表达式,方法声明时执行, 分别对应sourceCode中的几种方法声明
     'ClassMethod|ArrowFunctionExpression|FunctionExpression|FunctionDeclaration'(path, state) {
       const bodyPath = path.get('body')
       if (bodyPath.isBlockStatement()) { // 如果已经是块级语句的,即{}包裹,直接在函数体插入目标代码AST
         bodyPath.node.body.unshift(state.trackerAST)
      } else {
         // 使用template.statement方法生成AST,需要处理函数体包裹, trackerImportId就是上面记录的方法名
         const ast = api.template.statement(`{${state.trackerImportId}();return PREV_BODY;}`)({ PREV_BODY: bodyPath.node })
         bodyPath.replaceWith(ast)
      }
    }
  }
}
})
module.exports = autoTrackPlugin

最终执行

const { transformFromAstSync } = require('@babel/core');
const  parser = require('@babel/parser');
const autoTrackPlugin = require('./plugin/auto-track-plugin');
const fs = require('fs');
const path = require('path');
​
const sourceCode = fs.readFileSync(path.join(__dirname, './sourceCode.js'), {
   encoding: 'utf-8'
});
​
const ast = parser.parse(sourceCode, {
   sourceType: 'unambiguous'
});
​
const { code } = transformFromAstSync(ast, sourceCode, {
   plugins: [[autoTrackPlugin, {
       trackerPath: 'tracker' // 传入需要导入的埋点库路径
  }]]
});
​
console.log(code);

可以看到打印出了预期代码(导入方法作了转译,可以暂时忽略):

var _tracker2 = _interopRequireDefault(require("tracker")).default;
​
function _interopRequireDefault(obj) { _tracker2(); _tracker2(); return obj && obj.__esModule ? obj : { default: obj }; }
​
function a() {
 _tracker2();
 console.log('aaa');
}
​
class B {
 bb() {
   _tracker2();
   return 'bbb';
}
}
​

presets则是多个插件的预设,一个预设一般对应多个插件,babel会读取预设里面的插件配置依次应用。

112d501d641b4e509bd37d821489d72c_tplv-k3u1fbpfcp-watermark

比如Vue中的presets: @vue/cli-plugin-babel/presets @vue/babel-preset-app at dev · vuejs/vue-cli (github.com)

四、实现一个简易babel

思路分析

前面讲到,babel主要做了parse,transform,和generate三个工作,transform主要依赖于遍历AST节点(tranverse),所以我们主要要实现一个用于代码解析器,一个遍历器,和一个代码生成器。

parser(简单)

babel并未完全自己实现一个JavaScript解析器,而是通过扩展Acorn的方式,他们的关系如下图所示

upgit_20220306_1646556712

我们可以这样扩展

module.exports = function(Parser) {
   return class extends Parser {
       parseLiteral (...args) {
           const node = super.parseLiteral(...args);
           switch(typeof node.value) {
               case 'number':
                   node.type = 'NumericLiteral';
                   break;
               case 'string':
                   node.type = 'StringLiteral';
                   break;
          }
           return  node;
      }
  }
}

在使用parser进行代码解析时,我们希望实现下面的API使用方式,根据传入的 plugins 来确定使用什么插件,然后返回扩展以后的 parser

const ast = parser.parse(sourceCode, {
   plugins: ['auto-track-plugin', ...]
});

具体实现时,可以先把plugins放到map进行维护,然后再结合传入的plugins进行parser的扩展

const syntaxPlugins = {
   'auto-track-plugin'require('./plugins/auto-track-plugin'),
   ...
}
​
const defaultOptions = {
   plugins: []
}
​
function parse(code, options) {
   const resolvedOptions  = Object.assign({}, defaultOptions, options);
// 通过reduce方法,不断循环,通过Parser.extend(plugin)来扩展parser
   const newParser = resolvedOptions.plugins.reduce((Parser, pluginName) => {
       let plugin = syntaxPlugins[pluginName]
       return plugin ? Parser.extend(plugin) : Parser; 
  }, acorn.Parser);
​
   return newParser.parse(code, {
       locationstrue // 保留 AST 在源码中的位置信息,这个在生成 sourcemap 的时候会用
  });
}

tranverse(困难)

参考babel的使用方式,假设遍历AST时,我们需要提供这样的API:

traverse(ast, {
    Identifier(node) {
        node.name = 'hello';
    }
});

也就是说,需要提供一个traverse方法,其实就是对语法树进行深度优先遍历,但是在遍历的同时,我们需要根据该节点的类型,执行traverse函数传入的第二个参数对应的属性方法,比如遍历到Identifier,那就需要执行Identifier方法,同时将node作为参数传入。

首先,需要知道哪些属性可以遍历,也就是上面的Identifier等属性,可以通过一个map来维护:

const astDefinationsMap = new Map();

astDefinationsMap.set('Program', {
    visitor: ['body']
});
astDefinationsMap.set('VariableDeclaration', {
    visitor: ['declarations']
});
astDefinationsMap.set('VariableDeclarator', {
    visitor: ['id', 'init']
});
astDefinationsMap.set('Identifier', {});
astDefinationsMap.set('NumericLiteral', {});
astDefinationsMap.set('FunctionDeclaration', {
    visitor: ['id', 'params', 'body']
});
astDefinationsMap.set('BlockStatement', {
    visitor: ['body']
});
astDefinationsMap.set('ReturnStatement', {
    visitor: ['argument']
});
astDefinationsMap.set('BinaryExpression', {
    visitor: ['left', 'right']
});
astDefinationsMap.set('ExpressionStatement', {
    visitor: ['expression']
});
astDefinationsMap.set('CallExpression', {
    visitor: ['callee', 'arguments']
});

然后实现遍历:

// 调用方式
traverse(ast, {
    Identifier(node) {
        node.name = 'hello';
    }
});

function traverse(node, visitors) {
    // node.type就是astDefinationsMap的key,如Program,VariableDeclaration等
    const defination = astDefinationsMap.get(node.type);
		// visitors[node.type]就是插件里定义的Identifier,VariableDeclarator等方法
    let visitorFuncs = visitors[node.type] || {};
		// 扩展支持第二个参数可以传入function或者对象
    if(typeof visitorFuncs === 'function') {
        visitorFuncs = {
            enter: visitorFuncs
        }
    }
    // 调用插件定义的方法进行处理
    visitorFuncs.enter && visitorFuncs.enter(node);
    // 递归遍历子节点
    if (defination.visitor) {
        defination.visitor.forEach(key => {
            const prop = node[key];
            if (Array.isArray(prop)) { 
                prop.forEach(childNode => {
                    traverse(childNode, visitors);
                })
            } else {
                traverse(prop, visitors);
            }
        })
    }
    // 调用插件定义的方法进行处理
    visitorFuncs.exit && visitorFuncs.exit(node);
}

这里我们支持了enter和exit两个钩子,方便开发者在节点操作前后进行编码,也就是说还可以这样调用:

traverse(ast, {
    Identifier: {
        enter(node)() {
      	
    		},
  			exit(node)() {
      	
    		},
    }
});

至此,我们实现了AST的遍历,但是回顾babel的使用,我们在插件编写时,还经常使用到babel提供的API,尤其是前面提到的path相关的API,下面就来实现path的API。

先做一下思量分析:path 是节点之间的关联,每一个 path 记录了当前节点和父节点,并且 path 和 path 之间也有关联

5883d27d78054a72a9c650023b2ba481_tplv-k3u1fbpfcp-watermark

通过 path 我们可以找到父节点、父节点的父节点,一直到根节点。

path 的实现就是在 traverse 的时候创建一个对象来保存当前节点和父节点,并且能够拿到节点也就能对节点进行操作,可以基于节点来提供一系列增删改的 api。

首先我们创建一个表示path的类,记录当前节点 node,父节点 parent 以及父节点的 path

class NodePath {
    constructor(node, parent, parentPath) {
        this.node = node;
        this.parent = parent;
        this.parentPath = parentPath;
    }
}

然后在traverse递归遍历的时候创建 path 对象,传入 visitor

function traverse(node, visitors, parent, parentPath) {
    const defination = astDefinationsMap.get(node.type);

    let visitorFuncs = visitors[node.type] || {};

    if(typeof visitorFuncs === 'function') {
        visitorFuncs = {
            enter: visitorFuncs
        }
    }
    // 创建path对象
    const path = new NodePath(node, parent, parentPath);
		// 将enter参数从node改为path
    visitorFuncs.enter && visitorFuncs.enter(path);

    if (defination.visitor) {
        defination.visitor.forEach(key => {
            const prop = node[key];
            if (Array.isArray(prop)) { // 如果该属性是数组
                prop.forEach(childNode => {
                    traverse(childNode, visitors, node, path);
                })
            } else {
                traverse(prop, visitors, node, path);
            }
        })
    }
    visitorFuncs.exit && visitorFuncs.exit(path);
}

然后,我们就可以在自定义的visotor 里从path拿到path了:

traverse(ast, {
    Identifier: {
      	// node变为了path
        exit(path) {
            path.node.name = 'smart';
            let curPath = path;
            // 举个应用的🌰,这里可以循环获取到根path
            while (curPath) {
                console.log(curPath.node.type);
                curPath = curPath.parentPath;
            }
        }
    }
});

接下来是实现 api,path 的 api 就是对 AST 的增删改,我们可以实现 replaceWith、remove、findParent、find、traverse、skip 等 api,实现方式都大同小异,这里以replaceWith举例。

replaceWith,就是在父节点替换当前节点为另一个节点,伪代码逻辑是

parent[currentNodeIndex] = newNode

这里有一个难点,就是我们父节点怎么知道当前节点是他的哪个节点,也就是说我们怎么让父节点知道(找到)当前要替换的节点,也就是说怎么确定这个currentNodeIndex?babel通过了两个属性来记录节点在父节点中的位置:key和listKey

class NodePath {
    constructor(node, parent, parentPath, key, listKey) {
        this.node = node;
        this.parent = parent;
        this.parentPath = parentPath;
        this.key = key;
        this.listKey = listKey;
    }
}

这里需要补充一下,为什么需要有一个listKey,因为 AST 节点要挂在父 AST 节点的某个属性上,那个属性的属性值就是这个 AST 节点的 container

4ce55f6c749d4e35ad6460de6f5be71a_tplv-k3u1fbpfcp-watermark

而 BlockStatement 有 body 属性,是一个数组,对于数组中的每一个 AST 来说,这个数组就是它们的 container,而 listKey 是 body,key 则是下标

b48a0cdddb4344d1b9343e04818460ad_tplv-k3u1fbpfcp-watermark

在遍历时做记录:

module.exports = function traverse(node, visitors, parent, parentPath, key, listKey) {

    const defination = visitorKeys.get(node.type);
    let visitorFuncs = visitors[node.type] || {};

    if(typeof visitorFuncs === 'function') {
        visitorFuncs = {
            enter: visitorFuncs
        }
    }
  	// 多传入了key, listKey
    const path = new NodePath(node, parent, parentPath, key, listKey);
    visitorFuncs.enter && visitorFuncs.enter(path);

    if (defination.visitor) {
        defination.visitor.forEach(key => {
            const prop = node[key];
            if (Array.isArray(prop)) { // 如果该属性是数组
                prop.forEach((childNode, index) => { // 遍历每一个数组项
                  	// 多传入了key, listKey = index
                    traverse(childNode, visitors, node, path, key, index);
                })
            } else {
              	// 多传入了key
                traverse(prop, visitors, node, path, key);
            }
        })
    }
    visitorFuncs.exit && visitorFuncs.exit(path);
}

这样我们就能实现replaceWith:

replaceWith(node) {
    if (this.listKey != undefined) {
      	// 如果当前节点属于父节点的属性是数组类型
        this.parent[this.key].splice(this.listKey, 1, node);
    } else {
      	// 如果当前节点属于父节点的属性是非数组类型
        this.parent[this.key] = node
    }
}

其他API可参考:babel-plugin-exercize (github.com)

generator(简单)

generate就是将处理后的AST重新生成目标代码的过程,核心原理就是遍历AST,将每个节点转换为代码字符串,依次拼接打印。

比如我们要打印一个while循环语句,先打印 while、再打印空格,再打印 ( ,然后打印 test 部分,之后打印 ),最后打印 block 部分。

同时需要处理sourcemap,sourcemap记录源码位置和目标代码位置的关联,在打印的记录下当前打印的行列,就是目标代码位置,而源码位置 parse 的时候就有了,这样就生成了一个 mapping:

5b0045e0f063488da7ea2b30922160b9_tplv-k3u1fbpfcp-watermark

定义一个Printer类来处理打印逻辑,在打印的时候记录了 printLine、printColumn 的信息,也就是当前打印到了第几行,这样在 addMapping 里面就可以拿到 AST 在目标代码中的位置,而源码位置是在 parse 的时候记录到 loc 属性的,有了这两个位置就可以生成一个 mapping。

sourcemap 的生成是使用 source-map 包,这个 mozilla 维护的,因为 sourcemap 的标准也是他们提出来的:

const { SourceMapGenerator } = require('source-map');
class Printer {
    constructor (source, fileName) {
        this.buf = ''; // 存放生成的代码字符串
        this.sourceMapGenerator = new SourceMapGenerator({
            file: fileName + ".map.json",
        });
        this.fileName = fileName;
        this.sourceMapGenerator.setSourceContent(fileName, source);
        this.printLine = 1;
        this.printColumn = 0;
    }

    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();
        });
    }

    VariableDeclaration(node) {
        if(!node.declarations.length) {
            return;
        }
        this.addMapping(node);
        this.buf += node.kind;
        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 ';
        this.buf += node.id.name;
        this.buf += '(';
        this.buf += node.params.map(item => item.name).join(',');
        this.buf += '){';
        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 += ')';
    }
    ExpressionStatement(node) {
        this.addMapping(node);
        this[node.expression.type](node.expression);

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

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

有了这个Printer以后,就可以扩展实现一个生成器Generator:

class Generator extends Printer{
    constructor(source, fileName) {
        super(source, fileName);
    }
    generate(node) {
      	// 第一次node.type是Program
        this[node.type](node);
        return {
            code: this.buf,
            map: this.sourceMapGenerator.toString()
        }
    }
}

然后暴露出 generate 的 api:

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

core

core 包的功能是串联整个编译流程,并且实现插件和 preset

前面,我们实现了 parser、traverse、generator 包,使用方式是这样的:

分别调用 parse、traverse、generate,来完成源码的 parse、AST 的遍历和修改,以及目标代码和 sourcemap 的打印

而如果用了 core 包,使用方式是这样的:

function plugin1(api, options) {
    return {
        visitor: {
            Program(path) {
               // ...
            }
    }
}
const { code, map } = transformSync(sourceCode, {
    parserOpts: {
        plugins: ['literal']
    },
    fileName: 'foo.js',
    plugins: [
        [plugin1, {}]
    ],
    presets: []
});

transformSync 封装了 parse、traverse、generate 的逻辑,并且还实现了插件和 preset 机制,其实只需要简单的逻辑就可以实现封装:

function transformSync(code, options) {
    // 1.parse
    const ast = parser.parse(code, options.parserOpts);

    const pluginApi = {
        template
    }
    const visitors = {};
    // 2.处理plugins和presets
    // 遍历options的plugin,并添加到visitors内
    options.plugins && options.plugins.forEach(([plugin, options]) => {
        const res = plugin(pluginApi, options);
        Object.assign(visitors, res.visitor);
    });
    // 如果有presets,则取出presets的plugin,添加到visitors内
    // preset 是插件的集合,所以要多调用一层,并且因为顺序是从右往左,所以需要reverse一下
    options.presets && options.presets.reverse().forEach(([preset, options]) => {
        const plugins = preset(pluginApi, options);
        plugins.forEach(([plugin, options])=> {
            const res = plugin(pluginApi, options);
            Object.assign(visitors, res.visitor);
        })
    })

    traverse(ast, visitors);
    // 3.生成代码
    return generate(ast, code, options.fileName);
}

cli

cli的实现思想比较简单,就是实现一个命令,调用babel进行代码转译,这里借用commander进行实现,直接贴出代码,关键注释已经补充:

#!/usr/bin/env node
const commander = require('commander')
const { cosmiconfigSync } = require('cosmiconfig')
const glob = require('glob')
const myBabel = require('../core')
const fsPromises = require('fs').promises
const path = require('path')

commander.option('--out-dir <outDir>', '输出目录')
commander.option('--watch', '监听文件变动')

if (process.argv.length <= 2) {
  commander.outputHelp()
  process.exit(0)
}

commander.parse(process.argv)
const cliOpts = commander.opts()

if (!commander.args[0]) {
  console.error('没有指定待编译文件')
  commander.outputHelp()
  process.exit(1)
}

if (!cliOpts.outDir) {
  console.error('没有指定输出目录')
  commander.outputHelp()
  process.exit(1)
}
// 使用chokidar实现代码自动监听,变化后重新编译
if (cliOpts.watch) {
  const chokidar = require('chokidar')

  chokidar.watch(commander.args[0]).on('all', (event, path) => {
    console.log('检测到文件变动,编译:' + path)
    compile([path])
  })
}

// 使用glob实现文件名模糊匹配
const filenames = glob.sync(commander.args[0])

// 配置文件的指定使用 cosmiconfig,它支持如下的查找方式:
// package.json 的属性
// 扩展名为 rc 的 JSON 或者 YAML
// 扩展名为 .json、 .yaml、 .yml、 .js、.cjs 、.config.js、.config.cjs 的 rc 文件
// .config.js 或者 .config.cjs 的 commonjs 模块
// 这种配置文件查找机制在 eslint、babel 等很多工具中都有应用,我们也采用这种方式。
// 这里其实就是查找自定义babel的配置myBabel.config.js
const explorerSync = cosmiconfigSync('myBabel')
const searchResult = explorerSync.search()

const options = {
  babelOptions: searchResult.config,
  cliOptions: {
    ...cliOpts,
    filenames
  }
}

function compile(fileNames) {
  fileNames.forEach(async filename => {
    // 读取文件内容
    const fileContent = await fsPromises.readFile(filename, 'utf-8')
    const baseFileName = path.basename(filename)
    const sourceMapFileName = baseFileName + '.map.json'
    // 调用babel进行转译
    const res = myBabel.transformSync(fileContent, {
      ...options.babelOptions,
      fileName: baseFileName
    })
    // 后续生成代码和写入文件
    const generatedFile = res.code + '\n' + '//# sourceMappingURL=' + sourceMapFileName

    const distFilePath = path.join(options.cliOptions.outDir, baseFileName)
    const distSourceMapPath = path.join(options.cliOptions.outDir, baseFileName + '.map.json')

    try {
      await fsPromises.access(options.cliOptions.outDir)
    } catch (e) {
      await fsPromises.mkdir(options.cliOptions.outDir)
    }
    await fsPromises.writeFile(distFilePath, generatedFile)
    await fsPromises.writeFile(distSourceMapPath, res.map)
  })
}

compile(options.cliOptions.filenames)

五、vue中的编译器

vue template compiler

对于前端开发者而言,基本不存在需要对一门语言作语法分析的场景,经常遇到的场景是定义DSL,做一些自定义的语法。比如Vue的模板编译器,这里可以通过《Vuejs 设计与实现》中模板编译器的实现原理来大致看一下

code-for-vue-3-book编译器

vue与babel

参考Vue Loader官方文档 如果我们要把一个.vue文件进行打包,那么一个比较完整的webpack配置大概是这样的:

// webpack.config.js
const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
  mode: 'development',
  module: {
    rules: [
      {
        test: /.vue$/,
        loader: 'vue-loader'
      },
      // 它会应用到普通的 `.js` 文件
      // 以及 `.vue` 文件中的 `<script>` 块
      {
        test: /.js$/,
        loader: 'babel-loader'
      },
      // 它会应用到普通的 `.css` 文件
      // 以及 `.vue` 文件中的 `<style>` 块
      {
        test: /.css$/,
        use: [
          'vue-style-loader',
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    // 请确保引入这个插件来施展魔法
    new VueLoaderPlugin()
  ]
}

先回顾一下webpack的执行逻辑,首先,根据webpack打包机制,会从入口文件开始(默认src/index),根据rules中的正则匹配到对应的文件,通过loaderRunner调用对应loader包导出的loader方法,依次进行处理。而babel-loader则是连接babel-plugin的桥梁。

babel-loader的实现比较简单:

function loader(_x, _x2, _x3) {
  return _loader.apply(this, arguments);
}

function _loader() {
  ...
  // 获取babel配置
  const config = yield loadPartialConfigAsync(injectCaller(programmaticOptions, this.target));
	...
  if (config.files) {
    // 这里的files就包含了.babel.json文件,这里的addDependency实质上是调用webpack家族的loaderRunner的addDependency方法将这些配置添加到文件依赖fileDependencies中后续被调用
    config.files.forEach(configFile => this.addDependency(configFile));
  }
  ...
}

当然,.babel.json只是一种配置方式,其他的方式同理。

在babel执行过程中,则会获取上述文件依赖中的配置(代码路径:@babel/core/lib/config/files/configuration.js):

const RELATIVE_CONFIG_FILENAMES = [".babelrc", ".babelrc.js", ".babelrc.cjs", ".babelrc.mjs", ".babelrc.json"];
...
config = yield* loadOneConfig(RELATIVE_CONFIG_FILENAMES, loc, envName, caller, ((_packageData$pkg = packageData.pkg) == null ? void 0 : _packageData$pkg.dirname) === loc ? packageToBabelConfig(packageData.pkg) : null);
...

后续再将这些配置依次加到babel执行流程中去(对于babel如何执行plugin就不再细说,在实现一个简易babel过程中,可以看出其基本原理)。

显然,一个vue项目中的.js文件肯定是会被babel-loader处理。但这里有一个疑问,针对上面这个配置,babel-loader只会匹配到.js文件,那.vue编译生成的js代码是如何也能被babel-loader所处理的呢?

翻看vue-loader的package.json,我们发现它默认依赖了babel-loader。其次,官方文档上注明了,要使用vue-loader必须引入VueLoaderPlugin,在VueLoaderPlugin的apply方法内部 vue-loader/pluginWebpack5.ts,我发现了以下一段注释:

// for each rule that matches plain .js files, also create a clone and
// match it against the compiled template code inside *.vue files, so that
// compiled vue render functions receive the same treatment as user code
// (mostly babel)
const jsRulesForRenderFn = rules
    .filter((r) => r !== rawVueRule &&
    (match(r, 'test.js').length > 0 || match(r, 'test.ts').length > 0))
    .map((rawRule) => cloneRule(rawRule, refs, jsRuleCheck, jsRuleResource));

很明显,在vue-loader内部,会拿到babel-loader。

再来参考一下vue-loader的说明

image.png 可以看出,vue会添加一些loader到import query,从而webpack打包时会使用对应的loader处理

相关资料:

Babel 插件通关秘籍(强烈推荐)

vuejs设计与实现(强烈推荐)

Vue Loader官方文档

编译原理之美

编译原理实战课

手写webpack核心原理,再也不怕面试官问我webpack原理