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

lucifer發表於2020-06-20

除了除錯,處理異常或許是程式設計師程式設計時間佔比最高的了。我們天天和各種異常打交道,就好像我們天天和 Bug 打交道一樣。因此正確認識異常,並作出合適的異常處理就顯得很重要了。

我們先嚐試拋開前端這個限定條件,來看下更廣泛意義上程式的報錯以及異常處理。不管是什麼語言,都會有異常的發生。而我們程式設計師要做的就是正確識別程式中的各種異常,並針對其做相應的異常處理

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

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

什麼是異常

用直白的話來解釋異常的話,就是程式發生了意想不到的情況,這種情況影響到了程式的正確執行

從根本上來說,異常就是一個資料結構,其儲存了異常發生的相關資訊,比如錯誤碼,錯誤資訊等。以 JS 中的標準內建物件 Error 為例,其標準屬性有 name 和 message。然而不同的瀏覽器廠商有自己的自定義屬性,這些屬性並不通用。比如 Mozilla 瀏覽器就增加了 filename 和 stack 等屬性。

值得注意的是錯誤只有被丟擲,才會產生異常,不被丟擲的錯誤不會產生異常。比如:

function t() {
  console.log("start");
  new Error();
  console.log("end");
}
t();

(動畫演示)

這段程式碼不會產生任何的異常,控制檯也不會有任何錯誤輸出。

異常的分類

按照產生異常時程式是否正在執行,我們可以將錯誤分為編譯時異常執行時異常

編譯時異常指的是原始碼在編譯成可執行程式碼之前產生的異常。而執行時異常指的是可執行程式碼被裝載到記憶體中執行之後產生的異常。

編譯時異常

我們知道 TS 最終會被編譯成 JS,從而在 JS Runtime中執行。既然存在編譯,就有可能編譯失敗,就會有編譯時異常。

比如我使用 TS 寫出瞭如下程式碼:

const s: string = 123;

這很明顯是錯誤的程式碼, 我給 s 宣告瞭 string 型別,但是卻給它賦值 number。

當我使用 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.

這個異常就是編譯時異常,因為我的程式碼還沒有執行。

然而並不是你用了 TS 才存在編譯時異常,JS 同樣有編譯時異常。有的人可能會問 JS 不是解釋性語言麼?是邊解釋邊執行,沒有編譯環節,怎麼會有編譯時異常?

別急,我舉個例子你就明白了。如下程式碼:

function t() {
  console.log('start')
  await sa
  console.log('end')
}
t()

上面的程式碼由於存在語法錯誤,不會編譯通過,因此並不會列印start,側面證明了這是一個編譯時異常。儘管 JS 是解釋語言,也依然存在編譯階段,這是必然的,因此自然也會有編譯異常。

總的來說,編譯異常可以在程式碼被編譯成最終程式碼前被發現,因此對我們的傷害更小。接下來,看一下令人心生畏懼的執行時異常

執行時異常

相信大家對執行時異常非常熟悉。這恐怕是廣大前端碰到最多的異常型別了。眾所周知的 NPE(Null Pointer Exception) 就是執行時異常。

將上面的例子稍加改造,得到下面程式碼:

function t() {
  console.log("start");
  throw 1;
  console.log("end");
}
t();

(動畫演示)

注意 end 沒有列印,並且 t 沒有彈出棧。實際上 t 最終還是會被彈出的,只不過和普通的返回不一樣。

如上,則會列印出start。由於異常是在程式碼執行過程中丟擲的,因此這個異常屬於執行時異常。相對於編譯時異常,這種異常更加難以發現。上面的例子可能比較簡單,但是如果我的異常是隱藏在某一個流程控制語句(比如 if else)裡面呢?程式就可能在客戶的電腦走入那個丟擲異常的 if 語句,而在你的電腦走入另一條。這就是著名的 《在我電腦上好好的》 事件。

異常的傳播

異常的傳播和我之前寫的瀏覽器事件模型有很大的相似性。只不過那個是作用在 DOM 這樣的資料結構,這個則是作用在函式呼叫棧這種資料結構,並且事件傳播存在捕獲階段,異常傳播是沒有的。不同 C 語言,JS 中異常傳播是自動的,不需要程式設計師手動地一層層傳遞。如果一個異常沒有被 catch,它會沿著函式呼叫棧一層層傳播直到棧空。

