你不知道的前端異常處理(萬字長文,建議收藏)
點擊藍色“腦洞前端”關(guān)注我喲
加個“星標”,帶你揭開大前端和算法世界的神秘面紗!
?這是腦洞前端第「95」篇原創(chuàng)文章
?
除了調(diào)試,處理異常或許是程序員編程時間占比最高的了。我們天天和各種異常打交道,就好像我們天天和 Bug 打交道一樣。因此正確認識異常,并作出合適的異常處理就顯得很重要了。
我們先嘗試拋開前端這個限定條件,來看下更廣泛意義上程序的報錯以及異常處理。不管是什么語言,都會有異常的發(fā)生。而我們程序員要做的就是正確識別程序中的各種異常,并針對其做相應(yīng)的「異常處理」。
然而,很多人對異常的處理方式是「事后修補」,即某個異常發(fā)生的時候,增加對應(yīng)的條件判斷,這真的是一種非常低效的開發(fā)方式,非常不推薦大家這么做。那么究竟如何正確處理異常呢?由于不同語言有不同的特性,因此異常處理方式也不盡相同。但是異常處理的思維框架一定是一致的。本文就「前端」異常進行詳細闡述,但是讀者也可以稍加修改延伸到其他各個領(lǐng)域。
?本文討論的異常指的是軟件異常,而非硬件異常。
?
什么是異常
用直白的話來解釋異常的話,就是「程序發(fā)生了意想不到的情況,這種情況影響到了程序的正確運行」。
從根本上來說,異常就是一個「數(shù)據(jù)結(jié)構(gòu)」,其保存了異常發(fā)生的相關(guān)信息,比如錯誤碼,錯誤信息等。以 JS 中的標準內(nèi)置對象 Error 為例,其標準屬性有 name 和 message。然而不同的瀏覽器廠商有自己的自定義屬性,這些屬性并不通用。比如 Mozilla 瀏覽器就增加了 filename 和 stack 等屬性。
值得注意的是錯誤只有被拋出,才會產(chǎn)生異常,不被拋出的錯誤不會產(chǎn)生異常。比如:
function t() {
console.log("start");
new Error();
console.log("end");
}
t();

(動畫演示)
這段代碼不會產(chǎn)生任何的異常,控制臺也不會有任何錯誤輸出。
異常的分類
按照產(chǎn)生異常時程序是否正在運行,我們可以將錯誤分為「編譯時異常」和「運行時異常」。
編譯時異常指的是源代碼在編譯成可執(zhí)行代碼之前產(chǎn)生的異常。而運行時異常指的是可執(zhí)行代碼被裝載到內(nèi)存中執(zhí)行之后產(chǎn)生的異常。
編譯時異常
我們知道 TS 最終會被編譯成 JS,從而在 JS Runtime中執(zhí)行。既然存在編譯,就有可能編譯失敗,就會有編譯時異常。
比如我使用 TS 寫出了如下代碼:
const s: string = 123;
這很明顯是錯誤的代碼, 我給 s 聲明了 string 類型,但是卻給它賦值 number。
當(dāng)我使用 tsc(typescript 編譯工具,全稱是 typescript compiler)嘗試編譯這個文件的時候會有異常拋出:
tsc a.ts
a.ts:1:7 - error TS2322: Type '123' is not assignable to type 'string'.
1 const s: string = 123;
~
Found 1 error.
這個異常就是編譯時異常,因為我的代碼還沒有執(zhí)行。
然而并不是你用了 TS 才存在編譯時異常,JS 同樣有編譯時異常。有的人可能會問 JS 不是解釋性語言么?是邊解釋邊執(zhí)行,沒有編譯環(huán)節(jié),怎么會有編譯時異常?
別急,我舉個例子你就明白了。如下代碼:
function t() {
console.log('start')
await sa
console.log('end')
}
t()
上面的代碼由于存在語法錯誤,不會編譯通過,因此并不會打印start,側(cè)面證明了這是一個編譯時異常。盡管 JS 是解釋語言,也依然存在編譯階段,這是必然的,因此自然也會有編譯異常。
總的來說,編譯異常可以在代碼被編譯成最終代碼前被發(fā)現(xiàn),因此對我們的傷害更小。接下來,看一下令人心生畏懼的「運行時異常」。
運行時異常
相信大家對運行時異常非常熟悉。這恐怕是廣大前端碰到最多的異常類型了。眾所周知的 NPE(Null Pointer Exception)[1] 就是運行時異常。
將上面的例子稍加改造,得到下面代碼:
function t() {
console.log("start");
throw 1;
console.log("end");
}
t();

