本文首發於公眾號: 符合預期的CoyPan
寫在前面
在前端項目中,由於JavaScript本身是一個弱類型語言,加上瀏覽器環境的復雜性,網絡問題等等,很容易發生錯誤。做好網頁錯誤監控,不斷優化代碼,提高代碼健壯性是一項很重要的工作。本文將從Error開始,講到如何捕獲頁面中的異常。文章較長,細節較多,請耐心觀看。
前端開發中的Error
JavaScript中的Error
JavaScript中,Error是一個構造函數,通過它創建一個錯誤對象。當運行時錯誤產生時,Error的實例對象會被拋出。構造一個Error的語法如下:
// message: 錯誤描述
// fileName: 可選。被創建的Error對象的fileName屬性值。默認是調用Error構造器代碼所在的文件的名字。
// lineNumber: 可選。被創建的Error對象的lineNumber屬性值。默認是調用Error構造器代碼所在的文件的行號。
new Error([message[, fileName[, lineNumber]]])
ECMAScript標准:
Error有兩個標准屬性:
-
Error.prototype.name:錯誤的名字 -
Error.prototype.message:錯誤的描述
例如,在chrome控制台中輸入以下代碼:
var a = new Error('錯誤測試');
console.log(a); // Error: 錯誤測試
// at <anonymous>:1:9
console.log(a.name); // Error
console.log(a.message); // 錯誤測試
Error只有一個標准方法:
-
Error.prototype.toString:返回表示一個表示錯誤的字符串。
接上面的代碼:
a.toString(); // "Error: 錯誤測試"
非標准的屬性
各個瀏覽器廠商對於Error都有自己的實現。比如下面這些屬性:
-
Error.prototype.fileName:產生錯誤的文件名。 -
Error.prototype.lineNumber:產生錯誤的行號。 -
Error.prototype.columnNumber:產生錯誤的列號。 -
Error.prototype.stack:堆棧信息。這個比較常用。
這些屬性均不是標准屬性,在生產環境中謹慎使用。不過現代瀏覽器差不多都支持了。
Error的種類
除了通用的Error構造函數外,JavaScript還有7個其他類型的錯誤構造函數。
- InternalError: 創建一個代表Javascript引擎內部錯誤的異常拋出的實例。 如: "遞歸太多"。非ECMAScript標准。
- RangeError: 數值變量或參數超出其有效范圍。例子:var a = new Array(-1);
- EvalError: 與eval()相關的錯誤。eval()本身沒有正確執行。
- ReferenceError: 引用錯誤。 例子:console.log(b);
- SyntaxError: 語法錯誤。例子:var a = ;
- TypeError: 變量或參數不屬於有效范圍。例子:[1,2].split('.')
- URIError: 給 encodeURI或 decodeURl()傳遞的參數無效。例子:decodeURI('%2')
當JavaScript運行過程中出錯時,會拋出上8種(上述7種加上通用錯誤類型)錯誤中的其中一種錯誤。錯誤類型可以通過error.name拿到。
你也可以基於Error構造自己的錯誤類型,這里就不展開了。
其他錯誤
上面介紹的都是JavaScript本身運行時會發生的錯誤。頁面中還會有其他的異常,比如錯誤地操作了DOM。
DOMException
DOMException是W3C DOM核心對象,表示調用一個Web Api時發生的異常。什么是Web Api呢?最常見的就是DOM元素的一系列方法,其他還有XMLHttpRequest、Fetch等等等等,這里就不一一說明了。直接看下面一個操作DOM的例子:
var node = document.querySelector('#app');
var refnode = node.nextSibling;
var newnode = document.createElement('div');
node.insertBefore(newnode, refnode);
// 報錯:Uncaught DOMException: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.
單從JS代碼邏輯層面來看,沒有問題。但是代碼的操作不符合DOM的規則。
DOMException構造函數的語法如下:
// message: 可選,錯誤描述。
// name: 可選,錯誤名稱。常量,具體值可以在這里找到:https://developer.mozilla.org/zh-CN/docs/Web/API/DOMException
new DOMException([message[, name]]);
DOMException有以下三個屬性:
-
DOMException.code:錯誤編號。 -
DOMException.message:錯誤描述。 -
DOMException.name:錯誤名稱。
以上面那段錯誤代碼為例,其拋出的DOMException各屬性的值為:
code: 8
message: "Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node."
name: "NotFoundError"
Promise產生的異常
在Promise中,如果Promise被reject了,就會拋出異常:PromiseRejectionEvent。注意,下面兩種情況都會導致Promise被reject:
- 業務代碼本身調用了
Promise.reject。 -
Promise中的代碼出錯。
PromiseRejectionEvent的構造函數目前在瀏覽器中大多都不兼容,這里就不說了。
PromiseRejectionEvent的屬性有兩個:
-
PromiseRejectionEvent.promise:被reject的Promise。 -
PromiseRejectionEvent.reason:Promise被reject的原因。會傳遞給reject。Promsie的catch中的參數。
加載資源出錯
由於網絡,安全等原因,網頁加載資源失敗,請求接口出錯等,也是一種常見的錯誤。
關於錯誤的小結
一個網頁在運行過程中,可能發生四種錯誤:
- JavaScript在運行過程,語言自身拋出的異常。
- JavaScript在運行過程中,調用Web Api時發生異常。
- Promise中的拒絕。
- 網頁加載資源,調用接口時發生異常。
我認為,對於前兩種錯誤,我們在平時的開發過程中,不用特別去區分,可以統一成:【代碼出錯】。
捕獲錯誤
網頁發生錯誤,開發者如何捕獲這些錯誤呢 ? 常見的有以下方法。
try...catch...
try...catch…大家都不陌生了。一般用來在具體的代碼邏輯中捕獲錯誤。
try {
throw new Error("oops");
}
catch (ex) {
console.log("error", ex.message); // error oops
}
當try-block中的代碼發生異常時,可以在catck-block中將異常接住,瀏覽器便不會拋出錯誤。但是,這種方式並不能捕獲異步代碼中的錯誤,如:
try {
setTimeout(function(){
throw new Error('lala');
},0);
} catch(e) {
console.log('error', e.message);
}
這個時候,瀏覽器依然會拋出錯誤:Uncaught Error: lala。
試想以下,如果我們將所有的代碼合理的划分,然后都用try catch包起來,是不是就可以捕獲到所有的錯誤了呢?可以通過編譯工具來實現這個功能。不過,try catch是比較耗費性能的。
window.onerror
window.onerror = function(message, source, lineno, colno, error) { ... }
函數參數:
-
message:錯誤信息(字符串) -
source:發生錯誤的腳本URL(字符串) -
lineno:發生錯誤的行號(數字) -
colno:發生錯誤的列號(數字) -
error:Error對象(對象)
注意,如果這個函數返回true,那么將會阻止執行瀏覽器默認的錯誤處理函數。
window.addEventListener('error')
window.addEventListener('error', function(event) { ... })
我們調用Object.prototype.toString.call(event),返回的是[object ErrorEvent]。可以看到event是ErrorEvent對象的實例。ErrorEvent是事件對象在腳本發生錯誤時產生,從Event繼承而來。由於是事件,自然可以拿到target屬性。ErrorEvent還包括了錯誤發生時的信息。
- ErrorEvent.prototype.message: 字符串,包含了所發生錯誤的描述信息。
- ErrorEvent.prototype.filename: 字符串,包含了發生錯誤的腳本文件的文件名。
- ErrorEvent.prototype.lineno: 數字,包含了錯誤發生時所在的行號。
- ErrorEvent.prototype.colno: 數字,包含了錯誤發生時所在的列號。
- ErrorEvent.prototype.error: 發生錯誤時所拋出的 Error 對象。
注意,這里的ErrorEvent.prototype.error對應的Error對象,就是上文提到的Error, InternalError,RangeError,EvalError,ReferenceError,SyntaxError,TypeError,URIError,DOMException中的一種。
window.addEventListener('unhandledrejection')
window.addEventListener('unhandledrejection', function (event) { ... });
在使用Promise的時候,如果沒有聲明catch代碼塊,Promise的異常會被拋出。只能通過這個方法或者window.onunhandledrejection才能捕獲到該異常。
event就是上文提到的PromiseRejectionEvent。我們只需要關注其reason就行。
window.onerror 和 window.addEventListener('error')的區別
- 首先是事件監聽器和事件處理器的區別。監聽器只能聲明一次,后續的聲明會覆蓋之前的聲明。而事件處理器則可以綁定多個回調函數。
- 資源( <img> 或 <script> )加載失敗時,加載資源的元素會觸發一個
Event接口的error事件,並執行該元素上的onerror()處理函數。但這些error事件不會向上冒泡到window。不過,這些error事件能被window.addEventListener('error')捕獲。也就是說,面對資源加載失敗的錯誤,只能用window.addEventListerner('error'),window.onerror無效。
關於錯誤捕獲的小結
我認為,在開發的過程中,對於容易出錯的地方,可以使用try{}catch(){}來進行錯誤的捕獲,做好兜底處理,避免頁面掛掉。而對於全局的錯誤捕獲,在現代瀏覽器中,我傾向於只使用使用window.addEventListener('error'),window.addEventListener('unhandledrejection')就行了。如果需要考慮兼容性,需要加上window.onerror,三者同時使用,window.addEventListener('error')專門用來捕獲資源加載錯誤。
跨域腳本錯誤,Script Error
在進行錯誤捕獲的過程中,很多時候並不能拿到完整的錯誤信息,得到的僅僅是一個"Script Error"。
產生原因
由於12年前這篇文章里提到的安全問題:https://blog.jeremiahgrossman...,瀏覽器們都對內核進行了升級:
當加載自不同域的腳本中發生語法錯誤時,為避免信息泄露,語法錯誤的細節將不會報告,而是使用簡單的"Script error."代替。
一般而言,頁面的JS文件都是放在CDN的,和頁面自身的URL產生了跨域問題,所以引起了"Script Error"。
解決辦法
服務端添加Access-Control-Allow-Origin,頁面在script標簽中配置 crossorigin="anonymous"。這樣,便解決了因為跨域而帶來的"Script Error"問題。
能繞過Script Error么
上面介紹了"Script Error"的標准解決方案。但是,並不是所有的瀏覽器都支持crossorigin="anonymous",也不是所有的服務端都能及時配置Access-Control-Allow-Origin,這種情況下,還有什么方法能在全局捕獲到所有的錯誤,並拿到詳細信息呢?
劫持原生方法
看一個例子:
const nativeAddEventListener = EventTarget.prototype.addEventListener; // 先將原生方法保存起來。
EventTarget.prototype.addEventListener = function (type, func, options) { // 重寫原生方法。
const wrappedFunc = function (...args) { // 將回調函數包裹一層try catch
try {
return func.apply(this, args);
} catch (e) {
const errorObj = {
...
error_name: e.name || '',
error_msg: e.message || '',
error_stack: e.stack || (e.error && e.error.stack),
error_native: e,
...
};
// 接下來可以將errorObj統一進行處理。
}
}
return nativeAddEventListener.call(this, type, wrappedFunc, options); // 調用原生的方法,保證addEventListener正確執行
}
我們劫持了原生的addEventListener代碼,對addEventListener代碼中的回調函數加了一層try{}catch(){},這樣,回調函數中拋出的錯誤會被catch住,瀏覽器不會對try-catch 起來的異常進行跨域攔截,所以我們可以拿到詳細的錯誤信息。通過上面的操作,我們可以拿到所有監聽事件的回調函數中的錯誤啦。其他的場景怎么辦呢?繼續劫持原生方法。
一個前端項目中,除了事件監聽,接口請求也是一個頻繁出現的場景。接着上面的代碼,下面我們來劫持一下Ajax。
if (!XMLHttpRequest) {
return;
}
const nativeAjaxSend = XMLHttpRequest.prototype.send; // 首先將原生的方法保存。
const nativeAjaxOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (mothod, url, ...args) { // 劫持open方法,是為了拿到請求的url
const xhrInstance = this;
xhrInstance._url = url;
return nativeAjaxOpen.apply(this, [mothod, url].concat(args));
}
XMLHttpRequest.prototype.send = function (...args) { // 對於ajax請求的監控,主要是在send方法里處理。
const oldCb = this.onreadystatechange;
const oldErrorCb = this.onerror;
const xhrInstance = this;
xhrInstance.addEventListener('error', function (e) { // 這里捕獲到的error是一個ProgressEvent。e.target 的值為 XMLHttpRequest的實例。當網絡錯誤(ajax並沒有發出去)或者發生跨域的時候,會觸發XMLHttpRequest的error, 此時,e.target.status 的值為:0,e.target.statusText 的值為:''
const errorObj = {
...
error_msg: 'ajax filed',
error_stack: JSON.stringify({
status: e.target.status,
statusText: e.target.statusText
}),
error_native: e,
...
}
/*接下來可以對errorObj進行統一處理*/
});
xhrInstance.addEventListener('abort', function (e) { // 主動取消ajax的情況需要標注,否則可能會產生誤報
if (e.type === 'abort') {
xhrInstance._isAbort = true;
}
});
this.onreadystatechange = function (...innerArgs) {
if (xhrInstance.readyState === 4) {
if (!xhrInstance._isAbort && xhrInstance.status !== 200) { // 請求不成功時,拿到錯誤信息
const errorObj = {
error_msg: JSON.stringify({
code: xhrInstance.status,
msg: xhrInstance.statusText,
url: xhrInstance._url
}),
error_stack: '',
error_native: xhrInstance
};
/*接下來可以對errorObj進行統一處理*/
}
}
oldCb && oldCb.apply(this, innerArgs);
}
return nativeAjaxSend.apply(this, args);
}
}
我們引用框架時,某些框架會用console.error的方法拋出錯誤。我們可以劫持console.error,來捕獲錯誤。
const nativeConsoleError = window.console.error;
window.console.error = function (...args) {
args.forEach(item => {
if (typeDetect.isError(item)) {
...
} else {
...
}
});
nativeConsoleError.apply(this, args);
}
原生的方法有很多,還比如fetch、setTimeout等。這里不一一列舉了。但是使用劫持原生方法以覆蓋所有的場景是十分困難的。
前端框架是怎么捕獲錯誤的
我們主要來看一下React和Vue是怎么解決錯誤捕獲問題的。
React中的錯誤捕獲
在Reactv16以前,可以使用unstable_handleError來處理捕獲的錯誤。Reactv16以后,使用componentDidCatch來處理捕獲的錯誤。若需全局捕獲錯誤,可以在最外層包裹一層組件,在componentDidCatch中捕獲錯誤信息。具體用法參考官方文檔:https://reactjs.org/blog/2017/07/26/error-handling-in-react-16.html
在React中,錯誤會被throw出來。在寫作本文的時候,我遇到一個問題,如果在加載react 相關的代碼前,按照上文的方法劫持addEventListener,那么React將不會正常工作了,但是沒有任何報錯。React有一套自己的事件系統,會不會和這個有關呢?之前沒有研究過React源碼,粗略調試了以下,沒有發現問題所在。后續會仔細研究。
Vue中的錯誤捕獲
Vue的源碼中,在關鍵函數(比如鈎子函數等)執行的時候,都加上try{}catch(){},在cacth中處理捕獲到的錯誤。看下面的源碼。
...
// vue源碼片段
function callHook (vm, hook) {
// #7573 disable dep collection when invoking lifecycle hooks
pushTarget();
var handlers = vm.$options[hook];
if (handlers) {
for (var i = 0, j = handlers.length; i < j; i++) {
try {
handlers[i].call(vm);
} catch (e) {
handleError(e, vm, (hook + " hook"));
}
}
}
if (vm._hasHookEvent) {
vm.$emit('hook:' + hook);
}
popTarget();
}
...
function globalHandleError (err, vm, info) {
if (config.errorHandler) {
try {
return config.errorHandler.call(null, err, vm, info)
} catch (e) {
logError(e, null, 'config.errorHandler');
}
}
logError(err, vm, info);
}
function logError (err, vm, info) {
{
warn(("Error in " + info + ": \"" + (err.toString()) + "\""), vm);
}
/* istanbul ignore else */
if ((inBrowser || inWeex) && typeof console !== 'undefined') {
console.error(err);
} else {
throw err
}
}
Vue中提供了Vue.config.errorHandler`來處理捕獲到的錯誤。
// err: 捕獲到的錯誤對象。
// vm: 出錯的VueComponent.
// info: Vue 特定的錯誤信息,比如錯誤所在的生命周期鈎子
Vue.config.errorHandler = function (err, vm, info) {}
如果開發者沒有配置Vue.config.errorHandler,那么捕獲到的錯誤會以console.error的方式輸出。
上報錯誤
捕獲到錯誤后,如何上報呢?最常見、最簡單的方式就是通過<img>了。代碼簡單,且沒有跨域煩惱。
function logError(error){
var img = new Image();
img.onload = img.onerror = function(){
img = null;
}
img.src = `${上報地址}?${processErrorParam(error)}`;
}
當上報數據比較多時,可以使用post的方式進行上報。
錯誤的上報其實是一項復雜的工程,涉及到上報策略、上報分類等等。特別是在項目的業務比較復雜的時候,更應該關注上報的質量,避免影響到業務功能的正常運行。使用了打包工具處理的代碼,往往還需要結合sourceMap進行代碼定位。本文就不做介紹了。
寫在后面
要建立一套完整、可用的前端錯誤監控體系是一項復雜、浩大的工程。但是,這項工程往往是必備的。本文主要介紹了你可能沒關注過的Error的一些細節,以及如何捕獲頁面中的錯誤。關於劫持原生方法部分的代碼,你可以在https://github.com/CoyPan/Fec找到。
符合預期。
歡迎關注我的公眾號: 符合預期的CoyPan,
這里只有干貨,符合你的預期。

