提及编译原理,可能咱们脑海中首先浮现的就是 “编译器” 这个词汇。维基百科上对编译器的定义是:编译器是一种计算机程序,它会将某种编程语言写成的源代码(原始语言)转换成另外一种编程语言(目标语言)。 一般一个编译器的编译过程会通过词法分析、语法分析、语义分析、生成中间代码、优化、生成目标代码这几个阶段。若是将其简要归纳,则只包含 解析 ( parse ) 、转换 ( transform ) 、生成 ( generate ) 这三个阶段。node
若是想要了解一个简单的编译器是如何实现的,能够看看 The Super Tiny Compiler 。git
既然讲到了编译器 ( compiler ) ,就不得不提与它概念十分相近的转译器 ( transpiler ) 。转译器实际上是一种特殊的编译器,它用于将一种语言编写的源代码转换为另外一种具备相同抽象层次的语言。 例如,可以将 TypeScript 转换为 JavaScript 的 tsc 转译器以及可以将 ES6+ 转换为 ES5 的 Babel 转译器。从这里咱们也能够看出编译器与转译器最大的区别就在于编译器是将高级语言转换为低级语言(例如汇编语言、机器语言),转译器则是相同抽象层次间的语言转换。github
Ruby 之父松本行弘在《代码的将来》一书中对领域特定语言 ( Domain Specific Language ) 有着这样的解释:chrome
所谓 DSL ,是指利用为特定领域 ( Domain ) 所专门设计的词汇和语法,简化程序设计过程,提升生产效率的技术,同时也让非编程领域专家的人直接描述逻辑成为可能。DSL 的优势是,能够直接使用其对象领域中的概念,集中描述 “想要作到什么” ( What ) 的部分,而没必要对 “如何作到” ( How ) 进行描述。express
DSL 这个概念最先实际上是由 Martin Fowler 提出,他把 DSL 分为内部 DSL 和外部 DSL ,而实现外部 DSL 的理论基础就是编译原理。咱们知道若是将计算机编程语言按抽象层次划分能够分为高级语言、汇编语言以及机器语言。DSL 则是基于高级语言之上的抽象层次。上文提到的 TypeScript ,ES6+ 以及 React 中的 JSX 、Vue 中的 Template 、基于 Node.js 的模版引擎 ejs / jade / nunjucks 等等,从某种层面上来说,它们均可以被叫作 DSL 。npm
若是想要具体了解 DSL 是什么,能够看看这篇文章。编程
在讲了这么多概念以后,相信读者很容易就能够理解什么是 Babel 插件。从上文中咱们能够知道 Babel 其实就是一个转译器,它会将 ES6+ 语法的代码解析为 AST ,经过对 AST 中节点的增长、删除和更改将其转换为符合 ES5 规范的 AST ,最终再将转换后的 AST 翻译为 ES5 代码。下图展现了这个过程:babel
Babel 的主要做用是 ES6+ 转 ES5 ,但若只有这一个功能,确定不可以知足开发者的需求。而 Babel 插件机制则可以让开发者涉足转换 ( transform ) 阶段,经过 Babel 提供的相关 API 操纵 AST ,并将原始代码转换为咱们想要的目标代码。异步
想要编写一个可用的 Babel 插件,是须要不少前置知识的。首先咱们得理解基于 ESTree 的 AST 语法规范,经过 AST Explorer 咱们能够实时查看某段代码生成的 AST ,对不一样类型的节点对象有更加深入的认识。在理解 AST 其实就是用来描述代码的一种抽象形式后,咱们还须要学习如何对 Babel 生成的 AST 进行增长、删除和更改。在这里推荐 Babel Plugin Handbook ,里面完整地讲解了如何去写一个 Babel 插件,细读两遍以后写一个简单的 Babel 插件基本不在话下。在编写 Babel 插件时,咱们经常会用到如下几个 npm 包:async
在这里咱们以实现一个简单的函数性能分析工具为例,最终完成一个可以收集函数名、函数耗时以及函数对应行列号的 Babel 插件。它的基本原理其实就是在 Babel 遍历 AST 时,经过对 AST 节点的增长、删除和更改,在每一个有效函数的首尾插入咱们的打点代码,以后咱们还会收集函数名和函数对应的行列号,最后当代码运行时再收集函数耗时的相关数据。下图展现了与实现该 Babel 插件相关的整个流程:
能够看到想要实现整个功能实际上是有如下几个难点的:
在开始讲解该 Babel 插件的实现以前,请读者确保已经对 Babel 下的 AST 规范十分熟悉,而且已经通读过 Babel Plugin Handbook 。完成这两个步骤后,就让咱们来直接看代码吧。
module.exports = ({ types: t }) => {
return {
visitor: {
Function(path) {
if (isEmptyFunction(path) || isTraversalFunction(path)) {
return
}
var _tid = path.scope.generateUidIdentifier('tid')
var uid = getUid()
// 以查询取代变量
var query = { t, uid, _tid }
isAsyncFunction(path) ? asyncTransform(path, query) : syncTransform(path, query)
path.traverse(returnStatementVisitor, { path, query })
}
}
}
}
复制代码
从上面的代码不难看出,编写 Babel 插件的入口其实就是一个返回访问者对象的函数,该函数为咱们提供了 @babel/types 中的 types 对象,这对操纵 AST 十分有用。经过访问器模式和迭代器模式,Babel 可以遍历每一个特定类型的 AST 节点以及相应的路径,开发者只需在 Babel 暴露的函数中编写操纵特定 AST 节点的代码便可。
所以,这段代码的大概意思就是每当遇到一个函数,首先判断这个函数是否为空或者是像 map
、forEach
、reduce
这样的遍历函数,若是知足以上条件就直接跳过,不插入打点代码。而后咱们会建立 _tid
变量(后文会讲到)以及 uid
做为该函数的惟一标识符。以后咱们会判断该函数是同步函数仍是异步函数,进而执行不一样的转换 ( transform ) 操做。最后就是处理函数中特有的 return
语句,在这里咱们经过 returnStatementVisitor
来访问该函数下的全部 return
语句,但只会对相同函数做用域下的 return
语句进行转换操做。
在深刻讲解函数的转换操做以前,咱们先来看看插入的打点代码是如何实现的:
var data = {}
var time = {
start(uid) {
var startTime = performance.now()
data[startTime] = { uid, startTime }
return startTime
},
end(uid, tid) {
if (data[tid]) {
var endTime = performance.now()
data[tid] = { ...data[tid], endTime }
}
}
}
复制代码
能够看到,咱们以时间戳做为整个数据对象的 key ,每当调用 time.start()
就会记录当前函数开始执行的时间点,以后咱们将这个时间戳返回,并在函数内部新建一个变量来接收它,在函数执行结束时咱们会调用 time.end()
,此时再将该变量传回对象内部,这样就能经过 startTime
这个 key 将结束时的时间戳放到正确的位置。值得注意的是,因为通常时间戳的精度不足以计算同步函数执行的时间差,因此咱们使用的是精确到毫秒的 performance.now()
Web API 。打点函数中的 uid
指的是原始代码中每一个函数的惟一标识符,它是在 Babel 遍历每一个有效函数时由咱们生成的,在上文代码中也有提到。
接下来就让咱们开始讲解函数的转换操做,对于普通函数,咱们会对 AST 进行以下转换:
function syncTransform(path, query) {
path.get('body').unshiftContainer('body', startExpression(query))
if (!hasReturnStatement(path)) {
path.get('body').pushContainer('body', endExpression(query))
}
}
复制代码
不难理解,这段代码会在同步函数的头部插入开始计时的打点函数,若是函数中没有 return
语句,则会在函数结尾插入结束计时的打点函数。
而后让咱们来看看该如何处理异步函数:
function asyncTransform(path, query) {
path.get('body').unshiftContainer('body', startExpression(query))
if (path.node.async) {
path.traverse(awaitExpressionVisitor, { path, query })
}
if (path.node.generator) {
path.traverse(yieldExpressionVisitor, { path, query })
}
if (!hasReturnStatement(path)) {
path.get('body').pushContainer('body', endExpression(query))
}
}
复制代码
能够看到,它和同步函数的处理方式实际上是差很少的,只不过多了两处判断语句。若是函数为 async 类型则会经过 awaitExpressionVisitor
访问该函数下的 await 表达式并对其进行转换,awaitExpressionVisitor
的实现以下:
function isInjectedBefore(path) {
return (path.node.start === undefined || path.node.end === undefined)
}
function isUnmatchedContext(path, funcPath) {
return path.getFunctionParent().node !== funcPath.node
}
function shouldVisit(path, funcPath) {
return !(isInjectedBefore(path) || isUnmatchedContext(path, funcPath))
}
var awaitExpressionVisitor = {
AwaitExpression(path) {
if (shouldVisit(path, this.path)) {
awaitExpressionTransform(path, this.path, this.query)
}
}
}
复制代码
在这里咱们限制了 await 表达式可以进行转换的条件,只有当该表达式以前没有被转换过而且与函数位于同一做用域时,才能进行转换。那什么叫同一做用域呢?咱们来举个例子:
async function foo() {
async function bar() {
await baz()
}
await baz()
}
复制代码
Babel 在遍历 AST 时实际上是以深度优先的,所以在访问 foo 函数中的 await 表达式时,会首先遍历到 bar 函数中的 await 表达式,若是此时对它进行转换实际上是不符合咱们的预期的,由于咱们的打点代码只应该计算当前函数的执行耗时,因此对于这种状况咱们会直接返回。
在找到正确的 await 表达式后,咱们该如何插入打点代码来得到正确的函数耗时数据呢?咱们知道在 async 函数中,当遇到 await 表达式时会马上暂停当前函数的执行,而后去执行 await 表达式后面紧跟的函数,而恢复函数执行的条件则是等待 await 表达式后面的函数执行完毕或者返回的 Promise 决议完成。从这里咱们也能够看出,在 async 函数遇到 await 表达式中止执行到恢复执行的时间段并不属于当前函数的耗时。所以咱们的打点代码其实能够这样插入:
async function foo() {
var _tid5
var _tid4
var _tid3 = time.start("3")
console.log(2333)
(await (time.end("3", _tid3), bar()), _tid4 = time.start("3"))
console.log(2333)
(await (time.end("3", _tid4), bar()), _tid5 = time.start("3"))
time.end("3", _tid5)
}
复制代码
从上面的代码能够看出在 await 表达式后面的函数执行以前,咱们会先结束前一段同步代码的计时,并在函数恢复执行以后开始下一段同步代码的计时,在这里咱们巧妙地运用了 JavaScript 中的逗号操做符来实现该功能。对于 generator 函数,其实它和 async 函数的处理方式是同样的,只须要在函数中访问正确的 yield 表达式并进行转换便可。
下面是对两种异步函数转换的代码:
function asyncExpressionTransform(path, funcPath, query, expression) {
var _tid2 = funcPath.scope.generateUidIdentifier('tid')
query['_tid2'] = _tid2
funcPath.get('body').unshiftContainer('body', variableExpression(query))
path.replaceWith(expression(path, query))
query['_tid'] = _tid2
}
function yieldExpressionTransform(path, funcPath, query) {
asyncExpressionTransform(path, funcPath, query, yieldExpression)
}
function awaitExpressionTransform(path, funcPath, query) {
asyncExpressionTransform(path, funcPath, query, awaitExpression)
}
复制代码
接下来让咱们看看该如何对 return
语句进行转换:
var returnStatementVisitor = {
ReturnStatement(path) {
if (shouldVisit(path, this.path)) {
returnStatementTransform(path, this.query)
}
}
}
function returnStatementTransform(path, query) {
var { t } = query
var end = endExpression(query)
var return_uid = path.scope.generateUidIdentifier('uid')
var returnVar = t.variableDeclaration('var', [t.variableDeclarator(return_uid, path.node.argument)])
var _return = t.returnStatement(return_uid)
if (path.parentPath.type === 'BlockStatement') {
path.insertBefore(returnVar)
path.insertBefore(end)
path.insertBefore(_return)
path.remove()
}
}
复制代码
这里的转换十分简单,最终的效果大体是这个样子:
// 转换前
function foo() {
console.log(2333)
return 'xxx'
}
// 转换后
function foo() {
var _tid = time.start("1")
console.log(2333)
var _uid = 'xxx'
time.end("1", _tid)
return _uid
}
复制代码
到此为止,整个 Babel 插件的主要实现差很少就讲完了,如今让咱们把关注点转移到实现函数的性能分析工具上。只收集函数的耗时是远远不够的,咱们还须要收集函数名以及函数对应的行列号,由于只要转换后的代码带有 sourcemap ,结合 chrome 的 performance 面板,实际上是能够经过函数行列号直接定位到原始代码对应函数的位置的。但一般咱们的 sourcemap 只能映射到上一次转换前的代码,而咱们的代码每每会通过编译、ES6+ 转 ES五、压缩等一系列步骤,每一步都会生成不一样的 sourcemap ,那咱们该如何经过最终文件的 sourcemap 找到原始代码并进行调试呢?其实社区中已经有大神写出了这样的库,它的名字叫作 sorcery ,翻译过来就是魔法的意思,下面是这个库的简介:
Resolve a chain of sourcemaps back to the original source, like magic.
这个库的做者是 Rich-Harris ,他同时也是 Rollup.js 和 Svelte.js 的做者,确实是大神级别的人物。
经过 sorcery.js 咱们能够 flatten 多个 sourcemap 并最终生成可以直接映射到原始代码的 sourcemap ,这为咱们调试代码提供了极大的帮助。以后咱们经过 @babel/traverse 对压缩后的最终文件进行二次语法树分析,此时收集到的函数名与函数行列号,在有了正确的 sourcemap 后便显得尤其重要。下面是进行二次语法树分析的代码实现:
var ast = parser.parse(code)
function isStartExpression(path) {
var result = path.node.object.name === 'time' &&
path.node.property.name === 'start' && path.parentPath.node.type === 'CallExpression'
return result
}
function getFunctionInfo(path) {
var funcPath = path.getFunctionParent()
var parentNode = funcPath.parentPath.node
var info = {}
function generateInfo(name, location) {
info = { name, location }
}
if (parentNode.type === 'AssignmentExpression') {
generateInfo(parentNode.left.property.name, parentNode.left.property.loc.start)
} else if (parentNode.type === 'VariableDeclarator') {
generateInfo(parentNode.id.name, parentNode.id.loc.start)
} else {
funcPath.node.id
? generateInfo(funcPath.node.id.name, funcPath.node.id.loc.start)
: generateInfo('anonymous', funcPath.node.loc.start)
}
return info
}
var data = {}
// 对代码进行二次语法树分析,收集函数名以及对应的行列号。
traverse(ast, {
MemberExpression(path) {
if (isStartExpression(path)) {
var uid = path.parentPath.node.arguments[0].value
data[uid] = getFunctionInfo(path)
}
}
})
复制代码
最后让咱们来看看在编写 Babel 插件时该如何避免没必要要的遍历以及对 AST 的操做,进而减小插件的运行时间。
在遍历 AST 时,对于不知足要求的节点应该直接返回,这样既防止了咱们生成错误代码也在必定程度上缩短了遍历时间。
// 正确
if (shouldVisit(path, this.path)) {
returnStatementTransform(path, this.query)
}
复制代码
// 正确
if (isEmptyFunction(path) || isTraversalFunction(path)) {
return
}
复制代码
应尽可能避免遍历 AST,及时合并访问者对象。
// 错误
path.traverse({
Identifier(path) {
// ...
}
})
path.traverse({
BinaryExpression(path) {
// ...
}
})
复制代码
// 正确
path.traverse({
Identifier(path) {
// ...
},
BinaryExpression(path) {
// ...
}
})
复制代码
使用单例,优化嵌套的访问者对象。
// 错误
path.traverse({
ReturnStatement(path) {
if (shouldVisit(path, this.path)) {
returnStatementTransform(path, this.query)
}
}
}, { path, query })
复制代码
// 正确
var returnStatementVisitor = {
ReturnStatement(path) {
if (shouldVisit(path, this.path)) {
returnStatementTransform(path, this.query)
}
}
}
path.traverse(returnStatementVisitor, { path, query })
复制代码
本文到此也就接近尾声了,与文章相关的代码全都在这个仓库,有兴趣的朋友能够翻阅下。文中若有错误,请读者指出,做者会当即改正。在最后结束时想要感谢 2019 年暑假在腾讯实习时的导师,正是他给做者布置的课题才有了如今这篇文章,固然期间也受到了许多帮助,故在此表达谢意。
参考内容