根據筆者的專案經驗,本文講解了從函式回撥,到 es7
規範的異常處理方式。異常處理的優雅性隨著規範的進步越來越高,不要害怕使用 try catch
,不能迴避異常處理。
我們需要一個健全的架構捕獲所有同步、非同步的異常。業務方不處理異常時,中斷函式執行並啟用預設處理,業務方也可以隨時捕獲異常自己處理。
優雅的異常處理方式就像冒泡事件,任何元素可以自由攔截,也可以放任不管交給頂層處理。
文字講解僅是背景知識介紹,不包含對程式碼塊的完整解讀,不要忽略程式碼塊的閱讀。
1. 回撥
如果在回撥函式中直接處理了異常,是最不明智的選擇,因為業務方完全失去了對異常的控制能力。
下方的函式 請求處理
不但永遠不會執行,還無法在異常時做額外的處理,也無法阻止異常產生時笨拙的 console.log('請求失敗')
行為。
1 2 3 4 5 6 7 8 9 |
function fetch(callback) { setTimeout(() = > { console.log('請求失敗') }) } fetch(() = > { console.log('請求處理') // 永遠不會執行 }) |
2. 回撥,無法捕獲的異常
回撥函式有同步和非同步之分,區別在於對方執行回撥函式的時機,異常一般出現在請求、資料庫連線等操作中,這些操作大多是非同步的。
非同步回撥中,回撥函式的執行棧與原函式分離開,導致外部無法抓住異常。
從下文開始,我們約定用
setTimeout
模擬非同步操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function fetch(callback) { setTimeout(() = > { throw Error('請求失敗') }) } try { fetch(() = > { console.log('請求處理') // 永遠不會執行 }) } catch (error) { console.log('觸發異常', error) // 永遠不會執行 } // 程式崩潰 // Uncaught Error: 請求失敗 |
3. 回撥,不可控的異常
我們變得謹慎,不敢再隨意丟擲異常,這已經違背了異常處理的基本原則。
雖然使用了 error-first
約定,使異常看起來變得可處理,但業務方依然沒有對異常的控制權,是否呼叫錯誤處理取決於回撥函式是否執行,我們無法知道呼叫的函式是否可靠。
更糟糕的問題是,業務方必須處理異常,否則程式掛掉就會什麼都不做,這對大部分不用特殊處理異常的場景造成了很大的精神負擔。
1 2 3 4 5 6 7 8 9 10 11 |
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 中。
1 2 3 4 5 6 |
const promiseA = new Promise((resolve, reject) = > { resolve('ok') }) promiseA.then(result = > { console.log(result) // ok }) |
如果決議結果是決絕,那麼 then
函式的第二個回撥會立即插入 microtask
佇列。
1 2 3 4 5 6 7 8 |
const promiseB = new Promise((resolve, reject) = > { reject('no') }) promiseB.then(result = > { console.log(result) // 永遠不會執行 }, error = > { console.log(error) // no }) |
如果一直不決議,此 promise
將處於 pending
狀態。
1 2 3 4 5 6 7 8 |
const promiseC = new Promise((resolve, reject) = > { // nothing }) promiseC.then(result = > { console.log(result) // 永遠不會執行 }, error = > { console.log(error) // 永遠不會執行 }) |
未捕獲的 reject
會傳到末尾,通過 catch
接住
1 2 3 4 5 6 7 8 9 |
const promiseD = new Promise((resolve, reject) = > { reject('no') }) promiseD.then(result = > { console.log(result) // 永遠不會執行 }). catch (error = > { console.log(error) // no }) |
resolve
決議會被自動展開(reject
不會)
1 2 3 4 5 6 7 8 |
const promiseE = new Promise((resolve, reject) = > { return new Promise((resolve, reject) = > { resolve('ok') }) }) promiseE.then(result = > { console.log(result) // ok }) |
鏈式流,then
會返回一個新的 Promise
,其狀態取決於 then
的返回值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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
捕獲。
1 2 3 4 5 6 7 8 9 10 11 12 |
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
佇列脫離了執行上下文環境,異常無法被當前作用域捕獲。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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
佇列並沒有離開當前作用域,我們通過以下例子來證明:
1 2 3 4 5 6 |
Promise.resolve(true).then((resolve, reject) = > { throw Error('microtask 中的異常') }). catch (error = > { console.log('捕獲異常', error) // 捕獲異常 Error: microtask 中的異常 }) |
至此,Promise
的異常處理有了比較清晰的答案,只要注意在 macrotask
級別回撥中使用 reject
,就沒有抓不住的異常。
6 Promise 異常追問
如果第三方函式在 macrotask
回撥中以 throw Error
的方式丟擲異常怎麼辦?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function thirdFunction() { setTimeout(() = > { throw Error('就是任性') }) } Promise.resolve(true).then((resolve, reject) = > { thirdFunction() }). catch (error = > { console.log('捕獲異常', error) }) // 程式崩潰 // Uncaught Error: 就是任性 |
值得欣慰的是,由於不在同一個呼叫棧,雖然這個異常無法被捕獲,但也不會影響當前呼叫棧的執行。
我們必須正視這個問題,唯一的解決辦法,是第三方函式不要做這種傻事,一定要在 macrotask
丟擲異常的話,請改為 reject
的方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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
,而且當同時含有多個第三方函式時,處理方式不太優雅:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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
是更為優雅的流程控制方式,可以讓函式可中斷執行:
1 2 3 4 5 6 7 8 |
function* generatorA() { console.log('a') yield console.log('b') } const genA = generatorA() genA.next() // a genA.next() // b |
yield
關鍵字後面可以包含表示式,表示式會傳給 next().value
。
next()
可以傳遞引數,引數作為 yield
的返回值。
這些特性足以孕育出偉大的生成器,我們稍後介紹。下面是這個特性的例子:
1 2 3 4 5 6 7 8 9 |
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
的引數沒有任何意義,傳入也會被丟棄。
1 |
const result = yield 5 |
這一句,返回值不是想當然的 5
。其的作用是將 5
傳遞給 genB.next()
,其值,由下一個 next genB.next(7)
傳給了它,所以語句等於 const result = 7
。
最後一個 genBValue
,是最後一個 next
的返回值,這個值,就是函式的 return
值,顯然為 undefined
。
我們回到這個語句:
1 |
const result = yield 5 |
如果返回值是 5,是不是就清晰了許多?是的,這種語法就是 await
。所以 Async Await
與 generator
有著莫大的關聯,橋樑就是 生成器,我們稍後介紹 生成器。
番外 Async Await
如果認為 Generator
不太好理解,那 Async Await
絕對是救命稻草,我們看看它們的特徵:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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
的效果。
1 2 3 4 5 6 7 8 |
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
的功能,就是這麼神奇。
1 2 3 4 5 6 7 8 9 10 |
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
的執行效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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
都不會自動捕獲,但好處是可以自動中斷函式,我們大可放心編寫業務邏輯,而不用擔心非同步異常後會被執行引發雪崩:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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
捕獲異常。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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
的軟肋,不過任然可以通過第六章的方案解決:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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
是更加優雅的方案:
1 2 3 4 5 6 7 8 9 10 11 12 |
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
中。
我們以如下業務程式碼為例,預設不捕獲錯誤的話,錯誤會一直冒泡到頂層,最後丟擲異常。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
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
類級別裝飾器,修飾整個類,可以讀取、修改類中任何屬性和方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
方法級別裝飾器,修飾某個方法,和類裝飾器功能相同,但是能額外獲取當前修飾的方法名。
為了發揮這一特點,我們篡改一下修飾的函式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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
屬性級別裝飾器,修飾某個屬性,和類裝飾器功能相同,但是能額外獲取當前修飾的屬性名。
為了發揮這一特點,我們篡改一下修飾的屬性值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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
函式丟擲的異常:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
處理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
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() |
我們也可以編寫方法級別的異常處理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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 } } } |
業務方用法類似,只是裝飾器需要放在函式上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
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
。
同時業務方也握有絕對的主動權,比如登入失敗後,如果賬戶不存在,那麼直接跳轉到註冊頁,而不是傻瓜的提示使用者帳號不存在,可以這樣做:
1 2 3 4 5 6 7 8 9 10 11 12 |
async login(nickname, password) { try { const user = await userService.login(nickname, password) // 跳轉到首頁,登入失敗後不會執行到這,所以不用擔心使用者看到奇怪的跳轉 } catch (error) { if (error.no === -1) { // 跳轉到登入頁 } else { throw Error(error) // 其他錯誤不想管,把球繼續踢走 } } } |
補充
在 nodejs
端,記得監聽全域性錯誤,兜住落網之魚:
1 2 3 4 5 6 7 |
process.on('uncaughtException', (error: any) => { logger.error('uncaughtException', error) }) process.on('unhandledRejection', (error: any) => { logger.error('unhandledRejection', error) }) |
在瀏覽器端,記得監聽 window
全域性錯誤,兜住漏網之魚:
1 2 3 4 5 6 |
window.addEventListener('unhandledrejection', (event: any) => { logger.error('unhandledrejection', event) }) window.addEventListener('onrejectionhandled', (event: any) => { logger.error('onrejectionhandled', event) }) |
如有錯誤,歡迎斧正,本人 github 主頁:https://github.com/ascoders 希望結交有識之士!
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式