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

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

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

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

我們可以清楚地看出函數(shù)的調(diào)用關(guān)系。即錯(cuò)誤是在 c 中發(fā)生的,而 c 是 b 調(diào)用的,b 是 a 調(diào)用的。這個(gè)函數(shù)調(diào)用棧是為了方便開發(fā)者定位問(wèn)題而存在的。
上面的代碼,我們并沒有 catch 錯(cuò)誤,因此上面才會(huì)有「uncaught Error」。
那么如果我們 catch ,會(huì)發(fā)生什么樣的變化呢?catch 的位置會(huì)對(duì)結(jié)果產(chǎn)生什么樣的影響?在 a ,b,c 中 catch 的效果是一樣的么?
我們來(lái)分別看下:
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í)行, 會(huì)在控制臺(tái)顯示如下輸出:

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

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

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

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

我們來(lái)看一個(gè)更加復(fù)雜的例子,我們這里直接使用上面實(shí)現(xiàn)過(guò)的函數(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ù)知的錯(cuò)誤");
??}
}
如上代碼,會(huì)進(jìn)入 catch ,而不會(huì) alert。因此對(duì)于用戶來(lái)說(shuō), 應(yīng)用程序是沒有任何響應(yīng)的。這是不可接受的。
?要吐槽一點(diǎn)的是這種事情真的是挺常見的,只不過(guò)大家用的不是 alert 罷了。
?
如果我們的代碼在進(jìn)入 catch 之后還能夠繼續(xù)返回出錯(cuò)位置繼續(xù)執(zhí)行就好了。

如何實(shí)現(xiàn)異常中斷的恢復(fù)呢?我剛剛說(shuō)了:據(jù)我所知,目前沒有任何一個(gè)引擎能夠做到「異?;謴?fù)」。那么我就來(lái)「發(fā)明一個(gè)新的語(yǔ)法」解決這個(gè)問(wèn)題。
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 是我定義的一個(gè)關(guān)鍵字,功能是如果遇到異常,則返回到異常發(fā)生的地方,然后給當(dāng)前發(fā)生異常的函數(shù)一個(gè)返回值 「-1」,并使得后續(xù)代碼能夠正常運(yùn)行,不受影響。這其實(shí)是一種 fallback。
這絕對(duì)是一個(gè)超前的理念。當(dāng)然挑戰(zhàn)也非常大,對(duì)現(xiàn)有的體系沖擊很大,很多東西都要改。我希望社區(qū)可以考慮把這個(gè)東西加到標(biāo)準(zhǔn)。
最佳實(shí)踐
通過(guò)前面的學(xué)習(xí),你已經(jīng)知道了異常是什么,異常是怎么產(chǎn)生的,以及如何正確處理異常(同步和異步)。接下來(lái),我們談一下異常處理的最佳實(shí)踐。
我們平時(shí)開發(fā)一個(gè)應(yīng)用。如果站在生產(chǎn)者和消費(fèi)者的角度來(lái)看的話。當(dāng)我們使用別人封裝的框架,庫(kù),模塊,甚至是函數(shù)的時(shí)候,我們就是消費(fèi)者。而當(dāng)我們寫的東西被他人使用的時(shí)候,我們就是生產(chǎn)者。
實(shí)際上,就算是生產(chǎn)者內(nèi)部也會(huì)有多個(gè)模塊構(gòu)成,多個(gè)模塊之間也會(huì)有生產(chǎn)者和消費(fèi)者的再次身份轉(zhuǎn)化。不過(guò)為了簡(jiǎn)單起見,本文不考慮這種關(guān)系。這里的生產(chǎn)者指的就是給他人使用的功能,是純粹的生產(chǎn)者。
從這個(gè)角度出發(fā),來(lái)看下異常處理的最佳實(shí)踐。
作為消費(fèi)者
當(dāng)作為消費(fèi)者的時(shí)候,我們關(guān)心的是使用的功能是否會(huì)拋出異常,如果是,他們有哪些異常。比如:
import?foo?from?"lucifer";
try?{
??foo.bar();
}?catch?(err)?{
??//?有哪些異常?
}
當(dāng)然,理論上 foo.bar 可能產(chǎn)生任何異常,而不管它的 API 是這么寫的。但是我們關(guān)心的是「可預(yù)期的異?!?/strong>。因此你一定希望這個(gè)時(shí)候有一個(gè) API 文檔,詳細(xì)列舉了這個(gè) API 可能產(chǎn)生的異常有哪些。
比如這個(gè) 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)者,你要做的就是提供上面提到的詳細(xì)的 API,告訴消費(fèi)者你的可能錯(cuò)誤有哪些。這樣消費(fèi)者就可以在 catch 中進(jìn)行相應(yīng)判斷,處理異常情況。

