常見的前端異常及其捕獲方式
前端異常通??梢苑譃橐韵聨追N類型:
- js 代碼執(zhí)行時異常;
- promise 類型異常;
- 資源加載類型異常;
- 網(wǎng)絡(luò)請求類型異常;
- 跨域腳本執(zhí)行異常;
- 不同類型的異常,捕獲方式不同。
js 代碼執(zhí)行時異常
js 代碼執(zhí)行異常,是我們經(jīng)常遇到異常。這一類型的異常,又可以具體細(xì)分為:
- Error,最基本的錯誤類型,其他的錯誤類型都繼承自該類型。通過 Error,我們可以自定義 Error 類型。
- RangeError: 范圍錯誤。當(dāng)出現(xiàn)堆棧溢出(遞歸沒有終止條件)、數(shù)值超出范圍(new Array 傳入負(fù)數(shù)或者一個特別大的整數(shù))情況時會拋出這個異常。
- ReferenceError,引用錯誤。當(dāng)一個不存在的對象被引用時發(fā)生的異常。
- SyntaxError,語法錯誤。如變量以數(shù)字開頭;花括號沒有閉合等。
- TypeError,類型錯誤。如把 number 當(dāng) str 使用。
- URIError,向全局 URI 處理函數(shù)傳遞一個不合法的 URI 時,就會拋出這個異常。如使用 decodeURI('%')、decodeURIComponent('%')。
- EvalError, 一個關(guān)于 eval 的異常,不會被 javascript 拋出。
具體詳見: Error - JavaScript - MDN Web Docs - Mozilla
通常,我們會通過 try...catch 語句塊來捕獲這一類型異常。如果不使用 try...catch,我們也可以通過 window.onerror = callback 或者 window.addEventListener('error', callback) 的方式進(jìn)行全局捕獲。
promise 類異常
在使用 promise 時,如果 promise 被 reject 但沒有做 catch 處理時,就會拋出 promise 類異常。
Promise.reject(); // Uncaught (in promise) undefined
promise 類型的異常無法被 try...catch 捕獲,也無法被 window.onerror = callback 或者 window.addEventListener('error', callback) 的方式全局捕獲。針對這一類型的異常, 我們需要通過 window.onrejectionhandled = callback 或者 window.addListener('rejectionhandled', callback) 的方式去全局捕獲。
靜態(tài)資源加載類型異常
如果我們頁面的img、js、css 等資源鏈接失效,就會提示資源類型加載如異常。
<img src="localhost:3000/data.png" /> // Get localhost:3000/data.png net::ERR_FILE_NOT_FOUND
針對這一類的異常,我們可以通過 window.addEventListener('error', callback, true) 的方式進(jìn)行全局捕獲。
這里要注意一點,使用 window.onerror = callback 的方式是無法捕獲靜態(tài)資源類異常的。
原因是資源類型錯誤沒有冒泡,只能在捕獲階段捕獲,而 window.onerror 是通過在冒泡階段捕獲錯誤,對靜態(tài)資源加載類型異常無效,所以只能借助 window.addEventListener('error', callback, true) 的方式捕獲。
接口請求類型異常
在瀏覽器端發(fā)起一個接口請求時,如果請求的 url 的有問題,也會拋出異常。
不同的請求方式,異常捕獲方式也不相同:
- 接口調(diào)用是通過 fetch 發(fā)起的
我們可以通過 fetch(url).then(callback).catch(callback) 的方式去捕獲異常。
- 接口調(diào)用通過 xhr 實例發(fā)起
如果是 xhr.open 方法執(zhí)行時出現(xiàn)異常,可以通過 window.addEventListener('error', callback) 或者 window.onerror 的方式捕獲異常。
xhr.open('GET', "https://") // Uncaught DOMException: Failed to execute 'open' on 'XMLHttpRequest': Invalid URL
at ....
如果是 xhr.send 方法執(zhí)行時出現(xiàn)異常,可以通過 xhr.onerror 或者 xhr.addEventListener('error', callback) 的方式捕獲異常。
xhr.open('get', '/user/userInfo');
xhr.send(); // send localhost:3000/user/userinfo net::ERR_FAILED
跨域腳本執(zhí)行異常
當(dāng)項目中引用的第三方腳本執(zhí)行發(fā)生錯誤時,會拋出一類特殊的異常。這類型異常和我們剛才講過的異常都不同,它的 msg 只有 'Script error' 信息,沒有具體的行、列、類型信息。
之以會這樣,是因為瀏覽器的安全機制: 瀏覽器只允許同域下的腳本捕獲具體異常信息,跨域腳本中的異常,不會報告錯誤的細(xì)節(jié)。
針對這類型的異常,我們可以通過 window.addEventListener('error', callback) 或者 window.onerror 的方式捕獲異常。
如果我們想獲取這類異常的詳情,需要做以下兩個操作:
- 在發(fā)起請求的 script 標(biāo)簽上添加 crossorigin="anonymous";
- 請求響應(yīng)頭中添加 Access-Control-Allow-Origin: *;
這樣就可以獲取到跨域異常的細(xì)節(jié)信息了。
Sentry 異常監(jiān)控原理
有效的異常監(jiān)控需要哪些必備要素
異常監(jiān)控的核心作用就是通過上報的異常,幫開發(fā)人員及時發(fā)現(xiàn)線上問題并快速修復(fù)。
要達(dá)到這個目的,異常監(jiān)控需要做到以下 3 點:
線上應(yīng)用出現(xiàn)異常時,可以及時推送給開發(fā)人員,安排相關(guān)人員去處理。
上報的異常,含有異常類型、發(fā)生異常的源文件及行列信息、異常的追蹤棧信息等詳細(xì)信息,可以幫助開發(fā)人員快速定位問題。
可以獲取發(fā)生異常的用戶行為,幫助開發(fā)人員、測試人員重現(xiàn)問題和測試回歸。
這三點,分別對應(yīng)異常自動推送、異常詳情獲取、用戶行為獲取。
異常詳情獲取
為了能自動捕獲應(yīng)用異常,Sentry 劫持覆寫了 window.onerror 和 window.unhandledrejection 這兩個 api。
劫持覆寫 window.onerror 的代碼如下:
oldErrorHandler = window.onerror;
window.onerror = function (msg, url, line, column, error) {
// 收集異常信息并上報
triggerHandlers('error', {
column: column,
error: error,
line: line,
msg: msg,
url: url,
});
if (oldErrorHandler) {
return oldErrorHandler.apply(this, arguments);
}
return false;
};
劫持覆寫 window.unhandledrejection 的代碼如下:
oldOnUnhandledRejectionHandler = window.onunhandledrejection;
window.onunhandledrejection = function (e) {
// 收集異常信息并上報
triggerHandlers('unhandledrejection', e);
if (oldOnUnhandledRejectionHandler) {
return oldOnUnhandledRejectionHandler.apply(this, arguments);
}
return true;
};
雖然通過劫持覆寫 window.onerror 和 window.unhandledrejection 已足以完成異常自動捕獲,但為了能獲取更詳盡的異常信息, Sentry 在內(nèi)部做了一些更細(xì)微的異常捕獲。
具體來說,就是 Sentry 內(nèi)部對異常發(fā)生的特殊上下文,做了標(biāo)記。這些特殊上下文包括: dom 節(jié)點事件回調(diào)、setTimeout / setInterval 回調(diào)、xhr 接口調(diào)用、requestAnimationFrame 回調(diào)等。
舉個 ,如果是 click 事件的 handler 中發(fā)生了異常, Sentry 會捕獲這個異常,并將異常發(fā)生時的事件 name、dom 節(jié)點描述、handler 函數(shù)名等信息上報。
具體處理邏輯如下:
- 標(biāo)記 setTimeout / setInterval / requestAnimationFrame
- 為了標(biāo)記 setTimeout / setInterval / requestAnimationFrame 類型的異常,Sentry 劫持覆寫了原生的 setTimout / setInterval / requestAnimationFrame 方法。新的 setTimeout / setInterval / requestAnimationFrame 方法調(diào)用時,會使用 try ... catch 語句塊包裹 callback。
具體實現(xiàn)如下:
var originSetTimeout = window.setTimeout;
window.setTimeout = function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var originalCallback = args[0];
// wrap$1 會對 setTimeout 的入?yún)?callback 使用 try...catch 進(jìn)行包裝
// 并在 catch 中上報異常
args[0] = wrap$1(originalCallback, {
mechanism: {
data: { function: getFunctionName(original) },
handled: true,
// 異常的上下文是 setTimeout
type: 'setTimeout',
},
});
return original.apply(this, args);
}
- 當(dāng) callback 內(nèi)部發(fā)生異常時,會被 catch 捕獲,捕獲的異常會標(biāo)記 setTimeout。
- 由于 setInterval、requestAnimationFrame 的劫持覆寫邏輯和 setTimeout 基本一樣,這里就不再重復(fù)說明了,感興趣的小伙伴們可自行實現(xiàn)。
- 標(biāo)記 dom 事件 handler
- 所有的 dom 節(jié)點都繼承自 window.Node 對象,dom 對象的 addEventListener 方法來自 Node 的 prototype 對象。
- 為了標(biāo)記 dom 事件 handler,Sentry 對 Node.prototype.addEventListener 進(jìn)行了劫持覆寫。新的 addEventListener 方法調(diào)用時,同樣會使用 try ... catch 語句塊包裹傳入的 handler。
- 相關(guān)代碼實現(xiàn)如下:
function xxx() {
var proto = window.Node.prototype;
...
// 覆寫 addEventListener 方法fill(proto, 'addEventListener', function (original) {
return function (eventName, fn, options) {
try {
if (typeof fn.handleEvent === 'function') {
// 使用 try...catch 包括 handle
fn.handleEvent = wrap$1(fn.handleEvent.bind(fn), {
mechanism: {
data: {
function: 'handleEvent',
handler: getFunctionName(fn),
target: target,
},
handled: true,
type: 'instrument',
},
});
}
}
catch (err) {}
return original.apply(this, [
eventName,
wrap$1(fn, {
mechanism: {
data: {
function: 'addEventListener',
handler: getFunctionName(fn),
target: target,
},
handled: true,
type: 'instrument',
},
}),
options,
]);
};
});
}
當(dāng) handler 內(nèi)部發(fā)生異常時,會被 catch 捕獲,捕獲的異常會被標(biāo)記 handleEvent, 并攜帶 event name、event target 等信息。
其實,除了標(biāo)記 dom 事件回調(diào)上下文,Sentry 還可以標(biāo)記 Notification、WebSocket、XMLHttpRequest 等對象的事件回調(diào)上下文??梢赃@么說,只要一個對象有 addEventListener 方法并且可以被劫持覆寫,那么對應(yīng)的回調(diào)上下文會可以被標(biāo)記。
標(biāo)記 xhr 接口回調(diào)
為了標(biāo)記 xhr 接口回調(diào),Sentry 先對 XMLHttpRequest.prototype.send 方法劫持覆寫, 等 xhr 實例使用覆寫以后的 send 方法時,再對 xhr 對象的 onload、onerror、onprogress、onreadystatechange 方法進(jìn)行了劫持覆寫, 使用 try ... catch 語句塊包裹傳入的 callback。
具體代碼如下:
fill(XMLHttpRequest.prototype, 'send', _wrapXHR);
function _wrapXHR(originalSend) {
return function () {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var xhr = this;
var xmlHttpRequestProps = ['onload', 'onerror', 'onprogress', 'onreadystatechange'];
// 劫持覆寫
xmlHttpRequestProps.forEach(function (prop) {
if (prop in xhr && typeof xhr[prop] === 'function') {
// 覆寫
fill(xhr, prop, function (original) {
var wrapOptions = {
mechanism: {
data: {
// 回調(diào)觸發(fā)的階段
function: prop,
handler: getFunctionName(original),
},
handled: true,
type: 'instrument',
},
};
var originalFunction = getOriginalFunction(original);
if (originalFunction) {
wrapOptions.mechanism.data.handler = getFunctionName(originalFunction);
}
return wrap$1(original, wrapOptions);
});
}
});
return originalSend.apply(this, args);
};
- 當(dāng) callback 內(nèi)部發(fā)生異常時,會被 catch 捕獲,捕獲的異常會被標(biāo)記對應(yīng)的請求階段。
有了這些回調(diào)上下文信息的幫助,定位異常就更加方便快捷了。
用戶行為獲取
常見的用戶行為,可以歸納為頁面跳轉(zhuǎn)、鼠標(biāo) click 行為、鍵盤 keypress 行為、 fetch / xhr 接口請求、console 打印信息。
Sentry 接入應(yīng)用以后,會在用戶使用應(yīng)用的過程中,將上述行為一一收集起來。等到捕獲到異常時,會將收集到的用戶行為和異常信息一起上報。
那 Sentry 是怎么實現(xiàn)收集用戶行為的呢?答案: 劫持覆寫上述操作涉及的 api。
具體實現(xiàn)過程如下:
收集頁面跳轉(zhuǎn)行為
為了可以收集用戶頁面跳轉(zhuǎn)行為,Sentry 劫持并覆寫了原生 history 的 pushState、replaceState 方法和 window 的 onpopstate。
劫持覆寫 onpopstate
// 使用 oldPopState 變量保存原生的 onpopstatevar oldPopState = window.onpopstate;
var lastHref;
// 覆寫 onpopstatewindow.onpopstate = function() {
...
var to = window.location.href;
var from = lastHref;
lastHref = to;
// 將頁面跳轉(zhuǎn)行為收集起來triggerHandlers('history', {
from: from,
to: to,
});
if (oldOnPopState) {
try {
// 使用原生的 popstate return oldOnPopState.apply(this, args);
} catch (e) {
...
}
}
...
}
復(fù)制代碼
劫持覆寫 pushState、replaceState
// 保存原生的 pushState 方法
var originPushState = window.history.pushState;
// 保存原生的 replaceState 方法
var originReplaceState = window.history.replaceState;
// 劫持覆寫 pushState
window.history.pushState = function() {
var args = [];
for (var i = 0; i < arguments.length; i++) {
args[i] = arguments[i];
}
var url = args.length > 2 ? args[2] : undefined;
if (url) {
var from = lastHref;
var to = String(url);lastHref = to;
// 將頁面跳轉(zhuǎn)行為收集起來
triggerHandlers('history', {
from: from,
to: to,
});
}
// 使用原生的 pushState 做頁面跳轉(zhuǎn)
return originPushState.apply(this, args);
}
// 劫持覆寫 replaceState
window.history.replaceState = function() {
var args = [];
for (var i = 0; i < arguments.length; i++) {
args[i] = arguments[i];
}
var url = args.length > 2 ? args[2] : undefined;
if (url) {
var from = lastHref;
var to = String(url);lastHref = to;
// 將頁面跳轉(zhuǎn)行為收集起來
triggerHandlers('history', {
from: from,
to: to,
});
}
// 使用原生的 replaceState 做頁面跳轉(zhuǎn)
return originReplaceState.apply(this, args);
}
收集鼠標(biāo) click / 鍵盤 keypress 行為
- 為了收集用戶鼠標(biāo) click 和鍵盤 keypress 行為, Sentry 做了雙保險操作:通過 document 代理 click、keypress 事件來收集 click、keypress 行為;通過劫持 addEventListener 方法來收集 click、keypress 行為;
相關(guān)代碼實現(xiàn)如下:
function instrumentDOM() {
...
// triggerDOMHandler 用來收集用戶 click / keypress 行為var triggerDOMHandler = triggerHandlers.bind(null, 'dom');
var globalDOMEventHandler = makeDOMEventHandler(triggerDOMHandler, true);
// 通過 document 代理 click、keypress 事件的方式收集 click、keypress 行為document.addEventListener('click', globalDOMEventHandler, false);
document.addEventListener('keypress', globalDOMEventHandler, false);
['EventTarget', 'Node'].forEach(function (target) {
var proto = window[target] && window[target].prototype;
if (!proto || !proto.hasOwnProperty || !proto.hasOwnProperty('addEventListener')) {
return;
}
// 劫持覆寫 Node.prototype.addEventListener 和 EventTarget.prototype.addEventListenerfill(proto, 'addEventListener', function (originalAddEventListener) {
// 返回新的 addEventListener 覆寫原生的 addEventListenerreturn function (type, listener, options) {
// click、keypress 事件,要做特殊處理,if (type === 'click' || type == 'keypress') {
try {
var el = this;
var handlers_1 = (el.__sentry_instrumentation_handlers__ = el.__sentry_instrumentation_handlers__ || {});
var handlerForType = (handlers_1[type] = handlers_1[type] || { refCount: 0 });
// 如果沒有收集過 click、keypress 行為if (!handlerForType.handler) {
var handler = makeDOMEventHandler(triggerDOMHandler);
handlerForType.handler = handler;
originalAddEventListener.call(this, type, handler, options);
}
handlerForType.refCount += 1;
}
catch (e) {
// Accessing dom properties is always fragile.// Also allows us to skip `addEventListenrs` calls with no proper `this` context.
}
}
// 使用原生的 addEventListener 方法注冊事件return originalAddEventListener.call(this, type, listener, options);
};
});
...
});
}
整個實現(xiàn)過程還是非常巧妙的,很值得拿來細(xì)細(xì)說明。
首先, Sentry 使用 document 代理了 click、keypress 事件。通過這種方式,用戶的 click、keypress 行為可以被感知,然后被 Sentry 收集。
但這種方式有一個問題,如果應(yīng)用的 dom 節(jié)點是通過 addEventListener 注冊了 click、keypress 事件,并且在事件回調(diào)中做了阻止事件冒泡的操作,那么就無法通過代理的方式監(jiān)控到 click、keypress 事件了。
針對這一種情況, Sentry 采用了覆寫Node.prototype.addEventListener 的方式來監(jiān)控用戶的 click、keypress 行為。
由于所有的 dom 節(jié)點都繼承自 Node 對象,Sentry 劫持覆寫了Node.prototype.addEventListener。當(dāng)應(yīng)用代碼通過 addEventListener 訂閱事件時,會使用覆寫以后的 addEventListener 方法。
新的 addEventListener 方法,內(nèi)部里面也有很巧妙的實現(xiàn)。如果不是 click、keypress 事件,會直接使用原生的 addEventListener 方法注冊應(yīng)用提供的 listener。但如果是 click、keypress 事件,除了使用原生的 addEventListener 方法注冊應(yīng)用提供的 listener 外,還使用原生 addEventListener 注冊了一個 handler,這個 handler 執(zhí)行的時候會將用戶 click、keypress 行為收集起來。
也就是說,如果是 click、keypress 事件,應(yīng)用程序在調(diào)用 addEventListener 的時候,實際上是調(diào)用了兩次原生的 addEventListener。
另外,在收集 click、keypress 行為時,Sentry 還會把 target 節(jié)點的的父節(jié)點信息收集起來,幫助我們快速定位節(jié)點位置
收集 fetch / xhr 接口請求行為
同理,為了收集應(yīng)用的接口請求行為,Sentry 對原生的 fetch 和 xhr 做了劫持覆寫。
劫持覆寫 fetch
var originFetch = window.fetch;
window.fetch = function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
// 獲取接口 url、method 類型、參數(shù)、接口調(diào)用時間信息var handlerData = {
args: args,
fetchData: {
method: getFetchMethod(args),
url: getFetchUrl(args),
},
startTimestamp: Date.now(),
};
// 收集接口調(diào)用信息triggerHandlers('fetch', __assign({}, handlerData));
return originalFetch.apply(window, args).then(function (response) {
// 接口請求成功,收集返回數(shù)據(jù)triggerHandlers('fetch', __assign(__assign({}, handlerData), { endTimestamp: Date.now(), response: response }));
return response;
}, function (error) {
// 接口請求失敗,收集接口異常數(shù)據(jù)triggerHandlers('fetch', __assign(__assign({}, handlerData), { endTimestamp: Date.now(), error: error }));
throw error;
});
}
復(fù)制代碼
應(yīng)用中使用 fetch 發(fā)起請求時,實際使用的是新的 fetch 方法。新的 fetch 內(nèi)部,會使用原生的 fetch 發(fā)起請求,并收集接口請求數(shù)據(jù)和返回結(jié)果。
劫持覆寫 xhr
function instrumentXHR() {
...
var xhrproto = XMLHttpRequest.prototype;
// 覆寫 XMLHttpRequest.prototype.openfill(xhrproto, 'open', function (originalOpen) {
return function () {
...
var onreadystatechangeHandler = function () {
if (xhr.readyState === 4) {
...
// 收集接口調(diào)用結(jié)果triggerHandlers('xhr', {
args: args,
endTimestamp: Date.now(),
startTimestamp: Date.now(),
xhr: xhr,
});
}
};
// 覆寫 onreadystatechangeif ('onreadystatechange' in xhr && typeof xhr.onreadystatechange === 'function') {
fill(xhr, 'onreadystatechange', function (original) {
return function () {
var readyStateArgs = [];
for (var _i = 0; _i < arguments.length; _i++) {
readyStateArgs[_i] = arguments[_i];
}
onreadystatechangeHandler();
return original.apply(xhr, readyStateArgs);
};
});
}
else {
xhr.addEventListener('readystatechange', onreadystatechangeHandler);
}
return originalOpen.apply(xhr, args);
};
});
// 覆寫 XMLHttpRequest.prototype.sendfill(xhrproto, 'send', function (originalSend) {
return function () {
...
// 收集接口調(diào)用行為triggerHandlers('xhr', {
args: args,
startTimestamp: Date.now(),
xhr: this,
});
return originalSend.apply(this, args);
};
});
}
復(fù)制代碼
Sentry 是通過劫持覆寫 XMLHttpRequest 原型上的 open、send 方法的方式來實現(xiàn)收集接口請求行為的。
當(dāng)應(yīng)用代碼中調(diào)用 open 方法時,實際使用的是覆寫以后的 open 方法。在新的 open 方法內(nèi)部,又覆寫了 onreadystatechange,這樣就可以收集到接口請求返回的結(jié)果。新的 open 方法內(nèi)部會使用調(diào)用原生的 open 方法。
同樣的,當(dāng)應(yīng)用代碼中調(diào)用 send 方法時,實際使用的是覆寫以后的 send 方法。新的 send 方法內(nèi)部先收集接口調(diào)用信息,然后調(diào)用原生的 send 方法。
收集 console 打印行為
有了前面的鋪墊,console 行為的收集機制理解起來就非常簡單了,實際就是對 console 的 debug、info、warn、error、log、assert 這借個 api 進(jìn)行劫持覆寫。
代碼如下:
var originConsoleLog = console.log;console.log = function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
// 收集 console.log 行為
triggerHandlers('console', { args: args, level: 'log' });
if (originConsoleLog) {
originConsoleLog.apply(console, args);
}
}
文章出自:??前端餐廳ReTech??,如有轉(zhuǎn)載本文請聯(lián)系前端餐廳ReTech今日頭條號。
github:https://github.com/zuopf769
