前言
為什么要處理前端異常够掠,有以下幾方面的原因:
- 提高代碼健壯性:對(duì)于開(kāi)發(fā)人員來(lái)說(shuō)民褂,這點(diǎn)很重要,代碼的健壯性越好疯潭,系統(tǒng)越不容易崩潰赊堪;
- 提升系統(tǒng)穩(wěn)定性:異常會(huì)導(dǎo)致正常流程無(wú)法進(jìn)行、頁(yè)面樣式錯(cuò)亂竖哩、崩潰甚至白屏等問(wèn)題哭廉,嚴(yán)重的會(huì)給業(yè)務(wù)造成損失;
- 增強(qiáng)用戶體驗(yàn):代碼的錯(cuò)誤不應(yīng)該影響頁(yè)面的正常顯示和用戶交互相叁,出錯(cuò)時(shí)我們需要使用拖底方案或者給用戶反饋遵绰;
- 便于定位問(wèn)題:只有知道了如何處理異常,我們才能將異常正常上報(bào)給前端監(jiān)控系統(tǒng)增淹,及時(shí)發(fā)現(xiàn)并定位問(wèn)題椿访。
本文分為以下三個(gè)部分:
第一部分:介紹 Error 對(duì)象及 Error 的類型;
第二部分:介紹捕獲異常的方式有哪些虑润,包含通用成玫、Vue和React項(xiàng)目、iframe中的捕獲以及頁(yè)面崩潰異常的獲热鳌梁剔;
第三部分:結(jié)合工作中的場(chǎng)景,總結(jié)各自對(duì)應(yīng)的異常處理方式舞蔽。
這篇文章的前兩部分我盡量都提供了對(duì)應(yīng)的示例荣病,希望這些示例對(duì)你們有用。另外渗柿,由于這篇文章是做匯總用的个盆,會(huì)比較長(zhǎng),各位可以按自己的需要去看對(duì)應(yīng)的部分朵栖。
Error 及 Error 類型
說(shuō)到異常颊亮,我們需要先從 Error 對(duì)象講起。當(dāng) JavaScript 運(yùn)行時(shí)陨溅,如果發(fā)生了錯(cuò)誤终惑,瀏覽器就會(huì)拋出 Error 的實(shí)例對(duì)象。
Error 對(duì)象
Error 是 JavaScript 中的錯(cuò)誤類门扇,它同時(shí)也是一個(gè)構(gòu)造函數(shù)雹有,可以用來(lái)創(chuàng)建一個(gè)錯(cuò)誤對(duì)象偿渡。創(chuàng)建Error 實(shí)例對(duì)象的方法如下:
new Error([message[, fileName[,lineNumber]]]);
此外,Error 可以像函數(shù)一樣使用霸奕,如果沒(méi)有 new溜宽,它將返回一個(gè) Error 實(shí)例對(duì)象。所以质帅, 僅僅調(diào)用 Error 產(chǎn)生的結(jié)果與通過(guò) new 關(guān)鍵字構(gòu)造 Error 實(shí)例對(duì)象生成的結(jié)果相同适揉。
Error類型
參照MDN的文檔, 還有以下錯(cuò)誤類型都繼承自 Error 對(duì)象:
- SyntaxError
- RangeError
- ReferenceError
- TypeError
- URIError
- EvalError
- InternalError
- AggregateError
接下來(lái)我將按順序介紹上述錯(cuò)誤類型的含義,并盡量舉出對(duì)應(yīng)的例子煤惩。
- SyntaxError
SyntaxError 是代碼不符合 Javascript 語(yǔ)法規(guī)范產(chǎn)生的錯(cuò)誤嫉嘀。
// 變量名錯(cuò)誤
let 1name // Uncaught SyntaxError: Invalid or unexpected token
// 缺少括號(hào)
console.log('test' // Uncaught SyntaxError: missing ) after argument list
// 字符串沒(méi)有加引號(hào)
a string // Uncaught SyntaxError: Unexpected identifier
- RangeError
RangeError 是當(dāng)一個(gè)值不在允許的范圍或者集合中時(shí)的錯(cuò)誤。
// 傳遞一個(gè)不合法的length值作為Array構(gòu)造器的參數(shù)創(chuàng)建數(shù)組
new Array(-1) // Uncaught RangeError: Invalid array length
// 傳遞錯(cuò)誤值到數(shù)值計(jì)算方法
var number = 10
number.toFixed(-1) // Uncaught RangeError: toFixed() digits argument must be between 0 and 100
- ReferenceError
ReferenceError 是引用一個(gè)不存在的變量或者給不能賦值的對(duì)象賦值時(shí)發(fā)生的錯(cuò)誤魄揉。
// 變量名未定義
undefinedVariable // Uncaught ReferenceError: unknowName is not defined
// 方法名未定義
undefinedFunction() // Uncaught ReferenceError: undefinedFunction is not defined
// 等號(hào)左側(cè)不能賦值 //todo: 為啥
console.log() = 1 // Uncaught SyntaxError: Invalid left-hand side in assignment
// 等號(hào)左側(cè)不能賦值 //todo: 為啥
if(a === 1 || b = 2) {
console.log('a === 1 || b = 2')
} // Invalid left-hand side in assignment
// this對(duì)象不能手動(dòng)賦值
this = 1 // Uncaught SyntaxError: Invalid left-hand side in assignment
- TypeError
TypeError 是變量或參數(shù)的類型不是預(yù)期類型時(shí)發(fā)生的錯(cuò)誤盒卸。
// new命令的參數(shù)不是構(gòu)造函數(shù)
new 123 // Uncaught TypeError: 123 is not a constructor
// 使用的方法不是function
let functionName = 'functionName'
functionName() // Uncaught TypeError: functionName is not a function
// undefined或null沒(méi)有對(duì)應(yīng)的屬性或方法
undefined.value // Uncaught TypeError: Cannot read property 'value' of undefined
- URIError
URIError 是 URI 相關(guān)函數(shù)的參數(shù)不正確時(shí)拋出的錯(cuò)誤峰鄙。
decodeURI('%') // Uncaught URIError: URI malformed
- EvalError
EvalError 表示 eval 函數(shù)沒(méi)有被正確執(zhí)行時(shí)發(fā)生的錯(cuò)誤。需要注意的是此異常不再會(huì)被JavaScript 拋出,但是 EvalError 對(duì)象仍然保持兼容性痊焊。
// 沒(méi)有報(bào)EvalError而是對(duì)應(yīng)執(zhí)行js時(shí)的SyntaxError
eval('a string') // Uncaught SyntaxError: Unexpected identifier
永遠(yuǎn)不要使用 eval魔策!
eval() 是一個(gè)危險(xiǎn)的函數(shù)欣除, 它使用與調(diào)用者相同的權(quán)限執(zhí)行代碼兔朦。如果你用 eval() 運(yùn)行的字符串代碼被惡意方(不懷好意的人)修改,您最終可能會(huì)在您的網(wǎng)頁(yè)/擴(kuò)展程序的權(quán)限下摇零,在用戶計(jì)算機(jī)上運(yùn)行惡意代碼推掸。
eval() 通常比其他替代方法更慢,因?yàn)樗仨氄{(diào)用 JS 解釋器驻仅,而許多其他結(jié)構(gòu)則可被現(xiàn)代 JS 引擎進(jìn)行優(yōu)化谅畅。
- InternalError
InternalError 表示出現(xiàn)在 JavaScript 引擎內(nèi)部的錯(cuò)誤。
示例場(chǎng)景通常為某些成分過(guò)大噪服,例如:
- "too many switch cases"(過(guò)多case子句)毡泻;
- "too many parentheses in regular expression"(正則表達(dá)式中括號(hào)過(guò)多);
- "array initializer too large"(數(shù)組初始化器過(guò)大)粘优;
- "too much recursion"(遞歸過(guò)深)仇味。
- AggregateError
AggregateError 是用于把多個(gè)錯(cuò)誤集合在一起。需要注意的是這是一個(gè)實(shí)驗(yàn)中的功能雹顺,尚未被所有的瀏覽器支持(下面例子中用到的 Promise.any 也是實(shí)驗(yàn)中的功能)丹墨。
Promise.any([
Promise.reject(new Error("some error"))
]) // Uncaught (in promise) AggregateError: All promises were rejected
Promise.any() 接收一個(gè) Promise 可迭代對(duì)象,只要其中的一個(gè) promise 成功嬉愧,就返回那個(gè)已經(jīng)成功的 promise贩挣。如果可迭代對(duì)象中沒(méi)有一個(gè) promise 成功(即所有的 promises 都失敗/拒絕),就返回一個(gè)失敗的 promise 和 AggregateError 類型的實(shí)例。
我們還可以基于 Error 自定義異常類型王财,或者用 throw 方法拋出任意類型的異常卵迂,但我們本文的目標(biāo)在于捕獲并處理瀏覽器拋出的異常,這里對(duì)自定義的異常和手動(dòng) throw 的異常不做過(guò)多說(shuō)明搪搏。
捕獲異常
在了解了瀏覽器會(huì)拋出哪些異常后狭握,我們現(xiàn)在來(lái)進(jìn)一步了解在代碼層面我們可以做些什么來(lái)捕獲這些異常闪金,從而協(xié)助我們提升代碼的健壯性疯溺。
通用方式
try-catch
try-catch 語(yǔ)句標(biāo)記要嘗試的語(yǔ)句塊,并指定一個(gè)出現(xiàn)異常時(shí)拋出的響應(yīng)哎垦。try 語(yǔ)句包含了由一個(gè)或者多個(gè)語(yǔ)句組成的 try 塊囱嫩,catch 子句包含 try 塊中拋出異常時(shí)要執(zhí)行的語(yǔ)句。如果在 try 塊中有任何一個(gè)語(yǔ)句(或者從 try 塊中調(diào)用的函數(shù))拋出異常漏设,控制立即轉(zhuǎn)向 catch 子句墨闲。如果在 try 塊中沒(méi)有異常拋出,會(huì)跳過(guò) catch 子句郑口。
try {
const person = {};
console.log(person.info.name);
} catch (err) {
console.log(err);
}
// TypeError: Cannot read property 'name' of undefined at <anonymous>:3:27
上面的例子中鸳碧,我們?cè)噲D獲取一個(gè) undefined 對(duì)象的屬性值,這個(gè)異常被 catch 捕獲并輸出在控制臺(tái)犬性。
任何給定的異常只會(huì)被離它最近的封閉 catch 塊捕獲一次瞻离。
有時(shí)候,我們代碼中也會(huì)出現(xiàn) try-catch 嵌套的情況乒裆,如果內(nèi)層沒(méi)有 catch 事件套利,則會(huì)被外層 catch 捕獲:
try {
try {
throw new Error('error');
}
finally {
console.log('finally');
}
}
catch (err) {
console.log('outer', err);
}
// finally
// VM1360:10 outer Error: error at <anonymous>:3:11
如果在內(nèi)層拋出新異常,這個(gè)新異常會(huì)被外層 catch 捕獲:
try {
try {
throw new Error('error');
}
catch (err) {
console.log('inner', err);
throw err; // 拋出新異常鹤耍,沒(méi)有被內(nèi)層捕獲過(guò)
}
}
catch (err) {
console.log('outer', err);
}
// inner Error: error at <anonymous>:3:11
// outer Error: error at <anonymous>:3:11
try-catch 適用于知道某段代碼可能出現(xiàn)問(wèn)題的情況肉迫,只能捕獲同步的運(yùn)行時(shí)錯(cuò)誤,不能捕獲語(yǔ)法錯(cuò)誤和異步錯(cuò)誤:
- 語(yǔ)法錯(cuò)誤:語(yǔ)法錯(cuò)誤稿黄,try-catch 沒(méi)有正確執(zhí)行喊衫。
try {
let 1a = 'a';
console.log(1a);
} catch (err) {
console.log('catch syntax error');
}
// Uncaught SyntaxError: Invalid or unexpected token
- 異步錯(cuò)誤:因?yàn)楫惒绞录呀?jīng)放入異步事件隊(duì)列中,無(wú)法捕捉到杆怕。
try {
setTimeout(() => {
console.log(a)
}, 1000);
} catch (err) {
console.log('catch async error');
}
// Uncaught ReferenceError: a is not defined at <anonymous>:3:17
GlobalEventHandlers.onerror
從 GlobalEventHandlers.onerror 字面本身就可以看出格侯,這個(gè) onerror 用于處理全局的錯(cuò)誤。我們先來(lái)看下 MDN 上對(duì)它的解釋:
混合事件 GlobalEventHandlers 的 onerror 屬性用于處理 error 的事件财著。
- 當(dāng) JavaScript 運(yùn)行時(shí)錯(cuò)誤(包括語(yǔ)法錯(cuò)誤)發(fā)生時(shí)联四,window 會(huì)觸發(fā)一個(gè) ErrorEvent 接口的 error 事件,并執(zhí)行 window.onerror()撑教。
- 當(dāng)一項(xiàng)資源(圖片或 JavaScript文件)加載失敗朝墩,加載資源的元素會(huì)觸發(fā)一個(gè) Event 接口的 error 事件,并執(zhí)行該元素上的 onerror() 處理函數(shù)。這些 error 事件不會(huì)向上冒泡到 window收苏,不過(guò)(至少在 Firefox 中)能被單一的 window.addEventListener 捕獲亿卤。
從上面的文字,我們可以得出以下的結(jié)論:
- 代碼發(fā)生運(yùn)行時(shí)錯(cuò)誤(包括語(yǔ)法錯(cuò)誤)時(shí)鹿霸,會(huì)觸發(fā) window 的 error 事件排吴,我們可以通 window.onerror 和 window.addEventListener('error', function(event) { ... })來(lái)捕獲;
- 靜態(tài)資源加載失敗時(shí)懦鼠,會(huì)觸發(fā)加載資源的元素上的 onerror 事件钻哩,由于該事件不會(huì)冒泡到 winow,因此 window.onerror 是不會(huì)捕獲到靜態(tài)資源加載失敗的錯(cuò)誤的肛冶;
- 如果要使用全局方法捕獲靜態(tài)資源加載失敗的錯(cuò)誤街氢,可以使用 window.addEventListener。
我們還是來(lái)通過(guò)具體的例子來(lái)驗(yàn)證一下睦袖,先定義下 window.onerror 和 window.addEventListener 這兩個(gè)方法(需要寫在所有 JavaScript 腳本的前面珊肃,否則有可能捕獲不到錯(cuò)誤):
window.onerror = function(message, source, lineno, colno, error) {
console.log('window.onerror catch error:', message);
}
window.addEventListener('error', function(event) {
console.log('window.addEventListener catch error:', event.message)
});
- 語(yǔ)法錯(cuò)誤
let 1a = 'a';
console.log(1a);
// window.onerror catch error: Uncaught SyntaxError: Invalid or unexpected token
// window.addEventListener catch error: Uncaught SyntaxError: Invalid or unexpected token
- 靜態(tài)資源加載錯(cuò)誤
要捕獲靜態(tài)資源加載失敗的錯(cuò)誤,我們可以在靜態(tài)資源上添加 onerror 事件:
<script src="https://misc.360buyimg.com/jdf/lib/jquery-1.6.4.000.js" onerror="console.log('script load onerror')"></script>
// script load onerror
如果要全局捕獲靜態(tài)資源加載的錯(cuò)誤馅笙,需要給 addEventListener 方法增加第三個(gè)參數(shù)伦乔,即設(shè)置useCapture 為 ture:
window.addEventListener('error', function(event) {
console.log('window.addEventListener catch error:', event.message)
}, true);
加載一個(gè)錯(cuò)誤的JavaSctipt文件:
<script src="https://misc.360buyimg.com/jdf/lib/jquery-1.6.4.000.js"></script>
// window.addEventListener catch error: <script src=?"https:?/?/?misc.360buyimg.com/?jdf/?lib/?jquery-1.6.4.000.js">?</script>?
- 異步錯(cuò)誤
setTimeout(() => {
console.log(a)
}, 1000);
// window.onerror catch error: Uncaught ReferenceError: a is not defined
// window.addEventListener catch error: Uncaught ReferenceError: a is not defined
從上面的例子可以看出,GlobalEventHandlers.onerror 適用于需要捕獲全局的異常的情況董习。另外烈和,同 try-catch 相比,window.onerror 和 window.addEventListener 可以捕獲語(yǔ)法錯(cuò)誤和異步錯(cuò)誤阱飘,element.onerror 和 window.addEventListener 可以捕獲靜態(tài)資源加載失敗的錯(cuò)誤斥杜。
盡管 window.onerror 和 window.addEventListener 可以處理異步錯(cuò)誤,但是對(duì)于 Promise 的異步錯(cuò)誤沥匈,是捕獲不到的蔗喂。
new Promise((resolve, reject) => {
console.log(a)
})
// Uncaught (in promise) ReferenceError: a is not defined
promise-catch
Promise 的錯(cuò)誤需要使用 promise-catch 來(lái)捕獲,這些錯(cuò)誤可以是代碼運(yùn)行時(shí)的錯(cuò)誤高帖,也可以是我們處理業(yè)務(wù)邏輯時(shí) reject 的錯(cuò)誤缰儿。
- 代碼錯(cuò)誤
new Promise((resolve, reject) => {
console.log(a)
}).catch(err => {
console.log('promise catch error:', err.message)
})
// promise catch error: a is not defined
- reject的錯(cuò)誤
new Promise((resolve, reject) => {
reject(new Error('error rejected!'))
}).catch(err => {
console.log('promise catch error:', err.message)
})
// promise catch error: error rejected!
promise-catch 的適用范圍很明確,就是處理 Promise 的異常散址。但是這里有例外乖阵,async/await 雖然本質(zhì)上還是 Promise 語(yǔ)法,但是可以被 try-catch 捕獲预麸。(因此我們提倡使用 async/await 來(lái)代替純 Promise瞪浸,這樣子可以更方便的被捕獲,如果你還是使用 Promise吏祸,要記得添加 catch事件对蒲,或者依賴全局捕獲錯(cuò)誤的方法。)
function fn() {
return new Promise((resolve, reject) => {
console.log(a);
resolve();
})
}
async function test() {
try {
await fn();
} catch (err) {
console.log('try-catch error:', err.message);
}
}
test();
// try-catch error: a is not defined
unhandledrejection
我們開(kāi)發(fā)的時(shí)候,如果有些 Promise 異常沒(méi)有被處理蹈矮,可以使用全局的方法來(lái)捕獲砰逻,這里用到了 unhandledrejection 事件。
window.onunhandledrejection = function(err) {
console.log('window.onunhandledrejection catch error:', err.reason);
}
window.addEventListener('unhandledrejection', function(event) {
console.log('window.addEventListener unhandledrejection catch error:', event.reason);
});
// window.onunhandledrejection catch error: ReferenceError: a is not defined
// window.addEventListener unhandledrejection catch error: ReferenceError: a is not defined
我們?cè)趯懬岸隧?xiàng)目的時(shí)候一般都是使用框架的泛鸟,除了上面的通用的捕獲異常的方法蝠咆,框架本身還提供了一些方法供我們使用。
Vue 中捕獲異常
Vue 的官方文檔沒(méi)有專門的章節(jié)來(lái)介紹異常的處理北滥「詹伲總的來(lái)說(shuō),在生產(chǎn)環(huán)境有以下幾種方式(開(kāi)發(fā)環(huán)境的錯(cuò)誤通過(guò)控制臺(tái)就可以看到碑韵,這里不再鋪開(kāi)赡茸,詳見(jiàn) Vue 官網(wǎng)中的 warnHandler 及 renderError):
- errorHandler
- errorCaptured
errorHandler
errorHandler 在 Vue 中用于捕獲全局的錯(cuò)誤:
Vue.config.errorHandler = function (err, vm, info) {
console.log('vue errorHandler: ' + err);
}
errorHandler 可以捕獲的異常包含以下方面:
- 組件的渲染和觀察期間未捕獲的錯(cuò)誤
需要注意的是 template 中如果引用一個(gè)不存在的變量的話是不會(huì)被 errorHandler 捕獲的缎脾,這個(gè)錯(cuò)誤需要使用 errorHandler 捕獲祝闻。
<template>
<div>{{currentTime}}</div>
</template>
<script>
export default {
name: 'ErrorTest',
data () {
return {}
}
}
</script>
// 沒(méi)有捕獲到異常
稍微修改一下,在 data 中加入 currentTime 變量遗菠,但是賦值錯(cuò)誤:
<template>
<div>{{currentTime}}</div>
</template>
<script>
export default {
name: 'ErrorTest',
data () {
return {
currentTime
}
}
}
</script>
// vue errorHandler: ReferenceError: currentTime is not defined
- 捕獲組件生命周期鉤子里的錯(cuò)誤(版本>=2.2.0)
<template>
<div>{{currentTime}}</div>
</template>
<script>
export default {
name: 'ErrorTest',
data () {
return {
currentTime: Date.now()
}
},
mounted () {
console.log(currentTime)
}
}
</script>
// vue errorHandler: ReferenceError: currentTime is not defined
- 自定義事件處理函數(shù)內(nèi)部的錯(cuò)誤(版本>=2.4.0)
我們假設(shè)子組件使用 $emit 方法觸發(fā)了 change 事件:
<template>
<child @change="changeHandler" />
</template>
<script>
import Child from './child'
export default {
name: 'ErrorTest',
components: {
Child
},
methods: {
changeHandler () {
console.log(changedValue)
}
}
}
</script>
// vue errorHandler: ReferenceError: changedValue is not defined
- v-on DOM 監(jiān)聽(tīng)器內(nèi)部拋出的錯(cuò)誤(版本>=2.6.0)
<template>
<button v-on:click="clickHandler">click here</button>
</template>
<script>
export default {
name: 'ErrorTest',
methods: {
clickHandler () {
console.log(target)
}
}
}
</script>
// vue errorHandler: ReferenceError: target is not defined
- 如果任何被覆蓋的鉤子或處理函數(shù)返回一個(gè) Promise 鏈 (例如 async 函數(shù))联喘,則來(lái)自其 Promise 鏈的錯(cuò)誤也會(huì)被處理。(版本>=2.6.0)
<template>
<button v-on:click="clickHandler">click here</button>
</template>
<script>
export default {
name: 'ErrorTest',
methods: {
clickHandler () {
return new Promise(() => {
console.log(target)
}) // 必須要return辙纬,否則不會(huì)被捕獲
}
}
}
</script>
// vue errorHandler: ReferenceError: target is not defined
errorCaptured
errorCaptured 是 Vue 在 2.5.0 新增加的鉤子函數(shù)豁遭,用于捕獲來(lái)自子組件的錯(cuò)誤。現(xiàn)在贺拣,我們依然假設(shè)子組件拋出了一個(gè)錯(cuò)誤(這里依然保留上一節(jié)提到的 errorHandler 方法):
<template>
<child />
</template>
<script>
import Child from './child'
export default {
name: 'ErrorTest',
data() {
return {
currentTime: Date.now()
}
},
components: {
Child
},
errorCaptured (err, vm, info) {
console.log('vue errorCaptured: ' + err);
}
}
</script>
// vue errorCaptured: ReferenceError: current is not defined
// vue errorHandler: ReferenceError: current is not defined
上面的例子顯示蓖谢,errorCaptured 先于 errorHandler 捕獲了錯(cuò)誤,如果不想再次被上級(jí)捕獲譬涡,可以在鉤子函數(shù)中返回 false 闪幽。附上Vue官網(wǎng)給出的錯(cuò)誤傳播規(guī)則:
- 默認(rèn)情況下,如果全局的 config.errorHandler 被定義涡匀,所有的錯(cuò)誤仍會(huì)發(fā)送它盯腌,因此這些錯(cuò)誤仍然會(huì)向單一的分析服務(wù)的地方進(jìn)行匯報(bào)。
- 如果一個(gè)組件的繼承或父級(jí)從屬鏈路中存在多個(gè) errorCaptured 鉤子陨瘩,則它們將會(huì)被相同的錯(cuò)誤逐個(gè)喚起腕够。
- 如果此 errorCaptured 鉤子自身拋出了一個(gè)錯(cuò)誤,則這個(gè)新錯(cuò)誤和原本被捕獲的錯(cuò)誤都會(huì)發(fā)送給全局的 config.errorHandler舌劳。
- 一個(gè) errorCaptured 鉤子能夠返回 false 以阻止錯(cuò)誤繼續(xù)向上傳播帚湘。本質(zhì)上是說(shuō)“這個(gè)錯(cuò)誤已經(jīng)被搞定了且應(yīng)該被忽略”。它會(huì)阻止其它任何會(huì)被這個(gè)錯(cuò)誤喚起的 errorCaptured 鉤子和全局的 config.errorHandler甚淡。
React 中捕獲異常
React官網(wǎng)中有專門的章節(jié)介紹異常的章節(jié)——錯(cuò)誤邊界大诸。
錯(cuò)誤邊界
錯(cuò)誤邊界的概念是 React 在 React 16 引入的概念,是為了解決部分 UI 的 JavaScript 錯(cuò)誤引起的應(yīng)用崩潰問(wèn)題。
錯(cuò)誤邊界是一種 React 組件底挫,這種組件可以捕獲并打印發(fā)生在其子組件樹(shù)任何位置的 JavaScript 錯(cuò)誤恒傻,并且,它會(huì)渲染出備用 UI建邓,而不是渲染那些崩潰了的子組件樹(shù)盈厘。錯(cuò)誤邊界在渲染期間、生命周期方法和整個(gè)組件樹(shù)的構(gòu)造函數(shù)中捕獲錯(cuò)誤官边。
如果一個(gè) class 組件中定義了 static getDerivedStateFromError() 或 componentDidCatch() 這兩個(gè)生命周期方法中的任意一個(gè)(或兩個(gè))時(shí)沸手,那么它就變成一個(gè)錯(cuò)誤邊界。
只有 class 組件才可以成為錯(cuò)誤邊界組件注簿。
基于上面的說(shuō)明契吉,我們的錯(cuò)誤邊界的組件可以這樣寫:
import React from 'react'
import Default from '/default'
import { uploadError } from '../utils/error'
class ErrorBoundary extends React.Component {
constructor (props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError (err) {
// 發(fā)生錯(cuò)誤,顯示降級(jí)后的UI
return { hasError: true }
}
componentDidCatch (err, info) {
// 可以將錯(cuò)誤日志上報(bào)給服務(wù)器
uploadError(err)
}
render () {
if (this.state.hasError) {
return <Default />
}
return this.props.children
}
}
export default ErrorBoundary
// 使用:
<ErrorBoundary>
<Child />
</ErrorBoundary>
錯(cuò)誤邊界的工作方式類似于 JavaScript 的 catch {}诡渴,不同的地方在于錯(cuò)誤邊界只針對(duì) React 組件捐晶。錯(cuò)誤邊界無(wú)法捕獲的錯(cuò)誤有下面幾個(gè)方面,這些異常需要使用 try-catch 等捕獲:
- 事件處理
- 異步代碼
- 服務(wù)端渲染
- 它自身拋出來(lái)的錯(cuò)誤(并非它的子組件)
iframe 異常
當(dāng)我們的頁(yè)面引用了 iframe 的時(shí)候妄辩,也可以使用 onerror 方法捕獲 iframe 的異常惑灵,但這種形式僅限于你自己的頁(yè)面和 iframe 的頁(yè)面同域名的情況:
<iframe src="./iframe.html"></iframe>
<script>
window.frames[0].onerror = function (message) {
console.log('iframe error: ' + message);
return true;
}
</script>
// iframe error: Uncaught ReferenceError: a is not defined
頁(yè)面崩潰
頁(yè)面崩潰和上面提到的異常捕獲的情況是不一樣的,頁(yè)面崩潰時(shí)眼耀,JavaScript 代碼已經(jīng)不執(zhí)行了英支。但還是有辦法來(lái)監(jiān)控到頁(yè)面崩潰的,目前有兩種:一個(gè)是load 和 beforeunload 結(jié)合哮伟, 另外一個(gè)是基于 Service Worker干花。
load 和 beforeunload 事件
我們先來(lái)看下代碼:
window.addEventListener('load', function () {
sessionStorage.setItem('good_exit', 'pending');
setInterval(function () {
sessionStorage.setItem('time_before_crash', new Date().toString());
}, 1000);
});
window.addEventListener('beforeunload', function () {
sessionStorage.setItem('good_exit', 'true');
});
if(sessionStorage.getItem('good_exit') &&
sessionStorage.getItem('good_exit') !== 'true') {
/*
insert crash logging code here
*/
alert('Hey, welcome back from your crash, looks like you crashed on: ' + sessionStorage.getItem('time_before_crash'));
}
從上面的代碼來(lái)看,這個(gè)方法其實(shí)是利用了頁(yè)面崩潰時(shí)無(wú)法觸發(fā) beforeunload 事件來(lái)實(shí)現(xiàn)的楞黄。頁(yè)面加載完成后池凄,在 sessionStorage 中存儲(chǔ) good_exit 的值為 pending。如果頁(yè)面正常關(guān)閉谅辣, 會(huì)觸發(fā) beforeunload 事件修赞,在 beforeunload 事件中,我們將 good_exit 的值重置為 true桑阶。如果頁(yè)面崩潰了柏副,刷新頁(yè)面時(shí),從 sessionStorage 中讀取到的值就是 pending 而不是 true蚣录。
用上面的方式處理有以下問(wèn)題:
- 由于是 sessionStorage 存儲(chǔ)的值割择,頁(yè)面崩潰后如果用戶關(guān)閉頁(yè)面或重新打開(kāi)瀏覽器,sessionStorage 中存儲(chǔ)的 good_exit 值我們是獲取不到的萎河;
- 如果前進(jìn)或后退荔泳,頁(yè)面會(huì)從緩存中加載蕉饼,有時(shí)候是不會(huì)觸發(fā) load 事件的。
即使存在上面的問(wèn)題玛歌,但這個(gè)方法對(duì)我們依然有借鑒意義昧港。頁(yè)面崩潰時(shí),JavaScript 不會(huì)執(zhí)了支子,DOM 也卸載了创肥,我們對(duì)頁(yè)面的渲染是無(wú)能為力的。但我們可以在用戶再次刷新頁(yè)面時(shí)捕獲到上次的崩潰信息值朋,并將崩潰上報(bào)到監(jiān)控系統(tǒng)叹侄。如果監(jiān)控系統(tǒng)收到大量的崩潰信息,就說(shuō)明我們的頁(yè)面出現(xiàn)了嚴(yán)重的問(wèn)題了昨登,這時(shí)候我們就需要想辦法復(fù)現(xiàn)或者從代碼邏輯層面找到崩潰原因了趾代。
基于 Service Worker
基于 Service Worker 的方案其實(shí)也是利用了頁(yè)面崩潰時(shí)無(wú)法觸發(fā) beforeunload 事件來(lái)實(shí)現(xiàn)的,與 load 和 beforeunload 的區(qū)別是 Service Worker 相對(duì)于驅(qū)動(dòng)應(yīng)用的主 JavaScript 線程丰辣,它運(yùn)行在其他線程中撒强,即使網(wǎng)頁(yè)崩潰了,Service Worker 一般情況下也不會(huì)崩潰糯俗。所以尿褪,我們不需要等到用戶再次刷新頁(yè)面才能獲取上次的崩潰信息了睦擂。
// 頁(yè)面 JavaScript 代碼
if (navigator.serviceWorker.controller !== null) {
let HEARTBEAT_INTERVAL = 5 * 1000; // 每五秒發(fā)一次心跳
let sessionId = uuid();
let heartbeat = function () {
navigator.serviceWorker.controller.postMessage({
type: 'heartbeat',
id: sessionId,
data: {} // 附加信息得湘,如果頁(yè)面 crash,上報(bào)的附加數(shù)據(jù)顿仇,比如頁(yè)面地址等
});
}
window.addEventListener("beforeunload", function() {
navigator.serviceWorker.controller.postMessage({
type: 'unload',
id: sessionId
});
});
setInterval(heartbeat, HEARTBEAT_INTERVAL);
heartbeat();
}
// Service Worker
const CHECK_CRASH_INTERVAL = 10 * 1000; // 每 10s 檢查一次
const CRASH_THRESHOLD = 15 * 1000; // 15s 超過(guò)15s沒(méi)有心跳則認(rèn)為已經(jīng) crash
const pages = {}
let timer
function checkCrash() {
const now = Date.now()
for (var id in pages) {
let page = pages[id]
if ((now - page.t) > CRASH_THRESHOLD) {
// 上報(bào) crash
delete pages[id]
}
}
if (Object.keys(pages).length == 0) {
clearInterval(timer)
timer = null
}
}
worker.addEventListener('message', (e) => {
const data = e.data;
if (data.type === 'heartbeat') {
pages[data.id] = {
t: Date.now()
}
if (!timer) {
timer = setInterval(function () {
checkCrash()
}, CHECK_CRASH_INTERVAL)
}
} else if (data.type === 'unload') {
delete pages[data.id]
}
})
上面代碼的思路是:
- 網(wǎng)頁(yè)加載后淘正,通過(guò) postMessage API 每 5s 給 sw 發(fā)送一個(gè)心跳,表示自己的在線臼闻,sw 將在線的網(wǎng)頁(yè)登記下來(lái)鸿吆,更新登記時(shí)間;
- 網(wǎng)頁(yè)在 beforeunload 時(shí)述呐,通過(guò) postMessage API 告知自己已經(jīng)正常關(guān)閉惩淳,sw 將登記的網(wǎng)頁(yè)清除;
- 如果網(wǎng)頁(yè)在運(yùn)行的過(guò)程中 crash 了乓搬,sw 中的 running 狀態(tài)將不會(huì)被清除思犁,更新時(shí)間停留在奔潰前的最后一次心跳;
- Service Worker 每 10s 查看一遍登記中的網(wǎng)頁(yè)进肯,發(fā)現(xiàn)登記時(shí)間已經(jīng)超出了一定時(shí)間(比如 15s)即可判定該網(wǎng)頁(yè) crash 了激蹲。
同樣的,Service Worker捕獲的錯(cuò)誤對(duì)前端監(jiān)控是很有用的江掩。
總結(jié)
具體到實(shí)際工作中学辱,我們要處理的異常分為以下幾種:
- 語(yǔ)法錯(cuò)誤及代碼異常:對(duì)可疑區(qū)域增加 try-catch乘瓤,全局增加 window.onerror;
- 數(shù)據(jù)請(qǐng)求異常:使用 promise-catch 處理 Promise 異常,使用 unhandledrejection 處理未捕獲的Promise異常策泣,使用 try-catch 處理 async/await 異常;
- 靜態(tài)資源加載異常:在元素上添加 onerror衙傀,全局增加 window.addEventListener;
- 白屏:Vue 使用 errorHandler萨咕, React 使用 componentDidCatch差油,渲染備用UI;
- iframe異常:同域條件下使用 onerror任洞。
- 頁(yè)面崩潰:load 和 beforeunload 結(jié)合或者使用 Service Worker蓄喇。
原文地址:https://yolkpie.net/2021/01/28/%E5%89%8D%E7%AB%AF%E5%BC%82%E5%B8%B8%E5%A4%84%E7%90%86/