掃二維碼與項目經(jīng)理溝通
我們在微信上24小時期待你的聲音
解答本文疑問/技術咨詢/運營咨詢/技術建議/互聯(lián)網(wǎng)交流
前端異常通??梢苑譃橐韵聨追N類型:

js 代碼執(zhí)行異常,是我們經(jīng)常遇到異常。這一類型的異常,又可以具體細分為:
具體詳見: Error - JavaScript - MDN Web Docs - Mozilla
通常,我們會通過 try...catch 語句塊來捕獲這一類型異常。如果不使用 try...catch,我們也可以通過 window.onerror = callback 或者 window.addEventListener('error', callback) 的方式進行全局捕獲。
在使用 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) 的方式去全局捕獲。
如果我們頁面的img、js、css 等資源鏈接失效,就會提示資源類型加載如異常。
// Get localhost:3000/data.png net::ERR_FILE_NOT_FOUND
針對這一類的異常,我們可以通過 window.addEventListener('error', callback, true) 的方式進行全局捕獲。
這里要注意一點,使用 window.onerror = callback 的方式是無法捕獲靜態(tài)資源類異常的。
原因是資源類型錯誤沒有冒泡,只能在捕獲階段捕獲,而 window.onerror 是通過在冒泡階段捕獲錯誤,對靜態(tài)資源加載類型異常無效,所以只能借助 window.addEventListener('error', callback, true) 的方式捕獲。
在瀏覽器端發(fā)起一個接口請求時,如果請求的 url 的有問題,也會拋出異常。
不同的請求方式,異常捕獲方式也不相同:
我們可以通過 fetch(url).then(callback).catch(callback) 的方式去捕獲異常。
如果是 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í)行發(fā)生錯誤時,會拋出一類特殊的異常。這類型異常和我們剛才講過的異常都不同,它的 msg 只有 'Script error' 信息,沒有具體的行、列、類型信息。
之以會這樣,是因為瀏覽器的安全機制: 瀏覽器只允許同域下的腳本捕獲具體異常信息,跨域腳本中的異常,不會報告錯誤的細節(jié)。
針對這類型的異常,我們可以通過 window.addEventListener('error', callback) 或者 window.onerror 的方式捕獲異常。
如果我們想獲取這類異常的詳情,需要做以下兩個操作:
這樣就可以獲取到跨域異常的細節(jié)信息了。
異常監(jiān)控的核心作用就是通過上報的異常,幫開發(fā)人員及時發(fā)現(xiàn)線上問題并快速修復。
要達到這個目的,異常監(jiān)控需要做到以下 3 點:
線上應用出現(xiàn)異常時,可以及時推送給開發(fā)人員,安排相關人員去處理。
上報的異常,含有異常類型、發(fā)生異常的源文件及行列信息、異常的追蹤棧信息等詳細信息,可以幫助開發(fā)人員快速定位問題。
可以獲取發(fā)生異常的用戶行為,幫助開發(fā)人員、測試人員重現(xiàn)問題和測試回歸。
這三點,分別對應異常自動推送、異常詳情獲取、用戶行為獲取。
為了能自動捕獲應用異常,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)部做了一些更細微的異常捕獲。
具體來說,就是 Sentry 內(nèi)部對異常發(fā)生的特殊上下文,做了標記。這些特殊上下文包括: dom 節(jié)點事件回調(diào)、setTimeout / setInterval 回調(diào)、xhr 接口調(diào)用、requestAnimationFrame 回調(diào)等。
舉個 ,如果是 click 事件的 handler 中發(fā)生了異常, Sentry 會捕獲這個異常,并將異常發(fā)生時的事件 name、dom 節(jié)點描述、handler 函數(shù)名等信息上報。
具體處理邏輯如下:
具體實現(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 進行包裝
// 并在 catch 中上報異常
args[0] = wrap$1(originalCallback, {
mechanism: {
data: { function: getFunctionName(original) },
handled: true,
// 異常的上下文是 setTimeout
type: 'setTimeout',
},
});
return original.apply(this, args);
}
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,
]);
};
});
}當 handler 內(nèi)部發(fā)生異常時,會被 catch 捕獲,捕獲的異常會被標記 handleEvent, 并攜帶 event name、event target 等信息。
其實,除了標記 dom 事件回調(diào)上下文,Sentry 還可以標記 Notification、WebSocket、XMLHttpRequest 等對象的事件回調(diào)上下文??梢赃@么說,只要一個對象有 addEventListener 方法并且可以被劫持覆寫,那么對應的回調(diào)上下文會可以被標記。
標記 xhr 接口回調(diào)
為了標記 xhr 接口回調(diào),Sentry 先對 XMLHttpRequest.prototype.send 方法劫持覆寫, 等 xhr 實例使用覆寫以后的 send 方法時,再對 xhr 對象的 onload、onerror、onprogress、onreadystatechange 方法進行了劫持覆寫, 使用 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);
};
有了這些回調(diào)上下文信息的幫助,定位異常就更加方便快捷了。
常見的用戶行為,可以歸納為頁面跳轉(zhuǎn)、鼠標 click 行為、鍵盤 keypress 行為、 fetch / xhr 接口請求、console 打印信息。
Sentry 接入應用以后,會在用戶使用應用的過程中,將上述行為一一收集起來。等到捕獲到異常時,會將收集到的用戶行為和異常信息一起上報。
那 Sentry 是怎么實現(xiàn)收集用戶行為的呢?答案: 劫持覆寫上述操作涉及的 api。
具體實現(xiàn)過程如下:
為了可以收集用戶頁面跳轉(zhuǎn)行為,Sentry 劫持并覆寫了原生 history 的 pushState、replaceState 方法和 window 的 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) {
...
}
}
...
}
復制代碼
劫持覆寫 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);
}
相關代碼實現(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)過程還是非常巧妙的,很值得拿來細細說明。
首先, Sentry 使用 document 代理了 click、keypress 事件。通過這種方式,用戶的 click、keypress 行為可以被感知,然后被 Sentry 收集。
但這種方式有一個問題,如果應用的 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。當應用代碼通過 addEventListener 訂閱事件時,會使用覆寫以后的 addEventListener 方法。
新的 addEventListener 方法,內(nèi)部里面也有很巧妙的實現(xiàn)。如果不是 click、keypress 事件,會直接使用原生的 addEventListener 方法注冊應用提供的 listener。但如果是 click、keypress 事件,除了使用原生的 addEventListener 方法注冊應用提供的 listener 外,還使用原生 addEventListener 注冊了一個 handler,這個 handler 執(zhí)行的時候會將用戶 click、keypress 行為收集起來。
也就是說,如果是 click、keypress 事件,應用程序在調(diào)用 addEventListener 的時候,實際上是調(diào)用了兩次原生的 addEventListener。
另外,在收集 click、keypress 行為時,Sentry 還會把 target 節(jié)點的的父節(jié)點信息收集起來,幫助我們快速定位節(jié)點位置
收集 fetch / xhr 接口請求行為
同理,為了收集應用的接口請求行為,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;
});
}
復制代碼
應用中使用 fetch 發(fā)起請求時,實際使用的是新的 fetch 方法。新的 fetch 內(nèi)部,會使用原生的 fetch 發(fā)起請求,并收集接口請求數(shù)據(jù)和返回結(jié)果。
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);
};
});
}
復制代碼Sentry 是通過劫持覆寫 XMLHttpRequest 原型上的 open、send 方法的方式來實現(xiàn)收集接口請求行為的。
當應用代碼中調(diào)用 open 方法時,實際使用的是覆寫以后的 open 方法。在新的 open 方法內(nèi)部,又覆寫了 onreadystatechange,這樣就可以收集到接口請求返回的結(jié)果。新的 open 方法內(nèi)部會使用調(diào)用原生的 open 方法。
同樣的,當應用代碼中調(diào)用 send 方法時,實際使用的是覆寫以后的 send 方法。新的 send 方法內(nèi)部先收集接口調(diào)用信息,然后調(diào)用原生的 send 方法。
有了前面的鋪墊,console 行為的收集機制理解起來就非常簡單了,實際就是對 console 的 debug、info、warn、error、log、assert 這借個 api 進行劫持覆寫。
代碼如下:
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

我們在微信上24小時期待你的聲音
解答本文疑問/技術咨詢/運營咨詢/技術建議/互聯(lián)網(wǎng)交流