(動畫演示)
?注意 end 沒有打印,并且 t 沒有彈出棧。實際上 t 最終還是會被彈出的,只不過和普通的返回不一樣。
?
如上,則會打印出start。由于異常是在代碼運行過程中拋出的,因此這個異常屬于運行時異常。相對于編譯時異常,這種異常更加難以發(fā)現(xiàn)。上面的例子可能比較簡單,但是如果我的異常是隱藏在某一個流程控制語句(比如 if else)里面呢?程序就可能在客戶的電腦走入那個拋出異常的 if 語句,而在你的電腦走入另一條。這就是著名的 「《在我電腦上好好的》」 事件。
異常的傳播
異常的傳播和我之前寫的瀏覽器事件模型[2]有很大的相似性。只不過那個是作用在 「DOM 這樣的數(shù)據(jù)結(jié)構(gòu)」,這個則是作用在「函數(shù)調(diào)用棧這種數(shù)據(jù)結(jié)構(gòu)」,并且事件傳播存在捕獲階段,異常傳播是沒有的。不同 C 語言,JS 中異常傳播是自動的,不需要程序員手動地一層層傳遞。如果一個異常沒有被 catch,它會沿著函數(shù)調(diào)用棧一層層傳播直到棧空。
異常處理中有兩個關(guān)鍵詞,它們是「throw(拋出異常)」 和 「catch(處理異常)」。當(dāng)一個異常被拋出的時候,異常的傳播就開始了。異常會不斷傳播直到遇到第一個 catch。如果程序員沒有手動 catch,那么一般而言程序會拋出類似「unCaughtError」,表示發(fā)生了一個異常,并且這個異常沒有被程序中的任何 catch 語言處理。未被捕獲的異常通常會被打印在控制臺上,里面有詳細的堆棧信息,從而幫助程序員快速排查問題。實際上我們的程序的目標是「避免 unCaughtError」這種異常,而不是一般性的異常。
一點小前提
由于 JS 的 Error 對象沒有 code 屬性,只能根據(jù) message 來呈現(xiàn),不是很方便。我這里進行了簡單的擴展,后面很多地方我用的都是自己擴展的 Error ,而不是原生 JS Error ,不再贅述。
oldError = Error;
Error = function ({ code, message, fileName, lineNumber }) {
error = new oldError(message, fileName, lineNumber);
error.code = code;
return error;
};
手動拋出 or 自動拋出
異常既可以由程序員自己手動拋出,也可以由程序自動拋出。
throw new Error(`I'm Exception`);
(手動拋出的例子)
a = null;
a.toString(); // Thrown: TypeError: Cannot read property 'toString' of null
(程序自動拋出的例子)
自動拋出異常很好理解,畢竟我們哪個程序員沒有看到過程序自動拋出的異常呢?
?“這個異常突然就跳出來!嚇我一跳!”,某不知名程序員如是說。
?
那什么時候應(yīng)該手動拋出異常呢?
一個指導(dǎo)原則就是「你已經(jīng)預(yù)知到程序不能正確進行下去了」。比如我們要實現(xiàn)除法,首先我們要考慮的是被除數(shù)為 0 的情況。當(dāng)被除數(shù)為 0 的時候,我們應(yīng)該怎么辦呢?是拋出異常,還是 return 一個特殊值?答案是都可以,你自己能區(qū)分就行,這沒有一個嚴格的參考標準。我們先來看下拋出異常,告訴調(diào)用者「你的輸入,我處理不了」這種情況。
function divide(a, b) {
a = +a;
b = +b; // 轉(zhuǎn)化成數(shù)字
if (!b) {
// 匹配 +0, -0, NaN
throw new Error({
code: 1,
message: "Invalid dividend " + b,
});
}
if (Number.isNaN(a)) {
// 匹配 NaN
throw new Error({
code: 2,
message: "Invalid divisor " + a,
});
}
return a / b;
}
上面代碼會在兩種情況下拋出異常,告訴調(diào)用者你的輸入我處理不了。由于這兩個異常都是程序員自動手動拋出的,因此是「可預(yù)知的異常」。
剛才說了,我們也可以通過返回值來區(qū)分異常輸入。我們來看下返回值輸入是什么,以及和異常有什么關(guān)系。
異常 or 返回
如果是基于異常形式(遇到不能處理的輸入就拋出異常)。當(dāng)別的代碼調(diào)用divide的時候,需要自己 catch。
function t() {
try {
divide("foo", "bar");
} catch (err) {
if (err.code === 1) {
return console.log("被除數(shù)必須是除0之外的數(shù)");
}
if (err.code === 2) {
return console.log("除數(shù)必須是數(shù)字");
}
throw new Error("不可預(yù)知的錯誤");
}
}
然而就像上面我說的那樣,divide 函數(shù)設(shè)計的時候,也完全可以不用異常,而是使用返回值來區(qū)分。
function divide(a, b) {
a = +a;
b = +b; // 轉(zhuǎn)化成數(shù)字
if (!b) {
// 匹配 +0, -0, NaN
return new Error({
code: 1,
message: "Invalid dividend " + b,
});
}
if (Number.isNaN(a)) {
// 匹配 NaN
return new Error({
code: 2,
message: "Invalid divisor " + a,
});
}
return a / b;
}
當(dāng)然,我們使用方式也要作出相應(yīng)改變。
function t() {
const res = divide("foo", "bar");
if (res.code === 1) {
return console.log("被除數(shù)必須是除0之外的數(shù)");
}
if (res.code === 2) {
return console.log("除數(shù)必須是數(shù)字");
}
return new Error("不可預(yù)知的錯誤");
}
這種函數(shù)設(shè)計方式和拋出異常的設(shè)計方式從功能上說都是一樣的,只是告訴調(diào)用方的方式不同。如果你選擇第二種方式,而不是拋出異常,那么實際上需要調(diào)用方書寫額外的代碼,用來區(qū)分正常情況和異常情況,這并不是一種良好的編程習(xí)慣。
然而在 Go 等返回值可以為復(fù)數(shù)的語言中,我們無需使用上面蹩腳的方式,而是可以:
res, err := divide("foo", "bar");
if err != nil {
log.Fatal(err)
}
這是和 Java 和 JS 等語言使用的 try catch 不一樣的的地方,Go 是通過 panic recover defer 機制來進行異常處理的。感興趣的可以去看看 Go 源碼關(guān)于錯誤測試部分[3]
可能大家對 Go 不太熟悉。沒關(guān)系,我們來繼續(xù)看下 shell。實際上 shell 也是通過返回值來處理異常的,我們可以通過 $? 拿到上一個命令的返回值,這本質(zhì)上也是一種調(diào)用棧的傳播行為,而且是通過返回值而不是捕獲來處理異常的。
?作為函數(shù)返回值處理和 try catch 一樣,這是語言的設(shè)計者和開發(fā)者共同決定的一件事情。
?
上面提到了異常傳播是作用在「函數(shù)調(diào)用棧」上的。當(dāng)一個異常發(fā)生的時候,其會沿著函數(shù)調(diào)用棧逐層返回,直到第一個 catch 語句。當(dāng)然 catch 語句內(nèi)部仍然可以觸發(fā)異常(自動或者手動)。如果 catch 語句內(nèi)部發(fā)生了異常,也一樣會沿著其函數(shù)調(diào)用棧繼續(xù)執(zhí)行上述邏輯,專業(yè)術(shù)語是 「stack unwinding」。
?實際上并不是所有的語言都會進行 stack unwinding,這個我們會在接下來的《運行時異常可以恢復(fù)么?》部分講解。
?

