大家好,我是若川。持續組織了近一年的源碼共讀活動,感興趣的可以?點此加我微信ruochuan12?參與,每周大家一起學習200行左右的源碼,共同進步。同時極力推薦訂閱我寫的《學習源碼整體架構系列》?包含20余篇源碼文章。歷史面試系列。另外:目前建有江西|湖南|湖北
籍前端群,可加我微信進群。
前兩篇,我們介紹了為什么前端應該有監控系統,以及搭建前端監控的總體步驟,前端監控的 Why 和 What 想必你已經明白了。接下來我們解決 How 如何實現的問題。
如果不了解前端監控,建議先看前兩篇:
為什么前端不能沒有監控系統?
前端監控的總體搭建步驟
本篇我們介紹,前端如何采集數據,先從收集異常數據開始。
什么是異常數據?
異常數據,是指前端在操作頁面的過程中,觸發的執行異常或加載異常,此時瀏覽器會拋出來報錯信息。
比如說你的前端代碼用了個未聲明的變量,此時控制臺會打印出紅色錯誤,告訴你報錯原因。或者是接口請求出錯了,在網絡面板內也能查到異常情況,是請求發送的異常,還是接口響應的異常。
在我們實際的開發場景中,前端捕獲的異常主要是分兩個大類,接口異常
和 前端異常
,我們分別看下這兩大類異常怎么捕獲。
接口異常
接口異常一定是在請求的時候觸發。前端目前大部分的請求是用 axios
發起的,所以只要獲取 axios 可能發生的異常即可。
如果你用 Promise 的寫法,則用 .catch
捕獲:
axios.post('/test').then((res)?=>?{console.log(res);}).catch((err)?=>?{//?err?就是捕獲到的錯誤對象handleError(err);});
如果你用 async/await 的寫法,則用 try..catch..
捕獲:
async?()?=>?{try?{let?res?=?await?axios.post('/test');console.log(res);}?catch?(err)?{//?err?就是捕獲到的錯誤對象handleError(err);}
};
當捕獲到異常之后,統一交給 handleError
函數處理,這個函數會將接收到的異常進行處理,并調用 上報接口
將異常數據傳到服務器,從而完成采集。
上面我們寫的異常捕獲,邏輯上是沒問題的,實操起來就會發現第一道坎:頁面這么多,難道每個請求都要包一層 catch 嗎?
是啊,如果我們是新開發一個項目,在開始的時候就規定每個請求要包一層 catch 也無可厚非,但是如果是在一個已有的規模還不小的項目中接入前端監控,這時候在每個頁面或每個請求 catch 顯然是不現實的。
所以,為了最大程度地降低接入成本,減少侵入性,我們是用第二種方案:在 axios 攔截器中捕獲異常。
前端項目,為了統一處理請求,比如 401 的跳轉,或者全局錯誤提示,都會在全局寫一個 axios 實例,為這個實例添加攔截器,然后在其他頁面中直接導入這個實例使用,比如:
//?全局請求:src/request/axios.jsconst?instance?=?axios.create({baseURL:?'https://api.test.com'timeout:?15000,headers:?{'Content-Type':?'application/json',},
})export?default?instance
然后在具體的頁面中這樣發起請求:
// a 頁面:src/page/a.jsx
import?http?from?'@/src/request/axios.js';async?()?=>?{let?res?=?await?http.post('/test');console.log(res);
};
這樣的話,我們發現每個頁面的請求都會走全局 axios 實例,所以我們只需要在全局請求的位置捕獲異常即可,就不需要在每個頁面捕獲了,這樣接入成本會大大降低。
按照這個方案,接下來我們在 src/request/axios.js
這個文件中動手實施。
攔截器中捕獲異常
首先我們為 axios 添加響應攔截器:
//?響應攔截器
instance.interceptors.response.use((response)?=>?{return?response.data;},(error)?=>?{//?發生異常會走到這里if?(error.response)?{let?response?=?error.response;if?(response.status?>=?400)?{handleError(response);}}?else?{handleError(null);}return?Promise.reject(error);},
);
響應攔截器的第二個參數是在發生錯誤時執行的函數,參數就是異常。我們首先要判斷是否存在 error.response
,存在就說明接口有響應,也就是接口通了,但是返回錯誤;不存在則說明接口沒通,請求一直掛起,多數是接口崩潰了。
如果有響應,首先獲取狀態碼,根據狀態碼來判斷什么時候需要收集異常。上面的判斷方式簡單粗暴,只要狀態碼大于 400 就視為一個異常,拿到響應數據,并執行上報邏輯。
如果沒有響應,可以看作是接口超時異常,調用異常處理函數時傳一個 null
即可。
前端異常
上面我們介紹了在 axios 攔截器中如何捕獲接口異常,這部分我們再介紹如何捕獲前端異常。
前端代碼捕獲異常,最常用的方式就是用 try..catch.. 了,任意同步代碼塊都可以放到 try
塊中,只要發生異常就會執行 catch:
try?{//?任意同步代碼
}?catch?(err)?{console.log(err);
}
上面說“任意同步代碼”而不是“任意代碼”,主要是普通的 Promise 寫法 try..catch.. 是捕獲不到的,只能用 .catch()
捕獲,如:
try?{Promise.reject(new?Error('出錯了')).catch((err)?=>?console.log('1:',?err));
}?catch?(err)?{console.log('2:',?err);
}
把這段代碼丟進瀏覽器,打印結果是:
1:?Error:?出錯了
很明顯只是 .catch 捕獲到了異常。不過與上面接口異常的邏輯一樣,這種方式處理當前頁面異常沒什么問題,但從整個應用來看,這樣捕獲異常侵入性強,接入成本高,所以我們的思路依然是全局捕獲。
全局捕獲 js 的異常也比較簡單,用 window.addEventLinstener('error')
即可:
//?js?錯誤捕獲
window.addEventListener('error',?(error)?=>?{//?error?就是js的異常
});
為啥不用 window.onerror ?
這里很多小伙伴有疑問,為什么不用 window.onerror
全局監聽呢?window.addEventLinstener('error')
和 window.onerror
有什么區別呢?
首先這兩個函數功能基本一致,都可以全局捕獲 js 異常。但是有一類異常叫做 資源加載異常
,就是在代碼中引用了不存在的圖片,js,css 等靜態資源導致的異常,比如:
const?loadCss?=?()=>?{let?link?=?document.createElement('link')link.type?=?'text/css'link.rel?=?'stylesheet'link.href?=?'https://baidu.com/15.css'document.getElementsByTagName('head')[10].append(link)
}
render()?{return?<div><img?src='./bbb.png'/><button?onClick={loadCss}>加載樣式<button/></div>
}
上述代碼中的 baidu.com/15.css
和 bbb.png
是不存在的,JS 執行到這里肯定會報一個資源找不到的錯誤。但是默認情況下,上面兩種 window 對象上的全局監聽函數都監聽不到這類異常。
因為資源加載的異常只會在當前元素觸發,異常不會冒泡到 window,因此監聽 window 上的異常是捕捉不到的。那怎么辦呢?
如果你熟悉 DOM 事件你就會明白,既然冒泡階段監聽不到,那么在捕獲階段一定能監聽到。
方法就是給 window.addEventListene
函數指定第三個參數,很簡單就是 true
,表示該監聽函數會在捕獲階段執行,這樣就能監聽到資源加載異常了。
//?捕獲階段全局監聽
window.addEventListene('error',(error)?=>?{if?(error.target?!=?window)?{console.log(error.target.tagName,?error.target.src);}handleError(error);},true,
);
上述方式可以很輕松的監聽到圖片加載異常,這就是為什么更推薦 window.addEventListene
的原因。不過要記得,第三個參數設為 true
,監聽事件捕獲,就可以全局捕獲到 JS 異常和資源加載異常。
需要特別注意,window.addEventListene
同樣不能捕獲 Promise 異常。不管是 Promise.then()
寫法還是 async/await
寫法,發生異常時都不能捕獲。
因此,我們還需要全局監聽一個 unhandledrejection
函數來捕獲未處理的 Promise 異常。
//?promise?錯誤捕獲
window.addEventListener('unhandledrejection',?(error)?=>?{//?打印異常原因console.log(error.reason);handleError(error);//?阻止控制臺打印error.preventDefault();
});
unhandledrejection
事件會在 Promise 發生異常并且沒有指定 catch
的時候觸發,相當于一個全局的 Promise 異常兜底方案。這個函數會捕捉到運行時意外發生的 Promise 異常,這對我們排錯非常有用。
默認情況下,Promise 發生異常且未被 catch 時,會在控制臺打印異常。如果我們想阻止異常打印,可以用上面的 error.preventDefault()
方法。
異常處理函數
前面我們在捕獲到異常時調用了一個異常處理函數 handleError
,所有的異常和上報邏輯統一在這個函數內處理,接下來我們實現這個函數。
const?handleError?=?(error:?any,?type:?1?|?2)?{if(type?==?1)?{//?處理接口異常}if(type?==?2)?{//?處理前端異常}
}
為了區分異常類型,函數新加了第二個參數 type 表示當前異常屬于前端還是接口。在不同的場景中使用如下:
處理前端異常:
handleError(error, 1)
處理接口異常:
handleError(error, 2)
處理接口異常
處理接口異常,我們需要將拿到的 error 參數解析,然后取到需要的數據。接口異常一般需要的數據字段如下:
code
:http 狀態碼url
:接口請求地址method
:接口請求方法params
:接口請求參數error
:接口報錯信息
這些字段都可以在 error 參數中獲取,方法如下:
const?handleError?=?(error:?any,?type:?1?|?2)?{if(type?==?1)?{//?此時的?error?響應,它的?config?字段中包含請求信息let?{?url,?method,?params,?data?}?=?error.configlet?err_data?=?{url,?method,params:?{?query:?params,?body:?data?},error:?error.data?.message?||?JSON.stringify(error.data),})}
}
config 對象中的 params
表示 GET 請求的 query 參數,data
表示 POST 請求的 body 參數,所以我在處理參數的時候,將這兩個參數合并為一個,用一個屬性 params 來表示。
params:?{?query:?params,?body:?data?}
還有一個 error
屬性表示錯誤信息,這個獲取方式要根據你的接口返回格式來拿。要避免獲取到接口可能返回的超長錯誤信息,多半是接口沒處理,這樣可能會導致寫入數據失敗,要提前與后臺規定好。
處理前端異常
前端異常異常大多數就是 js 異常,異常對應到 js 的 Error
對象,在處理之前,我們先看 Error 有哪幾種類型:
ReferenceError
:引用錯誤RangeError
:超出有效范圍TypeError
:類型錯誤URIError
:URI 解析錯誤
這幾類異常的引用對象都是 Error
,因此可以這樣獲取:
const?handleError?=?(error:?any,?type:?1?|?2)?{if(type?==?2)?{let?err_data?=?null//?監測?error?是否是標準類型if(error?instanceof?Error)?{let?{?name,?message?}?=?errorerr_data?=?{type:?name,error:?message}}?else?{err_data?=?{type:?'other',error:?JSON.strigify(error)}}}
}
上述判斷中,首先判斷異常是否是 Error
的實例。事實上絕大部分的代碼異常都是標準的 JS Error,但我們這里還是判斷一下,如果是的話直接獲取異常類型和異常信息,不是的話將異常類型設置為 other
即可。
我們隨便寫一個異常代碼,看一下捕獲的結果:
function?test()?{console.aaa('ccc');
}
test();
然后捕獲到的異常是這樣的:
const?handleError?=?(error:?any)?=>?{if?(error?instanceof?Error)?{let?{?name,?message?}?=?error;console.log(name,?message);//?打印結果:TypeError console.aaa is not a function}
};
獲取環境數據
獲取環境數據的意思是,不管是接口異常還是前端異常,除了異常本身的數據之外,我們還需要一些其他信息來幫助我們更快更準的定位到哪里出錯了。
這類數據我們稱之為 “環境數據”,就是觸發異常時所在的環境。比如是誰在哪個頁面的哪個地方觸發的錯誤,有了這些,我們就能馬上找到錯誤來源,再根據異常信息解決錯誤。
環境數據至少包括下面這些:
app
:應用的名稱/標識env
:應用環境,一般是開發,測試,生產version
:應用的版本號user_id
:觸發異常的用戶 IDuser_name
:觸發異常的用戶名page_route
:異常的頁面路由page_title
:異常的頁面名稱
app
和 version
都是應用配置,可以判斷異常出現在哪個應用的哪個版本。這兩個字段我建議直接獲取 package.json
下的 name
和 version
屬性,在應用升級的時候,及時修改 version 版本號即可。
其余的字段,需要根據框架的配置獲取,下面我分別介紹在 Vue 和 React 中如何獲取。
在 Vue 中
在 Vue 中獲取用戶信息一般都是直接從 Vuex 里面拿,如果你的用戶信息沒有存到 Vuex 里,從 localStorage 里獲取也是一樣的。
如果在 Vuex 里,可以這樣實現:
import?store?from?'@/store';?//?vuex?導出目錄
let?user_info?=?store.state;
let?user_id?=?user_info.id;
let?user_name?=?user_info.name;
用戶信息存在狀態管理中,頁面路由信息一般是在 vue-router
中定義。前端的路由地址可以直接從 vue-router 中獲取,頁面名稱可以配置在 meta
中,如:
{path:?'/test',name:?'test',meta:?{title:?'測試頁面'},component:?()?=>?import('@/views/test/Index.vue')
},
這樣配置之后,獲取當前頁面路由和頁面名稱就簡單了:
window.vm?=?new?Vue({...})let?route?=?vm.$route
let?page_route?=?route.path
let?page_title?=?route.meta.title
最后一步,我們再獲取當前環境。當前環境用一個環境變量 VUE_APP_ENV
表示,有三個值:
dev
:開發環境test
:測試環境pro
:生產環境
然后在根目錄下新建三個環境文件,寫入環境變量:
.env.development
:VUE_APP_ENV=dev.env.staging
:VUE_APP_ENV=test.env.production
:VUE_APP_ENV=pro
現在獲取 env
環境時就可以這么獲取:
{env:?process.env.VUE_APP_ENV;
}
最后一步,執行打包時,傳入模式以匹配對應的環境文件:
#?測試環境打包
$?num?run?build?--mode?staging
#?生產環境打包
$?num?run?build?--mode?production
獲取到環境數據,再拼上異常數據,我們就準備好了數據等待上報了。
在 React 中
和 Vue 一樣,用戶信息可以直接從狀態管理里拿。因為 React 中沒有全局獲取當前旅游的快捷方式,所以頁面信息我也會放在狀態管理里面。我用的狀態管理是 Mobx,獲取方式如下:
import?{?TestStore?}?from?'@/stores';?//?mobx?導出目錄
let?{?user_info,?cur_path,?cur_page_title?}?=?TestStore;
//?用戶信息:user_info
//?頁面信息:cur_path,cur_page_title
這樣的話,就需要在每次切換頁面時,更新 mobx 里的路由信息,怎么做呢?
其實在根路由頁(一般是首頁)的 useEffect
中監聽即可:
import?{?useLocation?}?from?'react-router';
import?{?observer,?useLocalObservable?}?from?'mobx-react';
import?{?TestStore?}?from?'@/stores';export?default?observer(()?=>?{const?{?pathname,?search?}?=?useLocation();const?test_inst?=?useLocalObservable(()?=>?TestStore);useEffect(()?=>?{test_inst.setCurPath(pathname,?search);},?[pathname]);
});
獲取到用戶信息和頁面信息,接下來就是當前環境了。和 Vue 一樣通過 --mode
來指定模式,并加載相應的環境變量,只不過設置方法略有不同。大多數的 React 項目可能都是用 create-react-app
創建的,我們以此為例介紹怎么修改。
首先,打開 scripts/start.js
文件,這是執行 npm run start 時執行的文件,我們在開頭部分第 6 行加代碼:
process.env.REACT_APP_ENV?=?'dev';
沒錯,我們指定的環境變量就是 REACT_APP_ENV
,因為只有 REACT_
開頭的環境變量可被讀取。
然后再修改 scripts/build.js
文件的第 48 行,修改后如下:
if?(argv.length?>=?2?&&?argv[0]?==?'--mode')?{switch?(argv[1])?{case?'staging':process.env.REACT_APP_ENV?=?'test';break;case?'production':process.env.REACT_APP_ENV?=?'pro';break;default:}
}
此時獲取 env
環境時就可以這么獲取:
{env:?process.env.REACT_APP_ENV;
}
總結
經過前面一系列操作,我們已經比較全面的獲取到了異常數據,以及發生異常時到環境數據,接下來就是調用上報接口,將這些數據傳給后臺存起來,我們以后查找和追蹤就很方便了。
如果你也需要前端監控,不妨花上半個小時,按照文中介紹的方法收集一下異常數據,相信對你很有幫助。
·················?若川簡介?·················
你好,我是若川,畢業于江西高校。現在是一名前端開發“工程師”。寫有《學習源碼整體架構系列》20余篇,在知乎、掘金收獲超百萬閱讀。
從2014年起,每年都會寫一篇年度總結,已經堅持寫了8年,點擊查看年度總結。
同時,最近組織了源碼共讀活動,幫助4000+前端人學會看源碼。公眾號愿景:幫助5年內前端人走向前列。
掃碼加我微信 ruochuan12、拉你進源碼共讀群
今日話題
目前建有江西|湖南|湖北?籍 前端群,想進群的可以加我微信 ruochuan12?進群。分享、收藏、點贊、在看我的文章就是對我最大的支持