【Web技術】剖析前端異常及降級處理

一、導讀
“異常”一詞出自《后漢書.卷一.皇后紀上.光烈陰皇后紀》,表示非正常的,不同于平常的。在我們現實生活中同樣處處存在著異常,比如小縣城里的路燈年久失修...,上下班高峰期深圳的地鐵總是那么的擁擠...,人也總是時不時會生病等等; 由此可見,這個世界錯誤無處不在,這是一個基本的事實。
而在計算機的世界中,異常指的是在程序運行過程中發(fā)生的異常事件,有些錯誤是由于外部環(huán)境導致的,有些錯誤是由于開發(fā)人員疏忽所導致的,有效的處理這些錯誤,保證計算機世界的正常運轉是我們開發(fā)人員必不可少的一環(huán)。
二、背景
隨著項目的不斷壯大,客戶的不斷接入,項目的穩(wěn)定性成為團隊的一大挑戰(zhàn)。
當用戶或者團隊測試人員遇到問題時,大概率是直接丟給開發(fā)人員一張白屏頁面或錯誤UI的截圖,且該錯誤并不是必現時,讓前后端同學定位問題倍感頭痛。有沒有一種方式既能夠提升用戶體驗,又能夠幫助開發(fā)人員快速定位解決問題?
本著“客戶就是上帝”的商業(yè)準則,為用戶創(chuàng)造良好的用戶體驗,是前端開發(fā)者職責之所在。當頁面發(fā)生錯誤的時候,相比于頁面崩潰或點不動,在適當的時機,以一種適當的方式去提醒用戶當前發(fā)生了什么,無疑是一種更友好的處理方式。
項目中面臨下面幾種異常場景,需要處理:
語法錯誤 事件異常 HTTP請求異常 靜態(tài)資源加載異常 Promise 異常 Iframe 異常 頁面崩潰
整體異常處理方案需要實現二方面的效果:
提升用戶體驗 上報監(jiān)控系統(tǒng),能及時早發(fā)現、定位、解決問題
下面我們先從幾個異常場景出發(fā),逐步探討如何解決這些異常并給予更好的用戶體驗。
三、錯誤類型
在探討具體的解決方案之前,我們先來認識和熟悉一下前端的各種錯誤類型。
ECMA-262規(guī)范定義的七種錯誤類型:
Error EvalError RangeError ReferenceError SyntaxError TypeError URIError
Error
Error是所有錯誤的基類,其他錯誤都繼承自該類型
EvalError
EvalError對象表示全局函數eval()中發(fā)生的錯誤。如果eval()中沒有錯誤,則不會拋出該錯誤。可以通過構造函數創(chuàng)建這個對象的實例

RangeError
RangeError對象表示當一個值不在允許值的集合或范圍內時出現錯誤。

ReferenceError
當引用不存在的變量時,該對象表示錯誤:

SyntaxError
當JavaScript引擎在解析代碼時遇到不符合該語言語法的標記或標記順序時,將引發(fā)該異常:

TypeError
傳遞給函數的操作數或實參與該操作符或函數期望的類型不兼容:

URIError
當全局URI處理函數以錯誤的方式使用時:

四、處理和防范
上文我們提到錯誤和異常無處不在,存在于各式各樣的應用場景中,那我們應該如何有效的攔截異常,將錯誤扼殺于搖籃之中,讓用戶無感呢?亦或者遇到致命錯誤時,進行降級處理?
(1) try catch
1.語法
ECMA-262 第 3 版中引入了 try-catch作為 JavaScript 中處理異常的一種標準方式,基本的語法如下所示。
try {
// 可能會導致錯誤的代碼
} catch (error) {
// 在錯誤發(fā)生時怎么處理
}
復制代碼
2.動機
使用try...catch來捕獲異常,我歸納起來主要有兩個動機:
1)是真真正正地想對可能發(fā)生錯誤的代碼進行異常捕獲;
2)我想保證后面的代碼繼續(xù)運行。
動機一沒什么好講的,在這里,我們講講動機二。假如我們有以下代碼:
console.log(foo); //foo未定義
console.log('I want running')
復制代碼
代碼一執(zhí)行,你猜怎么著?第一行語句報錯了,第二行語句的log也就沒打印出來。如果我們把代碼改成這樣:
try{
console.log(foo)
}catch(e){
console.log(e)
}
console.log('I want running');
復制代碼
以上代碼執(zhí)行之后,雖然還是報了個ReferenceError錯誤,但是后面的log卻能夠被執(zhí)行。
從這個示例,我們可以看出,一旦前面的(同步)代碼出現了沒有被開發(fā)者捕獲的異常的話,那么后面的代碼就不會執(zhí)行了。所以,如果你希望當前可能出錯的代碼塊后續(xù)的代碼能夠正常運行的話,那么你就得使用try...catch來主動捕獲異常。
擴展:
實際上,出錯代碼是如何干擾后續(xù)代碼的執(zhí)行,是一個值得探討的主題。下面進行具體的探討。因為市面上瀏覽器眾多,對標準的實現也不太一致。所以,這里的結論僅僅是基于Chromev91.0.4472.114。探討過程中,我們涉及到兩組概念:同步代碼與異步代碼,代碼書寫期和代碼運行期。
場景一:同步代碼(出錯) + 同步代碼