偽代碼來描述一下:
function bubble(error, fn) {
if (fn.hasCatchBlock()) {
runCatchCode(error);
}
if (callstack.isNotEmpty()) {
bubble(error, callstack.pop());
}
}
?從我的偽代碼可以看出所謂的 stack unwinding 其實就是 callstack.pop()
?
這就是異常傳播的一切!僅此而已。
異常的處理
我們已經(jīng)了解來異常的傳播方式了。那么接下來的問題是,我們應(yīng)該如何在這個傳播過程中處理異常呢?
我們來看一個簡單的例子:
function a() {
b();
}
function b() {
c();
}
function c() {
throw new Error("an error occured");
}
a();
我們將上面的代碼放到 chrome 中執(zhí)行, 會在控制臺顯示如下輸出:

我們可以清楚地看出函數(shù)的調(diào)用關(guān)系。即錯誤是在 c 中發(fā)生的,而 c 是 b 調(diào)用的,b 是 a 調(diào)用的。這個函數(shù)調(diào)用棧是為了方便開發(fā)者定位問題而存在的。
上面的代碼,我們并沒有 catch 錯誤,因此上面才會有「uncaught Error」。
那么如果我們 catch ,會發(fā)生什么樣的變化呢?catch 的位置會對結(jié)果產(chǎn)生什么樣的影響?在 a ,b,c 中 catch 的效果是一樣的么?
我們來分別看下:
function a() {
b();
}
function b() {
c();
}
function c() {
try {
throw new Error("an error occured");
} catch (err) {
console.log(err);
}
}
a();
(在 c 中 catch)
我們將上面的代碼放到 chrome 中執(zhí)行, 會在控制臺顯示如下輸出:

可以看出,此時已經(jīng)沒有「uncaught Error」啦,僅僅在控制臺顯示了「標準輸出」,而「非錯誤輸出」(因為我用的是 console.log,而不是 console.error)。然而更重要是的是,如果我們沒有 catch,那么后面的同步代碼將不會執(zhí)行。
比如在 c 的 throw 下面增加一行代碼,這行代碼是無法被執(zhí)行的,「無論這個錯誤有沒有被捕獲」。
function c() {
try {
throw new Error("an error occured");
console.log("will never run");
} catch (err) {
console.log(err);
}
}
我們將 catch 移動到 b 中試試看。
function a() {
b();
}
function b() {
try {
c();
} catch (err) {
console.log(err);
}
}
function c() {
throw new Error("an error occured");
}
a();
(在 b 中 catch)
在這個例子中,和上面在 c 中捕獲沒有什么本質(zhì)不同。其實放到 a 中捕獲也是一樣,這里不再貼代碼了,感興趣的自己試下。
既然處于函數(shù)調(diào)用棧頂部的函數(shù)報錯, 其函數(shù)調(diào)用棧下方的任意函數(shù)都可以進行捕獲,并且效果沒有本質(zhì)不同。那么問題來了,我到底應(yīng)該在哪里進行錯誤處理呢?
答案是責(zé)任鏈模式。我們先來簡單介紹一下責(zé)任鏈模式,不過細節(jié)不會在這里展開。
假如 lucifer 要請假。
如果請假天數(shù)小于等于 1 天,則主管同意即可 如果請假大于 1 天,但是小于等于三天,則需要 CTO 同意。 如果請假天數(shù)大于三天,則需要老板同意。