異常處理中有兩個關鍵詞,它們是throw(丟擲異常)catch(處理異常)。 當一個異常被丟擲的時候,異常的傳播就開始了。異常會不斷傳播直到遇到第一個 catch。 如果程式設計師沒有手動 catch,那麼一般而言程式會丟擲類似unCaughtError,表示發生了一個異常,並且這個異常沒有被程式中的任何 catch 語言處理。未被捕獲的異常通常會被列印在控制檯上,裡面有詳細的堆疊資訊,從而幫助程式設計師快速排查問題。實際上我們的程式的目標是避免 unCaughtError這種異常,而不是一般性的異常。

一點小前提

由於 JS 的 Error 物件沒有 code 屬性,只能根據 message 來呈現,不是很方便。我這裡進行了簡單的擴充套件,後面很多地方我用的都是自己擴充套件的 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

(程式自動丟擲的例子)

自動丟擲異常很好理解,畢竟我們哪個程式設計師沒有看到過程式自動丟擲的異常呢?

“這個異常突然就跳出來!嚇我一跳!”,某不知名程式設計師如是說。

那什麼時候應該手動丟擲異常呢?

一個指導原則就是你已經預知到程式不能正確進行下去了。比如我們要實現除法,首先我們要考慮的是被除數為 0 的情況。當被除數為 0 的時候,我們應該怎麼辦呢?是丟擲異常,還是 return 一個特殊值?答案是都可以,你自己能區分就行,這沒有一個嚴格的參考標準。 我們先來看下丟擲異常,告訴呼叫者你的輸入,我處理不了這種情況。

function divide(a, b) {
  a = +a;
  b = +b; // 轉化成數字
  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;
}

上面程式碼會在兩種情況下丟擲異常,告訴呼叫者你的輸入我處理不了。由於這兩個異常都是程式設計師自動手動丟擲的,因此是可預知的異常

剛才說了,我們也可以通過返回值來區分異常輸入。我們來看下返回值輸入是什麼,以及和異常有什麼關係。

異常 or 返回

如果是基於異常形式(遇到不能處理的輸入就丟擲異常)。當別的程式碼呼叫divide的時候,需要自己 catch。

function t() {
  try {
    divide("foo", "bar");
  } catch (err) {
    if (err.code === 1) {
      return console.log("被除數必須是除0之外的數");
    }
    if (err.code === 2) {
      return console.log("除數必須是數字");
    }
    throw new Error("不可預知的錯誤");
  }
}

然而就像上面我說的那樣,divide 函式設計的時候,也完全可以不用異常,而是使用返回值來區分。

function divide(a, b) {
  a = +a;
  b = +b; // 轉化成數字
  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;
}

當然,我們使用方式也要作出相應改變。

function t() {
  const res = divide("foo", "bar");

  if (res.code === 1) {
    return console.log("被除數必須是除0之外的數");
  }
  if (res.code === 2) {
    return console.log("除數必須是數字");
  }
  return new Error("不可預知的錯誤");
}

這種函式設計方式和丟擲異常的設計方式從功能上說都是一樣的,只是告訴呼叫方的方式不同。如果你選擇第二種方式,而不是丟擲異常,那麼實際上需要呼叫方書寫額外的程式碼,用來區分正常情況和異常情況,這並不是一種良好的程式設計習慣。

然而在 Go 等返回值可以為複數的語言中,我們無需使用上面蹩腳的方式,而是可以:

res, err := divide("foo", "bar");
if err != nil {
    log.Fatal(err)
}

這是和 Java 和 JS 等語言使用的 try catch 不一樣的的地方,Go 是通過 panic recover defer 機制來進行異常處理的。感興趣的可以去看看 Go 原始碼關於錯誤測試部分

可能大家對 Go 不太熟悉。沒關係,我們來繼續看下 shell。實際上 shell 也是通過返回值來處理異常的,我們可以通過 $? 拿到上一個命令的返回值,這本質上也是一種呼叫棧的傳播行為,而且是通過返回值而不是捕獲來處理異常的。

作為函式返回值處理和 try catch 一樣,這是語言的設計者和開發者共同決定的一件事情。

