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

根據筆者的項目經驗,本文講解了從函數回調,到 es7 規範的異常處理方式。異常處理的優雅性隨着規範的進步愈來愈高,不要懼怕使用 try catch,不能迴避異常處理。前端

咱們須要一個健全的架構捕獲全部同步、異步的異常。業務方不處理異常時,中斷函數執行並啓用默認處理,業務方也能夠隨時捕獲異常本身處理。node

優雅的異常處理方式就像冒泡事件,任何元素能夠自由攔截,也能夠聽任無論交給頂層處理。git

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

1. 回調

若是在回調函數中直接處理了異常,是最不明智的選擇,由於業務方徹底失去了對異常的控制能力。golang

下方的函數 請求處理 不但永遠不會執行,還沒法在異常時作額外的處理,也沒法阻止異常產生時笨拙的 console.log('請求失敗') 行爲。數據庫

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

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

2. 回調,沒法捕獲的異常

回調函數有同步和異步之分,區別在於對方執行回調函數的時機,異常通常出如今請求、數據庫鏈接等操做中,這些操做大可能是異步的。promise

異步回調中,回調函數的執行棧與原函數分離開,致使外部沒法抓住異常。瀏覽器

從下文開始,咱們約定用 setTimeout 模擬異步操做markdown

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 但願結交有識之士!

相關文章
相關標籤/搜索