Callback Promise Generator Async-Await 和異常處理的演進

黃子毅發表於2019-03-04

根據筆者的專案經驗,本文講解了從函式回撥,到 es7 規範的異常處理方式。異常處理的優雅性隨著規範的進步越來越高,不要害怕使用 try catch,不能迴避異常處理。

我們需要一個健全的架構捕獲所有同步、非同步的異常。業務方不處理異常時,中斷函式執行並啟用預設處理,業務方也可以隨時捕獲異常自己處理。

優雅的異常處理方式就像冒泡事件,任何元素可以自由攔截,也可以放任不管交給頂層處理。

文字講解僅是背景知識介紹,不包含對程式碼塊的完整解讀,不要忽略程式碼塊的閱讀。

1. 回撥

如果在回撥函式中直接處理了異常,是最不明智的選擇,因為業務方完全失去了對異常的控制能力。

下方的函式 請求處理 不但永遠不會執行,還無法在異常時做額外的處理,也無法阻止異常產生時笨拙的 console.log(`請求失敗`) 行為。

function fetch(callback) {
    setTimeout(() => {
        console.log(`請求失敗`)
    })
}

fetch(() => {
    console.log(`請求處理`) // 永遠不會執行
})複製程式碼

2. 回撥,無法捕獲的異常

回撥函式有同步和非同步之分,區別在於對方執行回撥函式的時機,異常一般出現在請求、資料庫連線等操作中,這些操作大多是非同步的。

非同步回撥中,回撥函式的執行棧與原函式分離開,導致外部無法抓住異常。

從下文開始,我們約定用 setTimeout 模擬非同步操作

function fetch(callback) {
    setTimeout(() => {
        throw Error(`請求失敗`)
    })
}

try {
    fetch(() => {
        console.log(`請求處理`) // 永遠不會執行
    })
} catch (error) {
    console.log(`觸發異常`, error) // 永遠不會執行
}

// 程式崩潰
// Uncaught Error: 請求失敗複製程式碼

3. 回撥,不可控的異常

我們變得謹慎,不敢再隨意丟擲異常,這已經違背了異常處理的基本原則。

雖然使用了 error-first 約定,使異常看起來變得可處理,但業務方依然沒有對異常的控制權,是否呼叫錯誤處理取決於回撥函式是否執行,我們無法知道呼叫的函式是否可靠。

更糟糕的問題是,業務方必須處理異常,否則程式掛掉就會什麼都不做,這對大部分不用特殊處理異常的場景造成了很大的精神負擔。

function fetch(handleError, callback) {
    setTimeout(() => {
        handleError(`請求失敗`)
    })
}

fetch(() => {
    console.log(`失敗處理`) // 失敗處理
}, error => {
    console.log(`請求處理`) // 永遠不會執行
})複製程式碼

番外 Promise 基礎

Promise 是一個承諾,只可能是成功、失敗、無響應三種情況之一,一旦決策,無法修改結果。

Promise 不屬於流程控制,但流程控制可以用多個 Promise 組合實現,因此它的職責很單一,就是對一個決議的承諾。

resolve 表明通過的決議,reject 表明拒絕的決議,如果決議通過,then 函式的第一個回撥會立即插入 microtask 佇列,非同步立即執行

簡單補充下事件迴圈的知識,js 事件迴圈分為 macrotask 和 microtask。
microtask 會被插入到每一個 macrotask 的尾部,所以 microtask 總會優先執行,哪怕 macrotask 因為 js 程式繁忙被 hung 住。
比如 setTimeout setInterval 會插入到 macrotask 中。

const promiseA = new Promise((resolve, reject) => {
    resolve(`ok`)
})
promiseA.then(result => {
    console.log(result) // ok
})複製程式碼

如果決議結果是決絕,那麼 then 函式的第二個回撥會立即插入 microtask 佇列。

const promiseB = new Promise((resolve, reject) => {
    reject(`no`)
})
promiseB.then(result => {
    console.log(result) // 永遠不會執行
}, error => {
    console.log(error) // no
})複製程式碼

如果一直不決議,此 promise 將處於 pending 狀態。

const promiseC = new Promise((resolve, reject) => {
    // nothing
})
promiseC.then(result => {
    console.log(result) // 永遠不會執行
}, error => {
    console.log(error) // 永遠不會執行
})複製程式碼

未捕獲的 reject 會傳到末尾,通過 catch 接住

const promiseD = new Promise((resolve, reject) => {
    reject(`no`)
})
promiseD.then(result => {
    console.log(result) // 永遠不會執行
}).catch(error => {
    console.log(error) // no
})複製程式碼