可以看到,出錯的同步代碼后面的同步代碼不執(zhí)行了。
場景二:同步代碼(出錯) + 異步代碼

跟上面的情況一下,異步代碼也受到影響,也不執(zhí)行了。
場景三:異步代碼(出錯) + 同步代碼

可以看到,異步代碼出錯,并不會影響后面同步代碼的執(zhí)行。
場景四:異步代碼(出錯) + 異步代碼

出錯的異步代碼也不會影響后面異步代碼的執(zhí)行。
如果只看場景一二三,很容易得出如下結論:在代碼運行期,同步代碼始終是先于異步代碼執(zhí)行的。如果先執(zhí)行的同步代碼沒有出錯的話,那么后面的代碼就會正常執(zhí)行,否則后面的代碼就不會執(zhí)行。但場景四卻打破了這個結論。我們不妨繼續(xù)看看場景五。
場景五:異步代碼 + 同步代碼(出錯) + 異步代碼

看到了沒?同樣是異步代碼,按理說,代碼運行期,如果你是受出錯的同步代碼的影響的話,那你要么是兩個都不執(zhí)行,或者兩個都執(zhí)行啊?憑什么寫在出錯代碼代碼書寫期前面的異步代碼就能正常執(zhí)行,而寫在后面的就不執(zhí)行呢?經過驗證,在firefoxv75.0版本中也是同樣的表現。
所以,到了這里,我們基本上可以得出這樣的結論:運行期,一先一后的兩個代碼中,出錯的一方代碼是如何影響另外一方代碼繼續(xù)執(zhí)行的問題中,跟異步代碼沒關系,只跟同步代碼有關系;跟代碼執(zhí)行期沒關系,只跟代碼書寫期有關系。
說人話就是,異步代碼出錯與否都不會影響其他代碼繼續(xù)執(zhí)行。而出錯的同步代碼,如果它在代碼書寫期是寫在其他代碼之前,并且我們并沒有對它進行手動地去異常捕獲的話,那么它就會影響其他代碼(不論它是同步還是異步代碼)的繼續(xù)執(zhí)行。
綜上所述,如果我們想要保證某塊可能出錯的同步代碼后面的代碼繼續(xù)執(zhí)行的話,那么我們必須對這塊同步代碼進行異常捕獲。
3.范圍
只能捕獲同步代碼所產生的運行時錯誤,對于語法錯誤和異步代碼所產生的錯誤是無能為力的。
當遇到語法錯誤時:
當遇到異步運行時錯誤時:
(2) Promise.catch()
1.語法
const promise1 = new Promise((resolve, reject) => {
throw 'Uh-oh!';
});
promise1.catch((error) => {
console.error(error);
});
// expected output: Uh-oh!
復制代碼
2.動機
用來捕獲promise代碼中的錯誤
3.范圍
使用Promise.prototype.catch()我們可以方便的捕獲到異常,現在我們來測試一下常見的語法錯誤、代碼錯誤以及異步錯誤。
當遇到代碼錯誤時,可以捕獲:
當遇到語法錯誤時,不能捕獲:
當遇到異步運行時錯誤時,不能捕獲:

(3) unhandledrejection
1.用法
unhandledrejection:當Promise 被 reject 且沒有 reject 處理器的時候,會觸發(fā) unhandledrejection 事件
window.addEventListener("unhandledrejection", function(e){
console.log(e);
});
復制代碼
2.動機
為了防止有漏掉的 Promise 異常,可以在全局增加一個對 unhandledrejection 的監(jiān)聽進行兜底,用來全局監(jiān)聽Uncaught Promise Error。
3.范圍
window.addEventListener("unhandledrejection", function (e) {
console.log("捕獲到的promise異常:", e);
e.preventDefault();
});
new Promise((res) => {
console.log(a);
});
// 捕獲到的promise異常的: PromiseRejectionEvent
復制代碼
注意:此段代碼直接寫在控制臺是捕獲不到promise異常的,寫在html文件中可正常捕獲。
(4) window.onerror
1.用法
當 JS 運行時錯誤發(fā)生時,window 會觸發(fā)一個 ErrorEvent 接口的 error 事件,并執(zhí)行 window.onerror()。
window.onerror = function(message, source, lineno, colno, error) {
console.log('捕獲到異常:',{message, source, lineno, colno, error});
}
復制代碼
2.動機
眾所周知,很多做錯誤監(jiān)控和上報的類庫就是基于這個特性來實現的,我們期待它能處理那些try...catch不能處理的錯誤。
3.范圍
根據MDN的說法,wondow.onerror能捕獲JavaScript運行時錯誤(包括語法錯誤)或一些資源錯誤。而在真正的測試過程中,wondow.onerror并不能捕獲語法錯誤。