上面提到了異常傳播是作用在函式呼叫棧上的。當一個異常發生的時候,其會沿著函式呼叫棧逐層返回,直到第一個 catch 語句。當然 catch 語句內部仍然可以觸發異常(自動或者手動)。如果 catch 語句內部發生了異常,也一樣會沿著其函式呼叫棧繼續執行上述邏輯,專業術語是 stack unwinding

實際上並不是所有的語言都會進行 stack unwinding,這個我們會在接下來的《執行時異常可以恢復麼?》部分講解。

虛擬碼來描述一下:

function bubble(error, fn) {
  if (fn.hasCatchBlock()) {
    runCatchCode(error);
  }
  if (callstack.isNotEmpty()) {
    bubble(error, callstack.pop());
  }
}
從我的虛擬碼可以看出所謂的 stack unwinding 其實就是 callstack.pop()

這就是異常傳播的一切!僅此而已。

異常的處理

我們已經瞭解來異常的傳播方式了。那麼接下來的問題是,我們應該如何在這個傳播過程中處理異常呢?

我們來看一個簡單的例子:

function a() {
  b();
}
function b() {
  c();
}
function c() {
  throw new Error("an error  occured");
}
a();

我們將上面的程式碼放到 chrome 中執行, 會在控制檯顯示如下輸出:

我們可以清楚地看出函式的呼叫關係。即錯誤是在 c 中發生的,而 c 是 b 呼叫的,b 是 a 呼叫的。這個函式呼叫棧是為了方便開發者定位問題而存在的。

上面的程式碼,我們並沒有 catch 錯誤,因此上面才會有uncaught Error

那麼如果我們 catch ,會發生什麼樣的變化呢?catch 的位置會對結果產生什麼樣的影響?在 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 中執行, 會在控制檯顯示如下輸出:

可以看出,此時已經沒有uncaught Error啦,僅僅在控制檯顯示了標準輸出,而非錯誤輸出(因為我用的是 console.log,而不是 console.error)。然而更重要是的是,如果我們沒有 catch,那麼後面的同步程式碼將不會執行。

比如在 c 的 throw 下面增加一行程式碼,這行程式碼是無法被執行的,無論這個錯誤有沒有被捕獲

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 中捕獲沒有什麼本質不同。其實放到 a 中捕獲也是一樣,這裡不再貼程式碼了,感興趣的自己試下。

既然處於函式呼叫棧頂部的函式報錯, 其函式呼叫棧下方的任意函式都可以進行捕獲,並且效果沒有本質不同。那麼問題來了,我到底應該在哪裡進行錯誤處理呢?

答案是責任鏈模式。我們先來簡單介紹一下責任鏈模式,不過細節不會在這裡展開。

假如 lucifer 要請假。

  • 如果請假天數小於等於 1 天,則主管同意即可
  • 如果請假大於 1 天,但是小於等於三天,則需要 CTO 同意。
  • 如果請假天數大於三天,則需要老闆同意。

這就是一個典型的責任鏈模式。誰有責任幹什麼事情是確定的,不要做自己能力範圍之外的事情。比如主管不要去同意大於 1 天的審批。

舉個例子,假設我們的應用有三個異常處理類,它們分別是:使用者輸入錯誤網路錯誤型別錯誤。如下程式碼,當程式碼執行的時候會報錯一個使用者輸入異常。這個異常沒有被 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 中丟擲的是別的異常,比如網路異常,那麼 b 是無法處理的,雖然 b catch 住了,但是由於你無法處理,因此一個好的做法是繼續丟擲異常,而不是吞沒異常。不要畏懼錯誤,丟擲它。只有沒有被捕獲的異常才是可怕的,如果一個錯誤可以被捕獲並得到正確處理,它就不可怕。

舉個例子:

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();

如上程式碼不會有任何異常被丟擲,它被完全吞沒了,這對我們除錯問題簡直是災難。因此切記不要吞沒你不能處理的異常。正確的做法應該是上面講的那種只 catch 你可以處理的異常,而將你不能處理的異常 throw 出來,這就是責任鏈模式的典型應用。

這只是一個簡單的例子,就足以繞半天。實際業務肯定比這個複雜多得多。因此異常處理絕對不是一件容易的事情。

如果說誰來處理是一件困難的事情,那麼在非同步中決定誰來處理異常就是難上加難,我們來看下。