這就是一個典型的責(zé)任鏈模式。誰有責(zé)任干什么事情是確定的,不要做自己能力范圍之外的事情。比如主管不要去同意大于 1 天的審批。
舉個例子,假設(shè)我們的應(yīng)用有三個異常處理類,它們分別是:用戶輸入錯誤,網(wǎng)絡(luò)錯誤 和 類型錯誤。如下代碼,當(dāng)代碼執(zhí)行的時候會報錯一個用戶輸入異常。這個異常沒有被 C 捕獲,會 unwind stack 到 b,而 b 中 catch 到這個錯誤之后,通過查看 code 值判斷其可以被處理,于是打印I can handle this。
function a() {
try {
b();
} catch (err) {
if (err.code === "NETWORK_ERROR") {
return console.log("I can handle this");
}
// can't handle, pass it down
throw err;
}
}
function b() {
try {
c();
} catch (err) {
if (err.code === "INPUT_ERROR") {
return console.log("I can handle this");
}
// can't handle, pass it down
throw err;
}
}
function c() {
throw new Error({
code: "INPUT_ERROR",
message: "an error occured",
});
}
a();
而如果 c 中拋出的是別的異常,比如網(wǎng)絡(luò)異常,那么 b 是無法處理的,雖然 b catch 住了,但是由于你無法處理,因此一個好的做法是繼續(xù)拋出異常,而不是「吞沒」異常。不要畏懼錯誤,拋出它。「只有沒有被捕獲的異常才是可怕的」,如果一個錯誤可以被捕獲并得到正確處理,它就不可怕。
舉個例子:
function a() {
try {
b();
} catch (err) {
if (err.code === "NETWORK_ERROR") {
return console.log("I can handle this");
}
// can't handle, pass it down
throw err;
}
}
function b() {
try {
c();
} catch (err) {
if (err.code === "INPUT_ERROR") {
return console.log("I can handle this");
}
}
}
function c() {
throw new Error({
code: "NETWORK_ERROR",
message: "an error occured",
});
}
a();
如上代碼不會有任何異常被拋出,它被完全吞沒了,這對我們調(diào)試問題簡直是災(zāi)難。因此切記「不要吞沒你不能處理的異常」。正確的做法應(yīng)該是上面講的那種「只 catch 你可以處理的異常,而將你不能處理的異常 throw 出來」,這就是責(zé)任鏈模式的典型應(yīng)用。
這只是一個簡單的例子,就足以繞半天。實際業(yè)務(wù)肯定比這個復(fù)雜多得多。因此異常處理絕對不是一件容易的事情。
如果說誰來處理是一件困難的事情,那么在異步中決定誰來處理異常就是難上加難,我們來看下。
同步與異步
同步異步一直是前端難以跨越的坎,對于異常處理也是一樣。以 NodeJS 中用的比較多的「讀取文件」 API 為例。它有兩個版本,一個是異步,一個是同步。同步讀取僅僅應(yīng)該被用在沒了這個文件無法進行下去的時候。比如讀取一個配置文件。而不應(yīng)該在比如瀏覽器中讀取用戶磁盤上的一個圖片等,這樣會造成主線程阻塞,導(dǎo)致瀏覽器卡死。
// 異步讀取文件
fs.readFileSync();
// 同步讀取文件
fs.readFile();
當(dāng)我們試圖「同步」讀取一個不存在的文件的時候,會拋出以下異常:
fs.readFileSync('something-not-exist.lucifer');
console.log('腦洞前端');
Thrown:
Error: ENOENT: no such file or directory, open 'something-not-exist.lucifer'
at Object.openSync (fs.js:446:3)
at Object.readFileSync (fs.js:348:35) {
errno: -2,
syscall: 'open',
code: 'ENOENT',
path: 'something-not-exist.lucifer'
}
并且腦洞前端是不會被打印出來的。這個比較好理解,我們上面已經(jīng)解釋過了。
而如果以異步方式的話:
fs.readFile('something-not-exist.lucifer', (err, data) => {if(err) {throw err}});
console.log('lucifer')
lucifer
undefined
Thrown:
[Error: ENOENT: no such file or directory, open 'something-not-exist.lucifer'] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: 'something-not-exist.lucifer'
}
>
腦洞前端是會被打印出來的。
其本質(zhì)在于 fs.readFile 的函數(shù)調(diào)用已經(jīng)成功,并從調(diào)用棧返回并執(zhí)行到下一行的console.log('lucifer')。因此錯誤發(fā)生的時候,調(diào)用棧是空的,這一點可以從上面的錯誤堆棧信息中看出來。
?不明白為什么調(diào)用棧是空的同學(xué)可以看下我之前寫的《一文看懂瀏覽器事件循環(huán)》[4]
?
而 try catch 的作用僅僅是捕獲當(dāng)前調(diào)用棧的錯誤(上面異常傳播部分已經(jīng)講過了)。因此異步的錯誤是無法捕獲的,比如;
try {
fs.readFile("something-not-exist.lucifer", (err, data) => {
if (err) {
throw err;
}
});
} catch (err) {
console.log("catching an error");
}
上面的 catching an error 不會被打印。因為錯誤拋出的時候, 調(diào)用棧中不包含這個 catch 語句,而僅僅在執(zhí)行fs.readFile的時候才會。
如果我們換成同步讀取文件的例子看看:
try {
fs.readFileSync("something-not-exist.lucifer");
} catch (err) {
console.log("catching an error");
}
上面的代碼會打印 catching an error。因為讀取文件被同步發(fā)起,文件返回之前線程會被掛起,當(dāng)線程恢復(fù)執(zhí)行的時候, fs.readFileSync 仍然在函數(shù)調(diào)用棧中,因此 fs.readFileSync 產(chǎn)生的異常會冒泡到 catch 語句。
簡單來說就是「異步產(chǎn)生的錯誤不能用 try catch 捕獲,而要使用回調(diào)捕獲。」
可能有人會問了,我見過用 try catch 捕獲異步異常啊。比如:
rejectIn = (ms) =>
new Promise((_, r) => {
setTimeout(() => {
r(1);
}, ms);
});
async function t() {
try {
await rejectIn(0);
} catch (err) {
console.log("catching an error", err);
}
}
t();
本質(zhì)上這只是一個語法糖,是 Promise.prototype.catch 的一個語法糖而已。而這一語法糖能夠成立的原因在于其用了 Promise 這種包裝類型。如果你不用包裝類型,比如上面的 fs.readFile 不用 Promise 等包裝類型包裝,打死都不能用 try catch 捕獲。
而如果我們使用 babel 轉(zhuǎn)義下,會發(fā)現(xiàn) try catch 不見了,變成了 switch case 語句。這就是 try catch “可以捕獲異步異常”的原因,僅此而已,沒有更多。
(babel 轉(zhuǎn)義結(jié)果)
我使用的 babel 轉(zhuǎn)義環(huán)境都記錄在這里[5],大家可以直接點開鏈接查看.
?雖然瀏覽器并不像 babel 轉(zhuǎn)義這般實現(xiàn),但是至少我們明白了一點。目前的 try catch 的作用機制是無法捕獲異步異常的。
?
異步的錯誤處理推薦使用容器包裝,比如 Promise。然后使用 catch 進行處理。實際上 Promise 的 catch 和 try catch 的 catch 有很多相似的地方,大家可以類比過去。
和同步處理一樣,很多原則都是通用的。比如異步也不要去吞沒異常。下面的代碼是不好的,因為它吞沒了「它不能處理的」異常。
p = Promise.reject(1);
p.catch(() => {});
更合適的做法的應(yīng)該是類似這種:
p = Promise.reject(1);
p.catch((err) => {
if (err == 1) {
return console.log("I can handle this");
}
throw err;
});
徹底消除運行時異常可能么?
我個人對目前前端現(xiàn)狀最為頭疼的一點是:「大家過分依賴運行時,而嚴重忽略編譯時」。我見過很多程序,你如果不運行,根本不知道程序是怎么走的,每個變量的 shape 是什么。怪不得處處都可以看到 console.log。我相信你一定對此感同身受。也許你就是那個寫出這種代碼的人,也許你是給別人擦屁股的人。為什么會這樣?就是因為大家太依賴運行時。TS 的出現(xiàn)很大程度上改善了這一點,前提是你用的是 typescript,而不是 anyscript。其實 eslint 以及 stylint 對此也有貢獻,畢竟它們都是靜態(tài)分析工具。
我強烈建議將異常保留在編譯時,而不是運行時。不妨極端一點來看:假如所有的異常都在編譯時發(fā)生,而一定不會在運行時發(fā)生。那么我們是不是就可以「信心滿滿」地對應(yīng)用進行重構(gòu)啦?
幸運的是,我們能夠做到。只不過如果當(dāng)前語言做不到的話,則需要對現(xiàn)有的語言體系進行改造。這種改造成本真的很大。不僅僅是 API,編程模型也發(fā)生了翻天覆地的變化,不然函數(shù)式也不會這么多年沒有得到普及了。
?不熟悉函數(shù)編程的可以看看我之前寫的函數(shù)式編程入門篇[6]。
?
如果才能徹底消除異常呢?在回答這個問題之前,我們先來看下一門號稱「沒有運行時異常」的語言 elm。elm 是一門可以編譯為 JS 的函數(shù)式編程語言,其封裝了諸如網(wǎng)絡(luò) IO 等副作用,是一種聲明式可推導(dǎo)的語言。有趣的是,elm 也有異常處理。elm 中關(guān)于異常處理(Error Handling)部分有兩個小節(jié)的內(nèi)容,分別是:Maybe 和 Result。elm 之所以沒有運行時異常的一個原因就是它們。一句話概括“為什么 elm 沒有異常”的話,那就是「elm 把異常看作數(shù)據(jù)(data)」。
舉個簡單的例子:
maybeResolveOrNot = (ms) =>
setTimeout(() => {
if (Math.random() > 0.5) {
console.log("ok");
} else {
throw new Error("error");
}
});
上面的代碼有一半的可能報錯。那么在 elm 中就不允許這樣的情況發(fā)生。所有的可能發(fā)生異常的代碼都會被強制包裝一層容器,這個容器在這里是 Maybe。

