<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          你不知道的前端異常處理(萬字長文,建議收藏)

          共 22639字,需瀏覽 46分鐘

           ·

          2021-04-04 05:28


          除了調(diào)試,處理異常或許是程序員編程時間占比最高的了。我們天天和各種異常打交道,就好像我們天天和 Bug 打交道一樣。因此正確認(rèn)識異常,并作出合適的異常處理就顯得很重要了。

          我們先嘗試拋開前端這個限定條件,來看下更廣泛意義上程序的報錯以及異常處理。不管是什么語言,都會有異常的發(fā)生。而我們程序員要做的就是正確識別程序中的各種異常,并針對其做相應(yīng)的「異常處理」

          然而,很多人對異常的處理方式是「事后修補」,即某個異常發(fā)生的時候,增加對應(yīng)的條件判斷,這真的是一種非常低效的開發(fā)方式,非常不推薦大家這么做。那么究竟如何正確處理異常呢?由于不同語言有不同的特性,因此異常處理方式也不盡相同。但是異常處理的思維框架一定是一致的。本文就「前端」異常進行詳細(xì)闡述,但是讀者也可以稍加修改延伸到其他各個領(lǐng)域。

          ?

          本文討論的異常指的是軟件異常,而非硬件異常。

          ?

          什么是異常

          用直白的話來解釋異常的話,就是「程序發(fā)生了意想不到的情況,這種情況影響到了程序的正確運行」

          從根本上來說,異常就是一個「數(shù)據(jù)結(jié)構(gòu)」,其保存了異常發(fā)生的相關(guān)信息,比如錯誤碼,錯誤信息等。以 JS 中的標(biāo)準(zhǔn)內(nèi)置對象 Error 為例,其標(biāo)準(zhǔn)屬性有 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 語言處理。未被捕獲的異常通常會被打印在控制臺上,里面有詳細(xì)的堆棧信息,從而幫助程序員快速排查問題。實際上我們的程序的目標(biāo)是「避免 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ū)分就行,這沒有一個嚴(yán)格的參考標(biāo)準(zhǔn)。我們先來看下拋出異常,告訴調(diào)用者「你的輸入,我處理不了」這種情況。

          function divide(a, b{
            a = +a;
            b = +b; // 轉(zhuǎn)化成數(shù)字
            if (!b) {
              // 匹配 +0, -0, NaN
              throw new Error({
                code1,
                message"Invalid dividend " + b,
              });
            }
            if (Number.isNaN(a)) {
              // 匹配 NaN
              throw new Error({
                code2,
                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({
                code1,
                message"Invalid dividend " + b,
              });
            }
            if (Number.isNaN(a)) {
              // 匹配 NaN
              return new Error({
                code2,
                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」啦,僅僅在控制臺顯示了「標(biāo)準(zhǔn)輸出」,而「非錯誤輸出」(因為我用的是 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é)任鏈模式,不過細(xì)節(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)狀最為頭疼的一點是:「大家過分依賴運行時,而嚴(yá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)容,分別是:MaybeResult。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ū)可以考慮把這個東西加到標(biāo)準(zhǔn)。

          最佳實踐

          通過前面的學(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 文檔,詳細(xì)列舉了這個 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)者,你要做的就是提供上面提到的詳細(xì)的 API,告訴消費者你的可能錯誤有哪些。這樣消費者就可以在 catch 中進行相應(yīng)判斷,處理異常情況。

          你可以提供類似上圖的錯誤表,讓大家可以很快知道可能存在的「可預(yù)知」異常有哪些。不得不吐槽一句,在這一方面很多框架,庫做的都很差。希望大家可以重視起來,努力維護良好的前端開發(fā)大環(huán)境。

          總結(jié)

          本文很長,如果你能耐心看完,你真得給可以給自己鼓個掌 ??????。

          我從什么是異常,以及異常的分類,讓大家正確認(rèn)識異常,簡單來說異常就是一種數(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)換,認(rèn)識一下不同決定關(guān)注點以及承擔(dān)責(zé)任的不同。具體來說提到了 「明確聲明可能的異常」以及 「處理你應(yīng)該處理的,不要吞沒你不能處理的異常」。當(dāng)然這個最佳實踐仍然是輪廓性的。如果大家想要一份 前端最佳實踐 checklist,可以給我留言。留言人數(shù)較多的話,我考慮專門寫一個前端最佳實踐 checklist 類型的文章。

          Reference

          [1]

          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


          最后



          如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個小忙:

          1. 點個「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點在看,都是耍流氓 -_-)

          2. 歡迎加我微信「qianyu443033099」拉你進技術(shù)群,長期交流學(xué)習(xí)...

          3. 關(guān)注公眾號「前端下午茶」,持續(xù)為你推送精選好文,也可以加我為好友,隨時聊騷。

          點個在看支持我吧,轉(zhuǎn)發(fā)就更好了


          瀏覽 30
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  国产又粗又大操逼视频 | 台湾无码在线观看 | 骚虎最新网站 | 国产成人无码AV一区二区网站 | 俺去也在线www色官网 |