同步與非同步

同步非同步一直是前端難以跨越的坎,對於異常處理也是一樣。以 NodeJS 中用的比較多的讀取檔案 API 為例。它有兩個版本,一個是非同步,一個是同步。同步讀取僅僅應該被用在沒了這個檔案無法進行下去的時候。比如讀取一個配置檔案。而不應該在比如瀏覽器中讀取使用者磁碟上的一個圖片等,這樣會造成主執行緒阻塞,導致瀏覽器卡死。

// 非同步讀取檔案
fs.readFileSync();
// 同步讀取檔案
fs.readFile();

當我們試圖同步讀取一個不存在的檔案的時候,會丟擲以下異常:

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'
}

並且腦洞前端是不會被列印出來的。這個比較好理解,我們上面已經解釋過了。

而如果以非同步方式的話:

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'
}
>

腦洞前端是會被列印出來的。

其本質在於 fs.readFile 的函式呼叫已經成功,並從呼叫棧返回並執行到下一行的console.log('lucifer')。因此錯誤發生的時候,呼叫棧是空的,這一點可以從上面的錯誤堆疊資訊中看出來。

不明白為什麼呼叫棧是空的同學可以看下我之前寫的《一文看懂瀏覽器事件迴圈》

而 try catch 的作用僅僅是捕獲當前呼叫棧的錯誤(上面異常傳播部分已經講過了)。因此非同步的錯誤是無法捕獲的,比如;

try {
  fs.readFile("something-not-exist.lucifer", (err, data) => {
    if (err) {
      throw err;
    }
  });
} catch (err) {
  console.log("catching an error");
}

上面的 catching an error 不會被列印。因為錯誤丟擲的時候, 呼叫棧中不包含這個 catch 語句,而僅僅在執行fs.readFile的時候才會。

如果我們換成同步讀取檔案的例子看看:

try {
  fs.readFileSync("something-not-exist.lucifer");
} catch (err) {
  console.log("catching an error");
}

上面的程式碼會列印 catching an error。因為讀取檔案被同步發起,檔案返回之前執行緒會被掛起,當執行緒恢復執行的時候, fs.readFileSync 仍然在函式呼叫棧中,因此 fs.readFileSync 產生的異常會冒泡到 catch 語句。

簡單來說就是非同步產生的錯誤不能用 try catch 捕獲,而要使用回撥捕獲。

可能有人會問了,我見過用 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();

本質上這只是一個語法糖,是 Promise.prototype.catch 的一個語法糖而已。而這一語法糖能夠成立的原因在於其用了 Promise 這種包裝型別。如果你不用包裝型別,比如上面的 fs.readFile 不用 Promise 等包裝型別包裝,打死都不能用 try catch 捕獲。

而如果我們使用 babel 轉義下,會發現 try catch 不見了,變成了 switch case 語句。這就是 try catch “可以捕獲非同步異常”的原因,僅此而已,沒有更多。


(babel 轉義結果)

我使用的 babel 轉義環境都記錄在這裡,大家可以直接點開連結檢視.

雖然瀏覽器並不像 babel 轉義這般實現,但是至少我們明白了一點。目前的 try catch 的作用機制是無法捕獲非同步異常的。

非同步的錯誤處理推薦使用容器包裝,比如 Promise。然後使用 catch 進行處理。實際上 Promise 的 catch 和 try catch 的 catch 有很多相似的地方,大家可以類比過去。

和同步處理一樣,很多原則都是通用的。比如非同步也不要去吞沒異常。下面的程式碼是不好的,因為它吞沒了它不能處理的異常。

p = Promise.reject(1);
p.catch(() => {});

更合適的做法的應該是類似這種:

p = Promise.reject(1);
p.catch((err) => {
  if (err == 1) {
    return console.log("I can handle this");
  }
  throw err;
});

徹底消除執行時異常可能麼?

我個人對目前前端現狀最為頭疼的一點是:大家過分依賴執行時,而嚴重忽略編譯時。我見過很多程式,你如果不執行,根本不知道程式是怎麼走的,每個變數的 shape 是什麼。怪不得處處都可以看到 console.log。我相信你一定對此感同身受。也許你就是那個寫出這種程式碼的人,也許你是給別人擦屁股的人。為什麼會這樣? 就是因為大家太依賴執行時。TS 的出現很大程度上改善了這一點,前提是你用的是 typescript,而不是 anyscript。其實 eslint 以及 stylint 對此也有貢獻,畢竟它們都是靜態分析工具。