在其他函數(shù)式編程語言名字可能有所不同,但是意義相同。實際上,不僅僅是異常,正常的數(shù)據(jù)也會被包裝到容器中,你需要通過容器的接口來獲取數(shù)據(jù)。如果難以理解的話,你可以將其簡單理解為 Promsie(但并不完全等價)。
Maybe 可能返回正常的數(shù)據(jù) data,也可能會生成一個錯誤 error。某一個時刻只能是其中一個,并且只有運行的時候,我們才真正知道它是什么。從這一點來看,有點像薛定諤的貓。

不過 Maybe 已經(jīng)完全考慮到異常的存在,一切都在它的掌握之中。所有的異常都能夠在編譯時推導(dǎo)出來。當(dāng)然要想推導(dǎo)出這些東西,你需要對整個編程模型做一定的封裝會抽象,比如 DOM 就不能直接用了,而是需要一個中間層。
再來看下一個更普遍的例子 NPE:
null.toString();
elm 也不會發(fā)生。原因也很簡單,因為 null 也會被包裝起來,當(dāng)你通過這個包裝類型就行訪問的時候,容器有能力避免這種情況,因此就可以不會發(fā)生異常。當(dāng)然這里有一個很重要的前提就是「可推導(dǎo)」,而這正是函數(shù)式編程語言的特性。這部分內(nèi)容超出了本文的討論范圍,不再這里說了。
運行時異常可以恢復(fù)么?
最后要討論的一個主題是運行時異常是否可以恢復(fù)。先來解釋一下,什么是運行時異常的恢復(fù)。還是用上面的例子:
function t() {
console.log("start");
throw 1;
console.log("end");
}
t();
這個我們已經(jīng)知道了, end 是不會打印的。盡管你這么寫也是無濟于事:
function t() {
try {
console.log("start");
throw 1;
console.log("end");
} catch (err) {
console.log("relax, I can handle this");
}
}
t();
如果我想讓它打印呢?我想讓程序面對異常可以自己 recover 怎么辦?我已經(jīng)捕獲這個錯誤, 并且我確信我可以處理,讓流程繼續(xù)走下去吧!如果有能力做到這個,這個就是「運行時異常恢復(fù)」。
遺憾地告訴你,據(jù)我所知,目前沒有任何一個引擎能夠做到這一點。
這個例子過于簡單, 只能幫助我們理解什么是運行時異常恢復(fù),但是不足以讓我們看出這有什么用?