你可以提供類似上圖的錯(cuò)誤表,讓大家可以很快知道可能存在的「可預(yù)知」異常有哪些。不得不吐槽一句,在這一方面很多框架,庫(kù)做的都很差。希望大家可以重視起來(lái),努力維護(hù)良好的前端開發(fā)大環(huán)境。
總結(jié)
本文很長(zhǎng),如果你能耐心看完,你真得給可以給自己鼓個(gè)掌 ???。
我從什么是異常,以及異常的分類,讓大家正確認(rèn)識(shí)異常,簡(jiǎn)單來(lái)說(shuō)異常就是一種數(shù)據(jù)結(jié)構(gòu)而已。
接著,我又講到了異常的傳播和處理。這兩個(gè)部分是緊密聯(lián)系的。異常的傳播和事件傳播沒有本質(zhì)不同,主要不同是數(shù)據(jù)結(jié)構(gòu)不同,思想是類似的。具體來(lái)說(shuō)異常會(huì)從發(fā)生錯(cuò)誤的調(diào)用處,沿著調(diào)用棧回退,直到第一個(gè) catch 語(yǔ)句或者棧為空。如果棧為空都沒有碰到一個(gè) catch,則會(huì)拋出「uncaught Error」。需要特別注意的是異步的異常處理,不過(guò)你如果對(duì)我講的原理了解了,這都不是事。
然后,我提出了兩個(gè)腦洞問(wèn)題:
徹底消除運(yùn)行時(shí)異??赡苊?? 運(yùn)行時(shí)異??梢曰謴?fù)么?
這兩個(gè)問(wèn)題非常值得研究,但由于篇幅原因,我這里只是給你講個(gè)輪廓而已。如果你對(duì)這兩個(gè)話題感興趣,可以和我交流。
最后,我提到了前端異常處理的最佳實(shí)踐。大家通過(guò)兩種角色(生產(chǎn)者和消費(fèi)者)的轉(zhuǎn)換,認(rèn)識(shí)一下不同決定關(guān)注點(diǎn)以及承擔(dān)責(zé)任的不同。具體來(lái)說(shuō)提到了 「明確聲明可能的異?!?/strong>以及 「處理你應(yīng)該處理的,不要吞沒你不能處理的異?!?/strong>。當(dāng)然這個(gè)最佳實(shí)踐仍然是輪廓性的。如果大家想要一份 前端最佳實(shí)踐 checklist,可以給我留言。留言人數(shù)較多的話,我考慮專門寫一個(gè)前端最佳實(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)于錯(cuò)誤測(cè)試部分: 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
推薦閱讀
1、用TypeScript編寫React的最佳實(shí)踐
2、前端動(dòng)畫必知必會(huì):React 和 Vue 都在用的 FLIP 思想實(shí)戰(zhàn)。
5、或許是一本可以徹底改變你刷 LeetCode 效率的題解書
6、迎接Vue3.0 | 在Vue2與Vue3中構(gòu)建相同的組件
7、vite —— 一種新的、更快地 web 開發(fā)工具
?關(guān)注加加,星標(biāo)加加~
?
如果覺得文章不錯(cuò),幫忙點(diǎn)個(gè)在看唄