我強烈建議將異常保留在編譯時,而不是執行時。不妨極端一點來看:假如所有的異常都在編譯時發生,而一定不會在執行時發生。那麼我們是不是就可以信心滿滿地對應用進行重構啦?

幸運的是,我們能夠做到。只不過如果當前語言做不到的話,則需要對現有的語言體系進行改造。這種改造成本真的很大。不僅僅是 API,程式設計模型也發生了翻天覆地的變化,不然函式式也不會這麼多年沒有得到普及了。

不熟悉函式程式設計的可以看看我之前寫的函數語言程式設計入門篇

如果才能徹底消除異常呢?在回答這個問題之前,我們先來看下一門號稱沒有執行時異常的語言 elm。elm 是一門可以編譯為 JS 的函數語言程式設計語言,其封裝了諸如網路 IO 等副作用,是一種宣告式可推導的語言。 有趣的是,elm 也有異常處理。 elm 中關於異常處理(Error Handling)部分有兩個小節的內容,分別是:MaybeResult。elm 之所以沒有執行時異常的一個原因就是它們。 一句話概括“為什麼 elm 沒有異常”的話,那就是elm 把異常看作資料(data)

舉個簡單的例子:

maybeResolveOrNot = (ms) =>
  setTimeout(() => {
    if (Math.random() > 0.5) {
      console.log("ok");
    } else {
      throw new Error("error");
    }
  });

上面的程式碼有一半的可能報錯。那麼在 elm 中就不允許這樣的情況發生。所有的可能發生異常的程式碼都會被強制包裝一層容器,這個容器在這裡是 Maybe。

在其他函數語言程式設計語言名字可能有所不同,但是意義相同。實際上,不僅僅是異常,正常的資料也會被包裝到容器中,你需要通過容器的介面來獲取資料。如果難以理解的話,你可以將其簡單理解為 Promsie(但並不完全等價)。

Maybe 可能返回正常的資料 data,也可能會生成一個錯誤 error。某一個時刻只能是其中一個,並且只有執行的時候,我們才真正知道它是什麼。從這一點來看,有點像薛定諤的貓。

不過 Maybe 已經完全考慮到異常的存在,一切都在它的掌握之中。所有的異常都能夠在編譯時推匯出來。當然要想推匯出這些東西,你需要對整個程式設計模型做一定的封裝會抽象,比如 DOM 就不能直接用了,而是需要一箇中間層。

再來看下一個更普遍的例子 NPE:

null.toString();

elm 也不會發生。原因也很簡單,因為 null 也會被包裝起來,當你通過這個包裝型別就行訪問的時候,容器有能力避免這種情況,因此就可以不會發生異常。當然這裡有一個很重要的前提就是可推導,而這正是函數語言程式設計語言的特性。這部分內容超出了本文的討論範圍,不再這裡說了。

執行時異常可以恢復麼?

最後要討論的一個主題是執行時異常是否可以恢復。先來解釋一下,什麼是執行時異常的恢復。 還是用上面的例子:

function t() {
  console.log("start");
  throw 1;
  console.log("end");
}
t();

這個我們已經知道了, end 是不會列印的。 儘管你這麼寫也是無濟於事:

function t() {
  try {
    console.log("start");
    throw 1;
    console.log("end");
  } catch (err) {
    console.log("relax, I can handle this");
  }
}
t();

如果我想讓它列印呢?我想讓程式面對異常可以自己 recover 怎麼辦?我已經捕獲這個錯誤, 並且我確信我可以處理,讓流程繼續走下去吧!如果有能力做到這個,這個就是執行時異常恢復

遺憾地告訴你,據我所知,目前沒有任何一個引擎能夠做到這一點。

這個例子過於簡單, 只能幫助我們理解什麼是執行時異常恢復,但是不足以讓我們看出這有什麼用?

我們來看一個更加複雜的例子,我們這裡直接使用上面實現過的函式divide