我們來看一個更加復(fù)雜的例子,我們這里直接使用上面實現(xiàn)過的函數(shù)divide。
function t() {
try {
const res = divide("foo", "bar");
alert(`you got ${res}`);
} catch (err) {
if (err.code === 1) {
return console.log("被除數(shù)必須是除0之外的數(shù)");
}
if (err.code === 2) {
return console.log("除數(shù)必須是數(shù)字");
}
throw new Error("不可預(yù)知的錯誤");
}
}
如上代碼,會進入 catch ,而不會 alert。因此對于用戶來說, 應(yīng)用程序是沒有任何響應(yīng)的。這是不可接受的。
?要吐槽一點的是這種事情真的是挺常見的,只不過大家用的不是 alert 罷了。
?
如果我們的代碼在進入 catch 之后還能夠繼續(xù)返回出錯位置繼續(xù)執(zhí)行就好了。

如何實現(xiàn)異常中斷的恢復(fù)呢?我剛剛說了:據(jù)我所知,目前沒有任何一個引擎能夠做到「異常恢復(fù)」。那么我就來「發(fā)明一個新的語法」解決這個問題。
function t() {
try {
const res = divide("foo", "bar");
alert(`you got ${res}`);
} catch (err) {
console.log("releax, I can handle this");
resume - 1;
}
}
t();
上面的 resume 是我定義的一個關(guān)鍵字,功能是如果遇到異常,則返回到異常發(fā)生的地方,然后給當(dāng)前發(fā)生異常的函數(shù)一個返回值 「-1」,并使得后續(xù)代碼能夠正常運行,不受影響。這其實是一種 fallback。
這絕對是一個超前的理念。當(dāng)然挑戰(zhàn)也非常大,對現(xiàn)有的體系沖擊很大,很多東西都要改。我希望社區(qū)可以考慮把這個東西加到標準。
最佳實踐
通過前面的學(xué)習(xí),你已經(jīng)知道了異常是什么,異常是怎么產(chǎn)生的,以及如何正確處理異常(同步和異步)。接下來,我們談一下異常處理的最佳實踐。
我們平時開發(fā)一個應(yīng)用。如果站在生產(chǎn)者和消費者的角度來看的話。當(dāng)我們使用別人封裝的框架,庫,模塊,甚至是函數(shù)的時候,我們就是消費者。而當(dāng)我們寫的東西被他人使用的時候,我們就是生產(chǎn)者。
實際上,就算是生產(chǎn)者內(nèi)部也會有多個模塊構(gòu)成,多個模塊之間也會有生產(chǎn)者和消費者的再次身份轉(zhuǎn)化。不過為了簡單起見,本文不考慮這種關(guān)系。這里的生產(chǎn)者指的就是給他人使用的功能,是純粹的生產(chǎn)者。
從這個角度出發(fā),來看下異常處理的最佳實踐。
作為消費者
當(dāng)作為消費者的時候,我們關(guān)心的是使用的功能是否會拋出異常,如果是,他們有哪些異常。比如:
import foo from "lucifer";
try {
foo.bar();
} catch (err) {
// 有哪些異常?
}
當(dāng)然,理論上 foo.bar 可能產(chǎn)生任何異常,而不管它的 API 是這么寫的。但是我們關(guān)心的是「可預(yù)期的異常」。因此你一定希望這個時候有一個 API 文檔,詳細列舉了這個 API 可能產(chǎn)生的異常有哪些。
比如這個 foo.bar 4 種可能的異常 分別是 A,B,C 和 D。其中 A 和 B 是我可以處理的,而 C 和 D 是我不能處理的。那么我應(yīng)該:
import foo from "lucifer";
try {
foo.bar();
} catch (err) {
if (err.code === "A") {
return console.log("A happened");
}
if (err.code === "B") {
return console.log("B happened");
}
throw err;
}
可以看出,不管是 C 和 D,還是 API 中沒有列舉的各種可能異常,我們的做法都是直接拋出。