resolve 決議會被自動展開(reject 不會)

const promiseE = new Promise((resolve, reject) => {
    return new Promise((resolve, reject) => {
        resolve(`ok`)
    })
})
promiseE.then(result => {
    console.log(result) // ok
})複製程式碼

鏈式流,then 會返回一個新的 Promise,其狀態取決於 then 的返回值。

const promiseF = new Promise((resolve, reject) => {
    resolve(`ok`)
})
promiseF.then(result => {
    return Promise.reject(`error1`)
}).then(result => {
    console.log(result) // 永遠不會執行
    return Promise.resolve(`ok1`) // 永遠不會執行
}).then(result => {
    console.log(result) // 永遠不會執行
}).catch(error => {
    console.log(error) // error1
})複製程式碼

4 Promise 異常處理

不僅是 reject,丟擲的異常也會被作為拒絕狀態被 Promise 捕獲。

function fetch(callback) {
    return new Promise((resolve, reject) => {
        throw Error(`使用者不存在`)
    })
}

fetch().then(result => {
    console.log(`請求處理`, result) // 永遠不會執行
}).catch(error => {
    console.log(`請求處理異常`, error) // 請求處理異常 使用者不存在
})複製程式碼

5 Promise 無法捕獲的異常

但是,永遠不要在 macrotask 佇列中丟擲異常,因為 macrotask 佇列脫離了執行上下文環境,異常無法被當前作用域捕獲。

function fetch(callback) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
             throw Error(`使用者不存在`)
        })
    })
}

fetch().then(result => {
    console.log(`請求處理`, result) // 永遠不會執行
}).catch(error => {
    console.log(`請求處理異常`, error) // 永遠不會執行
})

// 程式崩潰
// Uncaught Error: 使用者不存在複製程式碼

不過 microtask 中丟擲的異常可以被捕獲,說明 microtask 佇列並沒有離開當前作用域,我們通過以下例子來證明:

Promise.resolve(true).then((resolve, reject)=> {
    throw Error(`microtask 中的異常`)
}).catch(error => {
    console.log(`捕獲異常`, error) // 捕獲異常 Error: microtask 中的異常
})複製程式碼

至此,Promise 的異常處理有了比較清晰的答案,只要注意在 macrotask 級別回撥中使用 reject,就沒有抓不住的異常。

6 Promise 異常追問

如果第三方函式在 macrotask 回撥中以 throw Error 的方式丟擲異常怎麼辦?

function thirdFunction() {
    setTimeout(() => {
        throw Error(`就是任性`)
    })
}

Promise.resolve(true).then((resolve, reject) => {
    thirdFunction()
}).catch(error => {
    console.log(`捕獲異常`, error)
})

// 程式崩潰
// Uncaught Error: 就是任性複製程式碼

值得欣慰的是,由於不在同一個呼叫棧,雖然這個異常無法被捕獲,但也不會影響當前呼叫棧的執行。

我們必須正視這個問題,唯一的解決辦法,是第三方函式不要做這種傻事,一定要在 macrotask 丟擲異常的話,請改為 reject 的方式。

function thirdFunction() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(`收斂一些`)
        })
    })
}

Promise.resolve(true).then((resolve, reject) => {
    return thirdFunction()
}).catch(error => {
    console.log(`捕獲異常`, error) // 捕獲異常 收斂一些
})複製程式碼

請注意,如果 return thirdFunction() 這行缺少了 return 的話,依然無法抓住這個錯誤,這是因為沒有將對方返回的 Promise 傳遞下去,錯誤也不會繼續傳遞。

我們發現,這樣還不是完美的辦法,不但容易忘記 return,而且當同時含有多個第三方函式時,處理方式不太優雅:

function thirdFunction() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(`收斂一些`)
        })
    })
}

Promise.resolve(true).then((resolve, reject) => {
    return thirdFunction().then(() => {
        return thirdFunction()
    }).then(() => {
        return thirdFunction()
    }).then(() => {
    })
}).catch(error => {
    console.log(`捕獲異常`, error)
})複製程式碼

是的,我們還有更好的處理方式。

番外 Generator 基礎

generator 是更為優雅的流程控制方式,可以讓函式可中斷執行:

function* generatorA() {
    console.log(`a`)
    yield
    console.log(`b`)
}
const genA = generatorA()
genA.next() // a
genA.next() // b複製程式碼

yield 關鍵字後面可以包含表示式,表示式會傳給 next().value

next() 可以傳遞引數,引數作為 yield 的返回值。

這些特性足以孕育出偉大的生成器,我們稍後介紹。下面是這個特性的例子:

function* generatorB(count) {
    console.log(count)
    const result = yield 5
    console.log(result * count)
}
const genB = generatorB(2)
genB.next() // 2
const genBValue = genB.next(7).value // 14
// genBValue undefined複製程式碼

第一個 next 是沒有引數的,因為在執行 generator 函式時,初始值已經傳入,第一個 next 的引數沒有任何意義,傳入也會被丟棄。

const result = yield 5複製程式碼

這一句,返回值不是想當然的 5。其的作用是將 5 傳遞給 genB.next(),其值,由下一個 next genB.next(7) 傳給了它,所以語句等於 const result = 7

最後一個 genBValue,是最後一個 next 的返回值,這個值,就是函式的 return,顯然為 undefined

我們回到這個語句:

const result = yield 5複製程式碼

如果返回值是 5,是不是就清晰了許多?是的,這種語法就是 await。所以 Async Awaitgenerator 有著莫大的關聯,橋樑就是 生成器,我們稍後介紹 生成器

番外 Async Await

如果認為 Generator 不太好理解,那 Async Await 絕對是救命稻草,我們看看它們的特徵:

const timeOut = (time = 0) => new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(time + 200)
    }, time)
})

async function main() {
    const result1 = await timeOut(200)
    console.log(result1) // 400
    const result2 = await timeOut(result1)
    console.log(result2) // 600
    const result3 = await timeOut(result2)
    console.log(result3) // 800
}

main()複製程式碼

所見即所得,await 後面的表示式被執行,表示式的返回值被返回給了 await 執行處。

但是程式是怎麼暫停的呢?只有 generator 可以暫停程式。那麼等等,回顧一下 generator 的特性,我們發現它也可以達到這種效果。

番外 async await 是 generator 的語法糖

終於可以介紹 生成器 了!它可以魔法般將下面的 generator 執行成為 await 的效果。

function* main() {
    const result1 = yield timeOut(200)
    console.log(result1)
    const result2 = yield timeOut(result1)
    console.log(result2)
    const result3 = yield timeOut(result2)
    console.log(result3)
}複製程式碼

下面的程式碼就是生成器了,生成器並不神祕,它只有一個目的,就是:

所見即所得,yield 後面的表示式被執行,表示式的返回值被返回給了 yield 執行處。

達到這個目標不難,達到了就完成了 await 的功能,就是這麼神奇。

function step(generator) {
    const gen = generator()
    // 由於其傳值,返回步驟交錯的特性,記錄上一次 yield 傳過來的值,在下一個 next 返回過去
    let lastValue
    // 包裹為 Promise,並執行表示式
    return () => Promise.resolve(gen.next(lastValue).value).then(value => {
        lastValue = value
        return lastValue
    })
}複製程式碼

利用生成器,模擬出 await 的執行效果:

const run = step(main)

function recursive(promise) {
    promise().then(result => {
        if (result) {
            recursive(promise)
        }
    })
}

recursive(run)
// 400
// 600
// 800複製程式碼

可以看出,await 的執行次數由程式自動控制,而回退到 generator 模擬,需要根據條件判斷是否已經將函式執行完畢。

7 Async Await 異常

不論是同步、非同步的異常,await 都不會自動捕獲,但好處是可以自動中斷函式,我們大可放心編寫業務邏輯,而不用擔心非同步異常後會被執行引發雪崩:

function fetch(callback) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject()
        })
    })
}

async function main() {
    const result = await fetch()
    console.log(`請求處理`, result) // 永遠不會執行
}

main()複製程式碼

8 Async Await 捕獲異常

我們使用 try catch 捕獲異常。

認真閱讀 Generator 番外篇的話,就會理解為什麼此時非同步的異常可以通過 try catch 來捕獲。

因為此時的非同步其實在一個作用域中,通過 generator 控制執行順序,所以可以將非同步看做同步的程式碼去編寫,包括使用 try catch 捕獲異常。

function fetch(callback) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(`no`)
        })
    })
}

async function main() {
    try {
        const result = await fetch()
        console.log(`請求處理`, result) // 永遠不會執行
    } catch (error) {
        console.log(`異常`, error) // 異常 no
    }
}

main()複製程式碼

9 Async Await 無法捕獲的異常

和第五章 Promise 無法捕獲的異常 一樣,這也是 await 的軟肋,不過任然可以通過第六章的方案解決:

function thirdFunction() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(`收斂一些`)
        })
    })
}

async function main() {
    try {
        const result = await thirdFunction()
        console.log(`請求處理`, result) // 永遠不會執行
    } catch (error) {
        console.log(`異常`, error) // 異常 收斂一些
    }
}

