嘿,不要給 async 函數寫那么多 try/catch 了


在開發中,你是否會為了系統健壯性,亦或者是為了捕獲異步的錯誤,而頻繁的在 async 函數中寫 try/catch 的邏輯?

async function func() {
    try {
        let res = await asyncFunc()
    } catch (e) {
      //......
    }
}
 

曾經我在《一個合格的中級前端工程師必須要掌握的 28 個 JavaScript 技巧》中提到過一個優雅處理 async/await 的方法

 

 

 

這樣我們就可以使用一個輔助函數包裹這個 async 函數實現錯誤捕獲

async function func() {
    let [err, res] = await errorCaptured(asyncFunc)
    if (err) {
        //... 錯誤捕獲
    }
    //...
}
 

但是這么做有一個缺陷就是每次使用的時候,都要引入 errorCaptured 這個輔助函數,有沒有“懶”的方法呢?

答案肯定是有的,我在那篇博客后提出了一個新的思路,可以通過一個 webpack loader 來自動注入 try/catch 代碼,最后的結果希望是這樣的

// development
async function func() {
   let res = await asyncFunc()
    //...其他邏輯
}

// release
async function func() {
    try {
        let res = await asyncFunc()
    } catch (e) {
      //......
    }
    //...其他邏輯
}
 

是不是很棒?在開發環境中不需要任何多余的代碼,讓 webpack 自動給生產環境的代碼注入錯誤捕獲的邏輯,接下來我們來逐步實現這個 loader

loader 原理

在實現這個 webpack loader 之前,先簡要介紹一下 loader 的原理,我們在 webpack 中定義的一個個 loader,本質上只是一個函數,在定義 loader 同時還會定義一個 test 屬性,webpack 會遍歷所有的模塊名,當匹配 test 屬性定義的正則時,會將這個模塊作為 source 參數傳入 loader 中執行

{
    test: /\.vue$/,
    use: "vue-loader",
}
 

當匹配到 .vue 結尾的文件名時,會將文件作為 source 參數傳給 vue-loader,use 屬性后面可以是一個字符串也可以是一個路徑,當是字符串時默認會視為 nodejs 模塊去 node_modules 中找

而這些文件本質上其實就是字符串(圖片,視頻就是 Buffer 對象),以 vue-loader 為例,當 loader 接受到文件時,通過字符串匹配將其分為 3 份,模版字符串會 vue-loader 編譯為 render 函數,script 部分會交給 babel-loader,style 部分會交給 css-loader,同時 loader 遵守單一原則,即一個 loader 只做一件事,這樣可以靈活組合多個 loader,互不干擾

實現思路

因為 loader 可以讀取匹配到的文件,經過處理變成期望的輸出結果,所以我們可以自己實現一個 loader,接受 js 文件,當遇到 await 關鍵字時,給代碼包裹一層 try/catch

那么如何能夠准確給 await 及后面的表達式包裹 try/catch 呢?這里需要用到抽象語法樹(AST)相關的知識

AST

抽象語法樹是源代碼[1]語法[2]結構的一種抽象表示。它以 樹狀[3]的形式表現編程語言[4]的語法結構,樹上的每個節點都表示源代碼中的一種結構

通過 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,當我們的 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,非常感謝~

async-catch-loader

 

參考資料

 

[1]源代碼: https://zh.wikipedia.org/wiki/%E6%BA%90%E4%BB%A3%E7%A0%81

[2]語法學: https://zh.wikipedia.org/wiki/%E8%AF%AD%E6%B3%95%E5%AD%A6

[3]樹 (圖論): https://zh.wikipedia.org/wiki/%E6%A0%91_(%E5%9B%BE%E8%AE%BA)

 

[4]編程語言: https://zh.wikipedia.org/wiki/%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80

 

以上內容轉載自https://mp.weixin.qq.com/s/1FMWTXeLjvQGIfg2k1UpdQ

喜歡這篇文章?歡迎打賞~~

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM