根據筆者的專案經驗,本文講解了從函式回撥,到 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 Await
與 generator
有著莫大的關聯,橋樑就是 生成器,我們稍後介紹 生成器。
番外 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 希望結交有識之士!