一、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之前,需要先对编译原理知识做一点了解
编译原理回顾
编译,其实就是把源代码变成目标代码的过程。如果源代码编译后要在操作系统上运行,那目标代码就是汇编代码,我们再通过汇编和链接的过程形成可执行文件,然后通过加载器加载到操作系统里执行。如果编译后是在解释器里执行,那目标代码就可以不是汇编代码,而是一种解释器可以理解的中间形式的代码即可。
熟悉编译原理的同学知道,编译的流程大致可以分为以下的步骤:
更完整的来讲,每个步骤可以分为下面的工作:
1. 词法分析
源代码只是一长串字符而已。编译的第一步,就是要像读文章一样,先把里面的单词和标点符号识别出来。程序里面的单词叫做 Token,它可以分成关键字、标识符、字面量、操作符号等多个种类。把字符串转换为 Token 的这个过程,就叫做词法分析。
比如 let name = 'smart'; 这样一段源码,我们要先把它分成一个个不能细分的单词(token),也就是 let, name, =, 'smart'
一个简易的词法分析器的原理大概是这样的(有限自动机):
2. 语法分析
词法是识别一个个的单词,而语法分析就是在词法分析的基础上识别出程序的语法结构,举个🌰,“我喜欢又聪明又勇敢的你”,可以这样表示:
我们可以通过遍历词法分析生成的token,当遇到不同的关键字时,依次生成一个树状的语法树,这样的算法叫做递归下降算法,V8编译器也是使用的这个算法。
语法分析生成的产物叫做抽象语法树AST,对于前端开发者而言,编译流程中接触最多的也是这个结构(后面会进行详细解释):
3. 语义分析
分析整个句子在上下文中的含义,让计算机真正理解这段代码的用途,比如:在语法分析阶段,对于int b = a + 3这样一条语句,无论 a 是否提前声明过,在语法上都是正确的。而在实际的计算机语言中,如果引用某个变量,这个变量就必须是已经声明过的。同时,当前这行代码,要处于变量 a 的作用域中才行,这就是语义分析中的引用消解,除此之外还有闭包分析、控制流检查也都是在语义分析阶段。
更详细的语义分析解读:语义分析:让程序符合语义规则
这里提供一张思维导图:
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做一个示意:
babel工作流程
首先强烈推荐神光的Babel 插件通关秘籍真的学到很多
babel作为一个js编译器,其工作原理也是遵循编译原理常规流程的,主要包含了代码的解析(parse)、转换(transform)、生成(generate)三个阶段,如下图:
相比于一门语言的编译器而言,babel应该只能算是一个转译器,当然转移器也是编译器的一种,只是一般语言编译器做的事往往是把高级语言编译成字节码、机器码等更低级的语言,而且最难的点在于进行语义分析,这点babel涉及不多;而babel更多做的是一个转译工作,往往只需要对语法分析的产物进行修改,再重新生成js代码即可,比如将ts、flow转换为js(或者说,我们通过对babel生成的AST进行分析,算是做了一点语义分析的工作?)。并且babel的语法分析功能也是依赖于开源项目acorn实现,相对于实现一个编译器而言要简单许多。
这三步主要做了以下事情:
- parse:通过 parser 把源码转成抽象语法树(AST),包含了词法和语法分析
- transform:遍历 AST,调用各种 transform 插件对 AST 进行增删改,开发者接触最多的是这一部分,下面会详细讲这个遍历过程
- generate:把转换后的 AST 打印成目标代码,并生成 sourcemap
babel的遍历过程
visitor 模式(访问者模式)是 23 种经典设计模式中的一种。当被操作的对象结构比较稳定,而操作对象的逻辑经常变化的时候,通过分离逻辑和对象结构,使得他们能独立扩展。这就是 visitor 模式的思想。
babel的核心部分,也是我们最常接触到的就是对语法树节点的遍历,当遍历到某个节点时会触发对应的钩子,允许开发者通过开发插件的形式,在每个钩子执行自定义操作,对AST进行修改:
每一个遍历到的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会读取预设里面的插件配置依次应用。
比如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的方式,他们的关系如下图所示
我们可以这样扩展
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, {
locations: true // 保留 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 之间也有关联
通过 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
而 BlockStatement 有 body 属性,是一个数组,对于数组中的每一个 AST 来说,这个数组就是它们的 container,而 listKey 是 body,key 则是下标
在遍历时做记录:
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:
定义一个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 设计与实现》中模板编译器的实现原理来大致看一下
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的说明
可以看出,vue会添加一些loader到import query,从而webpack打包时会使用对应的loader处理
相关资料:
Babel 插件通关秘籍(强烈推荐)
vuejs设计与实现(强烈推荐)