作為生產(chǎn)者
如果你作為生產(chǎn)者,你要做的就是提供上面提到的詳細的 API,告訴消費者你的可能錯誤有哪些。這樣消費者就可以在 catch 中進行相應(yīng)判斷,處理異常情況。

你可以提供類似上圖的錯誤表,讓大家可以很快知道可能存在的「可預(yù)知」異常有哪些。不得不吐槽一句,在這一方面很多框架,庫做的都很差。希望大家可以重視起來,努力維護良好的前端開發(fā)大環(huán)境。
總結(jié)
本文很長,如果你能耐心看完,你真得給可以給自己鼓個掌 ??????。
我從什么是異常,以及異常的分類,讓大家正確認識異常,簡單來說異常就是一種數(shù)據(jù)結(jié)構(gòu)而已。
接著,我又講到了異常的傳播和處理。這兩個部分是緊密聯(lián)系的。異常的傳播和事件傳播沒有本質(zhì)不同,主要不同是數(shù)據(jù)結(jié)構(gòu)不同,思想是類似的。具體來說異常會從發(fā)生錯誤的調(diào)用處,沿著調(diào)用棧回退,直到第一個 catch 語句或者棧為空。如果棧為空都沒有碰到一個 catch,則會拋出「uncaught Error」。需要特別注意的是異步的異常處理,不過你如果對我講的原理了解了,這都不是事。
然后,我提出了兩個腦洞問題:
徹底消除運行時異常可能么? 運行時異常可以恢復(fù)么?
這兩個問題非常值得研究,但由于篇幅原因,我這里只是給你講個輪廓而已。如果你對這兩個話題感興趣,可以和我交流。
最后,我提到了前端異常處理的最佳實踐。大家通過兩種角色(生產(chǎn)者和消費者)的轉(zhuǎn)換,認識一下不同決定關(guān)注點以及承擔(dān)責(zé)任的不同。具體來說提到了 「明確聲明可能的異常」以及 「處理你應(yīng)該處理的,不要吞沒你不能處理的異常」。當(dāng)然這個最佳實踐仍然是輪廓性的。如果大家想要一份 前端最佳實踐 checklist,可以給我留言。留言人數(shù)較多的話,我考慮專門寫一個前端最佳實踐 checklist 類型的文章。
Reference
Null Pointer Exception: https://zh.wikipedia.org/wiki/%E7%A9%BA%E6%8C%87%E6%A8%99#NullPointerException
[2]瀏覽器事件模型: https://lucifer.ren/blog/2019/12/11/browser-event/
[3]Go 源碼關(guān)于錯誤測試部分: https://github.com/golang/go/blob/master/src/os/error_test.go
[4]《一文看懂瀏覽器事件循環(huán)》: https://lucifer.ren/blog/2019/12/11/event-loop/
[5]babel 轉(zhuǎn)義環(huán)境: https://babeljs.io/repl#?browsers=defaults%2C%20not%20ie%2011%2C%20not%20ie_mob%2011&build=&builtIns=usage&spec=true&loose=true&code_lz=E4UwViDGAuCSB2ACAvIgFAWwM4EoUD4AoRReEAd0QAVgB7DASyxDTQH0AaRYPZfRAN7ESiZtAAqDDCFoBXaK178hIkcDQBGHAG5hJAL5dsO4fpMBDLAE94kRADNZt6A1pIFeFYmjArgvYjm5OYM0NzgUHDwaAAMJgaIkObQkAAW6CDAPP6qkG5YtAA2IAB0hbQA5mgAREkpqQzwFYFImXTA1Vxt8Yj6hH2EHtpAA&debug=false&forceAllTransforms=true&shippedProposals=true&circleciRepo=&evaluate=false&fileSize=false&timeTravel=false&sourceType=module&lineWrap=false&presets=env%2Ces2015%2Ces2016%2Ces2017%2Creact%2Cstage-0%2Cstage-1%2Cstage-2%2Cstage-3%2Ces2015-loose%2Ctypescript%2Cflow%2Cenv&prettier=false&targets=Electron-1.8%252CNode-10.13&version=7.10.2&externalPlugins=%40babel%2Fplugin-transform-arrow-functions%407.8.3
[6]函數(shù)式編程入門篇: https://github.com/azl397985856/functional-programming
推薦閱讀
2、前端動畫必知必會:React 和 Vue 都在用的 FLIP 思想實戰(zhàn)。
5、或許是一本可以徹底改變你刷 LeetCode 效率的題解書
6、迎接Vue3.0 | 在Vue2與Vue3中構(gòu)建相同的組件
7、vite —— 一種新的、更快地 web 開發(fā)工具
?關(guān)注加加,星標加加~
?
如果覺得文章不錯,幫忙點個在看唄