經測試,window.onerror并不能捕獲語法錯誤和靜態(tài)資源的加載錯誤。同樣也不能捕獲異步代碼的錯誤,但是有一點值得注意的是,window.onerror能捕獲同樣是異步代碼的setTimeout和setInterval里面的錯誤。
看來,寄予厚望的window.onerror并不是萬能的。
(5) window.addEventListener
1.用法
window.addEventListener('error',(error)=>{console.log(error)})
復制代碼
2.動機
當然是希望用他來兜住window.onerror和try catch的底,希望他能捕獲到異步錯誤和資源的加載錯誤。
3.范圍
<body>
<img id="img" src="./fake.png" />
<iframe id="iframe" src="./test4.html"></iframe>
</body>
<script>
window.addEventListener(
"error",
function (error) {
console.log(error, "error");
},
true
);
setTimeout(() => {
console.log(a);
});
new Promise((resolve, reject) => {
console.log(a);
});
console.log(b)
var f=e, //語法異常
</script>
復制代碼
在此過程中,資源文件都是不存在的,我們發(fā)現window.addEventListener('error')依舊不能捕獲語法錯誤,Promise異常和iframe異常。
對于語法錯誤我們可以在編譯過程中捕獲,,Promise異常已在上文中給出解決方案,現在還剩下iframe異常需要單獨處理了。
(5) iframe異常
1.用法
window.frames[0].onerror = function (message, source, lineno, colno, error) {
console.log('捕獲到 iframe 異常:',{message, source, lineno, colno, error});
return true;
};
復制代碼
2.動機
用來專門捕獲iframe加載過程中的異常。
3.范圍
很遺憾,結果并不令人滿意,在實際的測試過程中,該方法未能捕獲到異常。
(6) React中捕獲異常
部分 UI 的 JavaScript 錯誤不應該導致整個應用崩潰,為了解決這個問題,React 16 引入了一個新的概念 —— 錯誤邊界。
錯誤邊界是一種 React 組件,這種組件可以捕獲并打印發(fā)生在其子組件樹任何位置的 JavaScript 錯誤,并且,它會渲染出備用 UI,而不是渲染那些崩潰了的子組件樹。錯誤邊界在渲染期間、生命周期方法和整個組件樹的構造函數中捕獲錯誤。
注意:錯誤邊界無法捕獲以下場景中產生的錯誤
事件處理 異步代碼(例如 setTimeout 或 requestAnimationFrame 回調函數) 服務端渲染 它自身拋出來的錯誤(并非它的子組件)
如果一個 class 組件中定義了 static getDerivedStateFromError() 或 componentDidCatch() 這兩個生命周期方法中的任意一個(或兩個)時,那么它就變成一個錯誤邊界。當拋出錯誤后,請使用 static getDerivedStateFromError() 渲染備用 UI ,使用 componentDidCatch() 打印錯誤信息。
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能夠顯示降級后的 UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 你同樣可以將錯誤日志上報給服務器
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// 你可以自定義降級后的 UI 并渲染
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
復制代碼
錯誤邊界的工作方式類似于 JavaScript 的 catch {},不同的地方在于錯誤邊界只針對 React 組件。只有 class 組件才可以成為錯誤邊界組件。大多數情況下, 你只需要聲明一次錯誤邊界組件, 并在整個應用中使用它。
以上引用自React 官網。
(7) Vue中捕獲異常
Vue.config.errorHandler = function (err, vm, info) {
// handle error
// `info` 是 Vue 特定的錯誤信息,比如錯誤所在的生命周期鉤子
// 只在 2.2.0+ 可用
}
復制代碼
指定組件的渲染和觀察期間未捕獲錯誤的處理函數。這個處理函數被調用時,可獲取錯誤信息和 Vue 實例。
從 2.2.0 起,這個鉤子也會捕獲組件生命周期鉤子里的錯誤。同樣的,當這個鉤子是 undefined 時,被捕獲的錯誤會通過 console.error 輸出而避免應用崩潰。 從 2.4.0 起,這個鉤子也會捕獲 Vue 自定義事件處理函數內部的錯誤了。 從 2.6.0 起,這個鉤子也會捕獲 v-on DOM 監(jiān)聽器內部拋出的錯誤。另外,如果任何被覆蓋的鉤子或處理函數返回一個 Promise 鏈 (例如 async 函數),則來自其 Promise 鏈的錯誤也會被處理。
以上引用自Vue 官網。
(8) http請求異常
1.用法
以axios為例,添加響應攔截器
axios.interceptors.response.use(function (response) {
// 對響應數據做點什么
// response 是請求回來的數據
return response;
}, function (error) {
// 對響應錯誤做點什么
return Promise.reject(error)
}
)
復制代碼
2.動機
用來專門捕獲HTTP請求異常
五、項目實踐
在提出了這么多的解決方案之后,相信大家對具體怎么用還是存在一些疑惑。那么接下來,我們真正的進入實踐階段吧!
我們再次回顧一下我們需要解決的問題是什么?
語法錯誤 事件異常 HTTP請求異常 靜態(tài)資源加載異常 Promise 異常 Iframe 異常 頁面崩潰
捕獲異常是我們的最終目標嗎?并不是,回到解決問題的背景下,相比于頁面崩潰或點不動,在適當的時機,以一種適當的方式去提醒用戶當前發(fā)生了什么,無疑是一種更友好的處理方式。
結合到項目中,具體實踐起來有如下兩種方案:
1.代碼中通過大量的try catch/Promise.catch來捕獲,捕獲不到的使用其他方式進行兜底 2.通過框架提供的機制來做,再對不能捕獲的進行兜底
方案一無疑不是很聰明的樣子...這意味著要去改大量的原有代碼,心智負擔成倍數增加。方案二則更加明智,通過在底層對錯誤進行統(tǒng)一處理,無需變更原有邏輯。
到項目中,使用的是React框架,React正好提供了一種捕獲異常的機制(上文已提及)并做降級處理,但是細心的小伙伴發(fā)現了,react并不能捕獲如下四種錯誤:
事件處理 異步代碼(例如 setTimeout 或 requestAnimationFrame 回調函數) 服務端渲染 它自身拋出來的錯誤(并非它的子組件)
對于第三點服務端渲染錯誤,項目中并沒有適用的場景,此次不做重點分析。我們重點分析第一點和第二點。
我在這里先拋出幾個問題,大家先做短暫的思考:
1.若事件處理和異步代碼的錯誤導致頁面crash,我們該如何預防? 2.如何對ErrorBounary進行兜底?相比一個按鈕點擊無效,如何更加友好的提示用戶?
先來看第一個問題,若事件處理和異步代碼的錯誤導致頁面崩潰:
const Test = () => {
const [data, setData] = useState([]);
return (
<div
onClick={() => {
setData('');
}}
>
{data.map((s) => s.i)}
</div>
);
};
復制代碼
此段代碼在正常渲染期間是沒問題的,但在觸發(fā)了點擊事件之后會導致頁面異常白屏,如果在外面套上我們的ErrorBounday組件,情況會是怎么樣呢?
答案是依然能夠捕獲到錯誤,并能夠對該組件進行降級處理!
此時有些小伙伴已經察覺到了,錯誤邊界只要是在渲染期間都是可以捕獲錯誤的,無論首次渲染還是二次渲染。流程圖如下:

第一個問題原來根本就不是問題,這本身就是一個閉環(huán),不用我們解決!
再來看看第二個問題:
對于事件處理和異步代碼中不會導致頁面崩潰的代碼:
const Test = () => {
return (
<button
onClick={() => {
[].map((s) => s.a.b);
}}
>
點擊
</button>
);
};
復制代碼
button按鈕可正常點擊,但是該點擊事件的內部邏輯是有問題的,導致用戶點擊該按鈕本質是無效的。此時若不及時給與友好提示,用戶只會陷入抓狂中....
那么有沒有辦法對ErrorBoundary進行兜底呢?即可以捕獲異步代碼或事件處理中的錯誤。
上文提到的window.addEventListener('error')正好可以解決這個問題。理想狀態(tài)下:
而真正的執(zhí)行順序確實這樣的:

在真正執(zhí)行的過程中,window.addEventListener('error')是先于ErrorBoundary捕獲到錯誤的,這就導致當error事件捕獲到錯誤時,他并不知道該錯誤是否會導致頁面崩潰,不知道該給予怎樣的提示,到底是對頁面進行降級處理還是只做簡單的報錯提示?
問題似乎就卡在這了....
那能否通過一種有效的途徑告訴error事件:ErrorBoundary已經捕獲到了錯誤,你不需要處理!亦或者是ErrorBoundary未能捕獲到錯誤,這是一個異步錯誤/事件錯誤,但不會引起頁面崩潰,你只需要提示用戶!
答案肯定是有的,比如建立一個nodeJs服務器,通過webSocket去通知,但是這樣做不僅麻煩,還會有一定的延遲。
在筆者苦思冥想之際,在某個靜悄悄的夜晚,突然靈感一現。為什么我們非要按照他規(guī)定的順序執(zhí)行呢?我們能不能嘗試改變他的執(zhí)行順序,讓錯誤捕獲回到我們理想中的流程來呢?
改變思路之后,我們再思考有什么能改變代碼執(zhí)行順序嗎?沒錯,異步事件!
window.addEventListener('error', function (error) {
setTimeout(()=>{
console.log(error, 'error錯誤');
})
});
復制代碼
當給error事件的回調函數加入setTimeout后,捕獲異常的流程為:

現在就可以通知error事件到底頁面崩潰了沒有,到底需不需要它的處理!上代碼:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能夠顯示降級后的 UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 你同樣可以將錯誤日志上報給服務
logErrorToMyService(error, errorInfo);
//告訴error事件 ErrorBoundary已處理異常
localStorage.setItem("ErrorBoundary",true)
}
render() {
if (this.state.hasError) {
// 你可以自定義降級后的 UI 并渲染
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
復制代碼
window.addEventListener('error', function (error) {
setTimeout(() => {
//進來代表一定有錯誤 判斷ErrorBoundary中是否已處理異常
const flag = localStorage.getItem('ErrorBounary');
if (flag) {
//進入了ErrorBounary 錯誤已被處理 error事件不用處理該異常
localStorage.setItem('ErrorBounary', false); //重置狀態(tài)
} else {
//未進入ErrorBounary 代表此錯誤為異步錯誤/事件錯誤
logErrorToMyService(error, errorInfo); // 你可以將錯誤日志上報給服務
//判斷具體錯誤類型
if (error.message.indexOf('TypeError')) {
alert('這是一個TypeError錯誤,請通知開發(fā)人員');
} else if (error.message.indexOf('SyntaxError')) {
alert('這是一個SyntaxError錯誤,請通知開發(fā)人員');
} else {
//在此次給與友好提示
}
}
});
});
復制代碼
最后,通過我們的努力,當頁面崩潰時,及時進行降級處理;當頁面未崩潰,但有錯誤時,我們及時的告知用戶,并對錯誤進行上報,達到預期的效果。
六、擴展
1.設置采集率
若是錯誤實在太多,比如有時候代碼進入死循環(huán),錯誤量過多導致服務器壓力大時,可酌情降低采集率。比如采集30%:
if (Math.random() < 0.3) {
//上報錯誤
logErrorToMyService(error, errorInfo);
}
復制代碼
2.提效
解決上面這些問題后,大家難免會有疑問:那每一個組件都要去套一層ErrorBoundary組件,這工作量是不是有點大....而且有一些老代碼,嵌套的比較深,改起來心理負擔也會比較大。那有沒有辦法將其作為一個配置項,配置完之后,編譯時自動套上一層ErrorBoundary組件呢?這個我們下次在做探討!
3.可配置
能否將ErrorBoundary擴展成可傳入自定義UI的組件呢?這樣大家通過定制化UI,在不同的場景進行不同的降級處理。
同樣,這一塊我們下次再討論!
七、總結
異常處理是高質量軟件開發(fā)中的一個基本部分,但是在許多情況下,它們會被忽略,或者是不正確的使用,而處理異常只是保證代碼流程不出錯,重定向到正確的程序流中去。
本文從前端錯誤類型出發(fā),從try catch逐步揭開錯誤異常神秘的面紗,再通過一系列的操作對異常進行監(jiān)控和捕獲,最后達到提升用戶體驗,上報監(jiān)控系統(tǒng)的效果。
八、思考
Promise.catch 和 try catch 捕獲異常有什么區(qū)別? ErrorBounary內部如何實現? 為什么unhandledrejection寫在控制臺是捕獲不到錯誤的?而寫在HTML文件中就可以捕獲到? 服務端渲染錯誤如何捕獲?
帶著這些思考,我們下次見~
關于本文
來源:縱有疾風起
https://juejin.cn/post/6979564690787532814

回復“加群”與大佬們一起交流學習~
點擊“閱讀原文”查看 120+ 篇原創(chuàng)文章