main()複製程式碼

現在解答第六章尾部的問題,為什麼 await 是更加優雅的方案:

async function main() {
    try {
        const result1 = await secondFunction() // 如果不丟擲異常,後續繼續執行
        const result2 = await thirdFunction() // 丟擲異常
        const result3 = await thirdFunction() // 永遠不會執行
        console.log(`請求處理`, result) // 永遠不會執行
    } catch (error) {
        console.log(`異常`, error) // 異常 收斂一些
    }
}

main()複製程式碼

10 業務場景

在如今 action 概念成為標配的時代,我們大可以將所有異常處理收斂到 action 中。

我們以如下業務程式碼為例,預設不捕獲錯誤的話,錯誤會一直冒泡到頂層,最後丟擲異常。

const successRequest = () => Promise.resolve(`a`)
const failRequest = () => Promise.reject(`b`)

class Action {
    async successReuqest() {
        const result = await successRequest()
        console.log(`successReuqest`, `處理返回值`, result) // successReuqest 處理返回值 a
    }

    async failReuqest() {
        const result = await failRequest()
        console.log(`failReuqest`, `處理返回值`, result) // 永遠不會執行
    }

    async allReuqest() {
        const result1 = await successRequest()
        console.log(`allReuqest`, `處理返回值 success`, result1) // allReuqest 處理返回值 success a
        const result2 = await failRequest()
        console.log(`allReuqest`, `處理返回值 success`, result2) // 永遠不會執行
    }
}

const action = new Action()
action.successReuqest()
action.failReuqest()
action.allReuqest()

// 程式崩潰
// Uncaught (in promise) b
// Uncaught (in promise) b複製程式碼

為了防止程式崩潰,需要業務線在所有 async 函式中包裹 try catch

我們需要一種機制捕獲 action 最頂層的錯誤進行統一處理。

為了補充前置知識,我們再次進入番外話題。

番外 Decorator

Decorator 中文名是裝飾器,核心功能是可以通過外部包裝的方式,直接修改類的內部屬性。

裝飾器按照裝飾的位置,分為 class decorator method decorator 以及 property decorator(目前標準尚未支援,通過 get set 模擬實現)。

Class Decorator

類級別裝飾器,修飾整個類,可以讀取、修改類中任何屬性和方法。

const classDecorator = (target: any) => {
    const keys = Object.getOwnPropertyNames(target.prototype)
    console.log(`classA keys,`, keys) // classA keys ["constructor", "sayName"]
}

@classDecorator
class A {
    sayName() {
        console.log(`classA ascoders`)
    }
}
const a = new A()
a.sayName() // classA ascoders複製程式碼

Method Decorator

方法級別裝飾器,修飾某個方法,和類裝飾器功能相同,但是能額外獲取當前修飾的方法名。

為了發揮這一特點,我們篡改一下修飾的函式。

const methodDecorator = (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
    return {
        get() {
            return () => {
                console.log(`classC method override`)
            }
        }
    }
}

class C {
    @methodDecorator
    sayName() {
        console.log(`classC ascoders`)
    }
}
const c = new C()
c.sayName() // classC method override複製程式碼

Property Decorator

屬性級別裝飾器,修飾某個屬性,和類裝飾器功能相同,但是能額外獲取當前修飾的屬性名。

為了發揮這一特點,我們篡改一下修飾的屬性值。

const propertyDecorator = (target: any, propertyKey: string | symbol) => {
    Object.defineProperty(target, propertyKey, {
        get() {
            return `github`
        },
        set(value: any) {
            return value
        }
    })
}

class B {
    @propertyDecorator
    private name = `ascoders`

    sayName() {
        console.log(`classB ${this.name}`)
    }
}
const b = new B()
b.sayName() // classB github複製程式碼

11 業務場景 統一異常捕獲

我們來編寫類級別裝飾器,專門捕獲 async 函式丟擲的異常:

const asyncClass = (errorHandler?: (error?: Error) => void) => (target: any) => {
    Object.getOwnPropertyNames(target.prototype).forEach(key => {
        const func = target.prototype[key]
        target.prototype[key] = async (...args: any[]) => {
            try {
                await func.apply(this, args)
            } catch (error) {
                errorHandler && errorHandler(error)
            }
        }
    })
    return target
}複製程式碼

將類所有方法都用 try catch 包裹住,將異常交給業務方統一的 errorHandler 處理:

const successRequest = () => Promise.resolve(`a`)
const failRequest = () => Promise.reject(`b`)

