在開發中,你是否會爲了系統健壯性,亦或者是爲了捕獲異步的錯誤,而頻繁的在 async 函數中寫 try/catch 的邏輯?javascript
async function func() {
try {
let res = await asyncFunc()
} catch (e) {
//......
}
}
複製代碼
曾經我在《一個合格的中級前端工程師必需要掌握的 28 個 JavaScript 技巧》中提到過一個優雅處理 async/await 的方法css
這樣咱們就可使用一個輔助函數包裹這個 async 函數實現錯誤捕獲前端
async function func() {
let [err, res] = await errorCaptured(asyncFunc)
if (err) {
//... 錯誤捕獲
}
//...
}
複製代碼
可是這麼作有一個缺陷就是每次使用的時候,都要引入 errorCaptured 這個輔助函數,有沒有「懶」的方法呢?vue
答案確定是有的,我在那篇博客後提出了一個新的思路,能夠經過一個 webpack loader 來自動注入 try/catch 代碼,最後的結果但願是這樣的java
// development
async function func() {
let res = await asyncFunc()
//...其餘邏輯
}
// release
async function func() {
try {
let res = await asyncFunc()
} catch (e) {
//......
}
//...其餘邏輯
}
複製代碼
是否是很棒?在開發環境中不須要任何多餘的代碼,讓 webpack 自動給生產環境的代碼注入錯誤捕獲的邏輯,接下來咱們來逐步實現這個 loadernode
在實現這個 webpack loader 以前,先簡要介紹一下 loader 的原理,咱們在 webpack 中定義的一個個 loader,本質上只是一個函數,在定義 loader 同時還會定義一個 test 屬性,webpack 會遍歷全部的模塊名,當匹配 test 屬性定義的正則時,會將這個模塊做爲 source 參數傳入 loader 中執行webpack
{
test: /\.vue$/,
use: "vue-loader",
}
複製代碼
當匹配到 .vue 結尾的文件名時,會將文件做爲 source 參數傳給 vue-loader,use 屬性後面能夠是一個字符串也能夠是一個路徑,當是字符串時默認會視爲 nodejs 模塊去 node_modules 中找git
而這些文件本質上其實就是字符串(圖片,視頻就是 Buffer 對象),以 vue-loader 爲例,當 loader 接受到文件時,經過字符串匹配將其分爲 3 份,模版字符串會 vue-loader 編譯爲 render 函數,script 部分會交給 babel-loader,style 部分會交給 css-loader,同時 loader 遵照單一原則,即一個 loader 只作一件事,這樣能夠靈活組合多個 loader,互不干擾github
由於 loader 能夠讀取匹配到的文件,通過處理變成指望的輸出結果,因此咱們能夠本身實現一個 loader,接受 js 文件,當遇到 await 關鍵字時,給代碼包裹一層 try/catchweb
那麼如何可以準確給 await 及後面的表達式包裹 try/catch 呢?這裏須要用到抽象語法樹(AST)相關的知識
抽象語法樹是源代碼語法結構的一種抽象表示。它以樹狀的形式表現編程語言的語法結構,樹上的每一個節點都表示源代碼中的一種結構
經過 AST 能夠實現不少很是有用的功能,例如將 ES6 之後的代碼轉爲 ES5,eslint 的檢查,代碼美化,甚至 js 引擎都是依賴 AST 實現的,同時由於代碼本質只是單純的字符串,因此並不只限於 js 之間的轉換,scss,less 等 css 預處理器也是經過 AST 轉爲瀏覽器認識的 css 代碼,咱們來舉個例子
let a = 1
let b = a + 5
複製代碼
將其轉換爲抽象語法樹後是這樣的
將字符串轉爲 AST 樹須要通過詞法分析和語法分析兩步
詞法分析將一個個代碼片斷轉爲 token (詞法單元),去除空格註釋,例如第一行會將 let,a,=,1 這 4 個轉爲 token,token 是一個對象,描述了代碼片斷在整個代碼中的位置和記錄當前值的一些信息
語法分析會將 token 結合當前語言(JS)的語法轉換成 Node(節點),同時 Node 包含一個 type 屬性記錄當前的類型,例如 let 在 JS 中表明着一個變量聲明的關鍵字,因此它的 type 爲 VariableDeclaration,而 a = 1 會做爲 let 的聲明描述,它的 type 爲 VariableDeclarator,而聲明描述是依賴於變量聲明的,因此是一種上下的層級關係
另外能夠發現並非一個 token 對應一個 Node,等號左右必須都有值才能組成一個聲明語句,不然會做出警告,這就是 eslint 的基本原理。最後全部的 Node 組合在一塊兒就造成了 AST 語法樹
推薦一個很實用的 AST 查看工具,AST explorer,更直觀的查看代碼是如何轉爲抽象語法樹
回到代碼的實現,咱們只須要經過 AST 樹找到 await 表達式,將 await 外面包裹一層 try/catch 的 Node 節點便可
async function func() {
await asyncFunc()
}
複製代碼
對應 AST 樹:
async function func() {
try {
await asyncFunc()
} catch (e) {
console.log(e)
}
}
複製代碼
對應 AST 樹:
有了具體的思路,接下來咱們開始編寫 loader,當咱們的 loader 接收到 source 文件時,經過 @babel/parser
這個包能夠將文件轉換爲 AST 抽象語法樹,那麼如何找到對應的 await 表達式呢?
這就須要另一個 babel 的包 @babel/traverse
,經過 @babel/traverse
能夠傳入一個 AST 樹和一些鉤子函數,隨後深度遍歷傳入的 AST 樹,當遍歷的節點和鉤子函數的名字相同時,會執行對應的回調
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
//...
}
})
//...
}
複製代碼
經過 @babel/traverse
咱們可以輕鬆的找到 await 表達式對應的 Node 節點,接下來就是建立一個類型爲 TryStatement 的 Node 節點,最後 await 放入其中。這裏還須要依賴另一個包 @babel/types
,能夠理解爲 babel 版的 loadsh 庫,它提供了不少和 AST 的 Node 節點相關的輔助函數,咱們須要用到其中的 tryStatement
方法,即建立一個 TryStatement 的 Node 節點
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const t = require("@babel/types")
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
let tryCatchAst = t.tryStatement(
//...
)
//...
}
})
}
複製代碼
tryStatement
接受 3 個參數,第一個是 try 子句,第二個是 catch 子句,第三個是 finally 子句,一個完整的 try/catch 語句對應的 Node 節點看起來像這樣
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const t = require("@babel/types")
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const t = require("@babel/types")
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
let tryCatchAst = t.tryStatement(
// try 子句(必需項)
t.blockStatement([
t.expressionStatement(path.node)
]),
// catch 子句
t.catchClause(
//...
)
)
path.replaceWithMultiple([
tryCatchAst
])
}
})
//...
}
複製代碼
使用 blockStatement
,expressionStatement
方法建立一個塊級做用域和承載 await 表達式的 Node 節點,@babel/traverse
會給每一個鉤子函數傳入一個 path 參數,包含了當前遍歷的一些信息,例如當前節點,上個遍歷的 path 對象和對應的節點,最重要的是裏面有一些能夠操做 Node 節點的方法,咱們須要使用到 replaceWithMultiple
這個方法來將當前的 Node 節點替換爲 try/catch 語句的 Node 節點
另外咱們要考慮到 await 表達式多是是做爲一個聲明語句
let res = await asyncFunc()
複製代碼
也有多是一個賦值語句
res = await asyncFunc()
複製代碼
還有可能只是一個單純的表達式
await asyncFunc()
複製代碼
這 3 種狀況對應的 AST 也是不同的,因此咱們須要對其分別處理,@bable/types
提供了豐富的判斷函數,在 AwaitExpression 鉤子函數中,咱們只須要判斷上級節點是哪一種類型的 Node 節點便可,另外也能夠經過 AST explorer 來查看最終須要生成的 AST 樹的結構
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const t = require("@babel/types")
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
if (t.isVariableDeclarator(path.parent)) { // 變量聲明
let variableDeclarationPath = path.parentPath.parentPath
let tryCatchAst = t.tryStatement(
t.blockStatement([
variableDeclarationPath.node // Ast
]),
t.catchClause(
//...
)
)
variableDeclarationPath.replaceWithMultiple([
tryCatchAst
])
} else if (t.isAssignmentExpression(path.parent)) { // 賦值表達式
let expressionStatementPath = path.parentPath.parentPath
let tryCatchAst = t.tryStatement(
t.blockStatement([
expressionStatementPath.node
]),
t.catchClause(
//...
)
)
expressionStatementPath.replaceWithMultiple([
tryCatchAst
])
} else { // await 表達式
let tryCatchAst = t.tryStatement(
t.blockStatement([
t.expressionStatement(path.node)
]),
t.catchClause(
//...
)
)
path.replaceWithMultiple([
tryCatchAst
])
}
}
})
//...
}
複製代碼
在拿到替換後的 AST 樹後,使用 @babel/core
包中的 transformFromAstSync
方法將 AST 樹從新轉爲對應的代碼字符串返回便可
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const t = require("@babel/types")
const core = require("@babel/core")
module.exports = function (source) {
let ast = parser.parse(source)
traverse(ast, {
AwaitExpression(path) {
// 同上
}
})
return core.transformFromAstSync(ast).code
}
複製代碼
在這基礎上還暴露了一些 loader 配置項以提升易用性,例如若是 await 語句已經被 try/catch 包裹則不會再次注入,其原理也是基於 AST,利用 path 參數的 findParent
方法向上遍歷全部父節點,判斷是否被 try/catch 的 Node 包裹
traverse(ast, {
AwaitExpression(path) {
if (path.findParent((path) => t.isTryStatement(path.node))) return
// 處理邏輯
}
})
複製代碼
另外 catch 子句中的代碼片斷也支持自定義,這樣使得全部錯誤都使用統一邏輯處理,原理是將用戶配置的代碼片斷轉爲 AST,在 TryStatement 節點被建立的時候做爲參數傳入其 catch 節點
通過評論區的交流,我將默認給每一個 await 語句添加一個 try/catch,修改成給整個 async 函數包裹 try/catch,原理是先找到 await 語句,而後遞歸向上遍歷
當找到 async 函數時,建立一個 try/catch 的 Node 節點,並將原來 async 函數中的代碼做爲 Node 節點的子節點,並替換 async 函數的函數體
當遇到 try/catch,說明已經被 try/catch 包裹,取消注入,直接退出遍歷,這樣當用戶有自定義的錯誤捕獲代碼就不會執行 loader 默認的捕獲邏輯了
對應 AST 樹:
對應 AST 樹:
這只是最基本的 async 函數聲明的 node 節點,另外還有函數表達式,箭頭函數,做爲對象的方法等這些表現形式,當知足其中一種狀況就注入 try/catch 代碼塊
// 函數表達式
const func = async function () {
await asyncFunc()
}
// 箭頭函數
const func2 = async () => {
await asyncFunc()
}
// 方法
const vueComponent = {
methods: {
async func() {
await asyncFunc()
}
}
}
複製代碼
本文意在拋磚引玉,在平常開發過程當中,能夠結合本身的業務線,開發更加適合本身的 loader,例如技術棧是 jQuery 的老項目,能夠匹配 $.ajax
的 Node 節點,統一注入錯誤處理邏輯,甚至能夠自定義一些 ECMA 沒有的新語法
抱歉,懂編譯原理,真的是能夠隨心所欲
經過開發這個 loader 不只能夠學習到 webpack loader 是如何運行的,同時瞭解不少 AST 方面的知識,瞭解 babel 的原理,更多的方法能夠查看babel 的官方文檔或者 babel 手書
關於這個 loader 我已經發布到 npm 上,有興趣的朋友能夠直接調用 npm install async-catch-loader -D
安裝和研究,使用方法和通常 loader 同樣,記得放在 babel-loader 後面,以便優先執行,將注入後的結果繼續交給 babel 轉義
{
test: /\.js$/,
use: [
"babel-loader?cacheDirectory=true",
'async-catch-loader'
]
}
複製代碼
更多細節和源代碼能夠查看 github,同時本文對您有收穫的話,但願能點個 star,很是感謝~