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

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

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

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

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

1. 回調

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

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

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

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

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

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

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

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

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 主頁:https://github.com/ascoders 但願結交有識之士!

相關文章
相關標籤/搜索