const iAsyncClass = asyncClass(error => {
    console.log(`統一異常處理`, error) // 統一異常處理 b
})

@iAsyncClass
class Action {
    async successReuqest() {
        const result = await successRequest()
        console.log(`successReuqest`, `處理返回值`, result)
    }

    async failReuqest() {
        const result = await failRequest()
        console.log(`failReuqest`, `處理返回值`, result) // 永遠不會執行
    }

    async allReuqest() {
        const result1 = await successRequest()
        console.log(`allReuqest`, `處理返回值 success`, result1)
        const result2 = await failRequest()
        console.log(`allReuqest`, `處理返回值 success`, result2) // 永遠不會執行
    }
}

const action = new Action()
action.successReuqest()
action.failReuqest()
action.allReuqest()複製程式碼

我們也可以編寫方法級別的異常處理:

const asyncMethod = (errorHandler?: (error?: Error) => void) => (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
    const func = descriptor.value
    return {
        get() {
            return (...args: any[]) => {
                return Promise.resolve(func.apply(this, args)).catch(error => {
                    errorHandler && errorHandler(error)
                })
            }
        },
        set(newValue: any) {
            return newValue
        }
    }
}複製程式碼

業務方用法類似,只是裝飾器需要放在函式上:

const successRequest = () => Promise.resolve(`a`)
const failRequest = () => Promise.reject(`b`)

const asyncAction = asyncMethod(error => {
    console.log(`統一異常處理`, error) // 統一異常處理 b
})

class Action {
    @asyncAction async successReuqest() {
        const result = await successRequest()
        console.log(`successReuqest`, `處理返回值`, result)
    }

    @asyncAction async failReuqest() {
        const result = await failRequest()
        console.log(`failReuqest`, `處理返回值`, result) // 永遠不會執行
    }

    @asyncAction async allReuqest() {
        const result1 = await successRequest()
        console.log(`allReuqest`, `處理返回值 success`, result1)
        const result2 = await failRequest()
        console.log(`allReuqest`, `處理返回值 success`, result2) // 永遠不會執行
    }
}

const action = new Action()
action.successReuqest()
action.failReuqest()
action.allReuqest()複製程式碼

12 業務場景 沒有後顧之憂的主動權

我想描述的意思是,在第 11 章這種場景下,業務方是不用擔心異常導致的 crash,因為所有異常都會在頂層統一捕獲,可能表現為彈出一個提示框,告訴使用者請求傳送失敗。

業務方也不需要判斷程式中是否存在異常,而戰戰兢兢的到處 try catch,因為程式中任何異常都會立刻終止函式的後續執行,不會再引發更惡劣的結果。

像 golang 中異常處理方式,就存在這個問題
通過 err, result := func() 的方式,雖然固定了第一個引數是錯誤資訊,但下一行程式碼免不了要以 if error {...} 開頭,整個程式的業務程式碼充斥著巨量的不必要錯誤處理,而大部分時候,我們還要為如何處理這些錯誤想的焦頭爛額。

而 js 異常冒泡的方式,在前端可以用提示框兜底,nodejs端可以返回 500 錯誤兜底,並立刻中斷後續請求程式碼,等於在所有危險程式碼身後加了一層隱藏的 return

同時業務方也握有絕對的主動權,比如登入失敗後,如果賬戶不存在,那麼直接跳轉到註冊頁,而不是傻瓜的提示使用者帳號不存在,可以這樣做:

async login(nickname, password) {
    try {
        const user = await userService.login(nickname, password)
        // 跳轉到首頁,登入失敗後不會執行到這,所以不用擔心使用者看到奇怪的跳轉
    } catch (error) {
        if (error.no === -1) {
            // 跳轉到登入頁
        } else {
            throw Error(error) // 其他錯誤不想管,把球繼續踢走
        }
    }
}複製程式碼

補充

nodejs 端,記得監聽全域性錯誤,兜住落網之魚:

process.on(`uncaughtException`, (error: any) => {
    logger.error(`uncaughtException`, error)
})

process.on(`unhandledRejection`, (error: any) => {
    logger.error(`unhandledRejection`, error)
})複製程式碼

在瀏覽器端,記得監聽 window 全域性錯誤,兜住漏網之魚:

window.addEventListener(`unhandledrejection`, (event: any) => {
    logger.error(`unhandledrejection`, event)
})
window.addEventListener(`onrejectionhandled`, (event: any) => {
    logger.error(`onrejectionhandled`, event)
})複製程式碼

如有錯誤,歡迎斧正,本人 github 主頁:github.com/ascoders 希望結交有識之士!