function t() {
  try {
    const res = divide("foo", "bar");
    alert(`you got ${res}`);
  } catch (err) {
    if (err.code === 1) {
      return console.log("被除數必須是除0之外的數");
    }
    if (err.code === 2) {
      return console.log("除數必須是數字");
    }
    throw new Error("不可預知的錯誤");
  }
}

如上程式碼,會進入 catch ,而不會 alert。因此對於使用者來說, 應用程式是沒有任何響應的。這是不可接受的。

要吐槽一點的是這種事情真的是挺常見的,只不過大家用的不是 alert 罷了。

如果我們的程式碼在進入 catch 之後還能夠繼續返回出錯位置繼續執行就好了。

如何實現異常中斷的恢復呢?我剛剛說了:據我所知,目前沒有任何一個引擎能夠做到異常恢復。那麼我就來發明一個新的語法解決這個問題。

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 是我定義的一個關鍵字,功能是如果遇到異常,則返回到異常發生的地方,然後給當前發生異常的函式一個返回值 -1,並使得後續程式碼能夠正常執行,不受影響。這其實是一種 fallback。

這絕對是一個超前的理念。當然挑戰也非常大,對現有的體系衝擊很大,很多東西都要改。我希望社群可以考慮把這個東西加到標準。

最佳實踐

通過前面的學習,你已經知道了異常是什麼,異常是怎麼產生的,以及如何正確處理異常(同步和非同步)。接下來,我們談一下異常處理的最佳實踐。

我們平時開發一個應用。 如果站在生產者和消費者的角度來看的話。當我們使用別人封裝的框架,庫,模組,甚至是函式的時候,我們就是消費者。而當我們寫的東西被他人使用的時候,我們就是生產者。

實際上,就算是生產者內部也會有多個模組構成,多個模組之間也會有生產者和消費者的再次身份轉化。不過為了簡單起見,本文不考慮這種關係。這裡的生產者指的就是給他人使用的功能,是純粹的生產者。

從這個角度出發,來看下異常處理的最佳實踐。

作為消費者

當作為消費者的時候,我們關心的是使用的功能是否會丟擲異常,如果是,他們有哪些異常。比如:

import foo from "lucifer";
try {
  foo.bar();
} catch (err) {
  // 有哪些異常?
}

當然,理論上 foo.bar 可能產生任何異常,而不管它的 API 是這麼寫的。但是我們關心的是可預期的異常。因此你一定希望這個時候有一個 API 文件,詳細列舉了這個 API 可能產生的異常有哪些。

比如這個 foo.bar 4 種可能的異常 分別是 A,B,C 和 D。其中 A 和 B 是我可以處理的,而 C 和 D 是我不能處理的。那麼我應該:

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 中沒有列舉的各種可能異常,我們的做法都是直接丟擲。

作為生產者

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

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

總結

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

我從什麼是異常,以及異常的分類,讓大家正確認識異常,簡單來說異常就是一種資料結構而已。

接著,我又講到了異常的傳播和處理。這兩個部分是緊密聯絡的。異常的傳播和事件傳播沒有本質不同,主要不同是資料結構不同,思想是類似的。具體來說異常會從發生錯誤的呼叫處,沿著呼叫棧回退,直到第一個 catch 語句或者棧為空。如果棧為空都沒有碰到一個 catch,則會丟擲uncaught Error。 需要特別注意的是非同步的異常處理,不過你如果對我講的原理了解了,這都不是事。

然後,我提出了兩個腦洞問題:

  • 徹底消除執行時異常可能麼?
  • 執行時異常可以恢復麼?

這兩個問題非常值得研究,但由於篇幅原因,我這裡只是給你講個輪廓而已。如果你對這兩個話題感興趣,可以和我交流。

最後,我提到了前端異常處理的最佳實踐。大家通過兩種角色(生產者和消費者)的轉換,認識一下不同決定關注點以及承擔責任的不同。具體來說提到了 明確宣告可能的異常以及 處理你應該處理的,不要吞沒你不能處理的異常。當然這個最佳實踐仍然是輪廓性的。如果大家想要一份 前端最佳實踐 checklist,可以給我留言。留言人數較多的話,我考慮專門寫一個前端最佳實踐 checklist 型別的文章。

大家也可以關注我的公眾號《腦洞前端》獲取更多更新鮮的前端硬核文章,帶你認識你不知道的前端。

相關文章