提及編譯原理,可能咱們腦海中首先浮現的就是 「編譯器」 這個詞彙。維基百科上對編譯器的定義是:編譯器是一種計算機程序,它會將某種編程語言寫成的源代碼(原始語言)轉換成另外一種編程語言(目標語言)。 一般一個編譯器的編譯過程會通過詞法分析、語法分析、語義分析、生成中間代碼、優化、生成目標代碼這幾個階段。若是將其簡要歸納,則只包含 解析 ( 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 年暑假在騰訊實習時的導師,正是他給做者佈置的課題纔有瞭如今這篇文章,固然期間也受到了許多幫助,故在此表達謝意。
參考內容