首先先看看如何捕獲異常。javascript
js異常的特色是,出現不會致使JS引擎崩潰 最多隻會終止當前執行的任務。好比一個頁面有兩個按鈕,若是點擊按鈕發生異常頁面,這個時候頁面不會崩潰,只是這個按鈕的功能失效,其餘按鈕還會有效。css
setTimeout(() => {
console.log('1->begin')
error
console.log('1->end')
})
setTimeout(() => {
console.log('2->begin')
console.log('2->end')
})
複製代碼
其實若是你不打開控制檯都看不到發生了錯誤。好像是錯誤是在靜默中發生的。html
下面咱們來看看這樣的錯誤該如何收集。前端
JS做爲一門高級語言咱們首先想到的使用try-catch來收集。vue
setTimeout(() => {
try {
console.log('1->begin')
error
console.log('1->end')
} catch (e) {
console.log('catch',e)
}
})
複製代碼
function fun1() {
console.log('1->begin')
error
console.log('1->end')
}
setTimeout(() => {
try {
fun1()
} catch (e) {
console.log('catch',e)
}
})
複製代碼
讀到這裏你們可能會想那就在最底層作一個錯誤try-catch不就行了嗎。確實做爲一個從java轉過來的程序員也是這麼想的。可是理想很豐滿,現實很骨感。咱們看看下一個例子。java
function fun1() {
console.log('1->begin')
error
console.log('1->end')
}
try {
setTimeout(() => {
fun1()
})
} catch (e) {
console.log('catch', e)
}
複製代碼
你們注意運行結果,異常並無被捕獲。node
這是由於JS的try-catch功能很是有限一遇到異步就很差用了。那總不能爲了收集錯誤給全部的異步都加一個try-catch吧,太坑爹了。其實你想一想異步任務其實也不是由代碼形式上的上層調用的就好比本例中的settimeout。你們想一想eventloop就明白啦,其實這些一步函數都是就比如一羣沒孃的孩子出了錯誤找不到家大人。固然我也想過一些黑魔法來處理這個問題好比代理執行或者用過的異步方法。算了仍是仍是再看看吧。react
window.onerror 最大的好處就是能夠同步任務仍是異步任務均可捕獲。webpack
function fun1() {
console.log('1->begin')
error
console.log('1->end')
}
window.onerror = (...args) => {
console.log('onerror:',args)
}
setTimeout(() => {
fun1()
})
複製代碼
onerror返回值git
onerror還有一個問題你們要注意 若是返回返回true 就不會被上拋了。否則控制檯中還會看到錯誤日誌。
window.addEventListener('error',() => {})
其實onerror當然好可是仍是有一類異常沒法捕獲。這就是網絡異常的錯誤。好比下面的例子。
<img src="./xxxxx.png">
複製代碼
試想一下咱們若是頁面上要顯示的圖片忽然不顯示了,而咱們渾然不知那就是麻煩了。
addEventListener就是
window.addEventListener('error', args => {
console.log(
'error event:', args
);
return true;
},
true // 利用捕獲方式
);
複製代碼
運行結果以下:
Promise的出現主要是爲了讓咱們解決回調地域問題。基本是咱們程序開發的標配了。雖然咱們提倡使用es7 async/await語法來寫,可是不排除不少祖傳代碼仍是存在Promise寫法。
new Promise((resolve, reject) => {
abcxxx()
});
複製代碼
這種狀況不管是onerror仍是監聽錯誤事件都是沒法捕獲的
new Promise((resolve, reject) => {
error()
})
// 增長異常捕獲
.catch((err) => {
console.log('promise catch:',err)
});
複製代碼
除非每一個Promise都添加一個catch方法。可是顯然是不能這樣作。
window.addEventListener("unhandledrejection", e => {
console.log('unhandledrejection',e)
});
複製代碼
咱們能夠考慮將unhandledrejection事件捕獲錯誤拋出交由錯誤事件統一處理就能夠了
window.addEventListener("unhandledrejection", e => {
throw e.reason
});
複製代碼
const asyncFunc = () => new Promise(resolve => {
error
})
setTimeout(async() => {
try {
await asyncFun()
} catch (e) {
console.log('catch:',e)
}
})
複製代碼
實際上async/await語法本質仍是Promise語法。區別就是async方法能夠被上層的try/catch捕獲。
異常類型 | 同步方法 | 異步方法 | 資源加載 | Promise | async/await |
---|---|---|---|---|---|
try/catch | ✔️ | ✔️ | |||
onerror | ✔️ | ✔️ | |||
error事件監聽 | ✔️ | ✔️ | ✔️ | ||
unhandledrejection事件監聽 | ✔️ | ✔️ |
實際上咱們能夠將unhandledrejection事件拋出的異常再次拋出就能夠統一經過error事件進行處理了。
最終用代碼表示以下:
window.addEventListener("unhandledrejection", e => {
throw e.reason
});
window.addEventListener('error', args => {
console.log(
'error event:', args
);
return true;
}, true);
複製代碼
如今是前端工程化的時代,工程化導出的代碼通常都是被壓縮混淆後的。
好比:
setTimeout(() => {
xxx(1223)
}, 1000)
複製代碼
出錯的代碼指向被壓縮後的JS文件,而JS文件長下圖這個樣子。
簡單說,sourceMap
就是一個文件,裏面儲存着位置信息。
仔細點說,這個文件裏保存的,是轉換後代碼的位置,和對應的轉換前的位置。
那麼如何利用sourceMap對還原異常代碼發生的位置這個問題咱們到異常分析這個章節再講。
利用vue-cli工具直接建立一個項目。
# 安裝vue-cli
npm install -g @vue/cli
# 建立一個項目
vue create vue-sample
cd vue-sample
npm i
// 啓動應用
npm run serve
複製代碼
爲了測試的須要咱們暫時關閉eslint 這裏面仍是建議你們全程打開eslint
在vue.config.js進行配置
module.exports = {
// 關閉eslint規則
devServer: {
overlay: {
warnings: true,
errors: true
}
},
lintOnSave:false
}
複製代碼
咱們故意在src/components/HelloWorld.vue
<script> export default { name: "HelloWorld", props: { msg: String }, mounted() { // 製造一個錯誤 abc() } }; </script>
```html
而後在src/main.js中添加錯誤事件監聽
```js
window.addEventListener('error', args => {
console.log('error', error)
})
複製代碼
這個時候 錯誤會在控制檯中被打印出來,可是錯誤事件並無監聽到。
爲了對Vue發生的異常進行統一的上報,須要利用vue提供的handleError句柄。一旦Vue發生異常都會調用這個方法。
咱們在src/main.js
Vue.config.errorHandler = function (err, vm, info) {
console.log('errorHandle:', err)
}
複製代碼
運行結果結果:
npx create-react-app react-sample
cd react-sample
yarn start
複製代碼
咱們l用useEffect hooks 製造一個錯誤
import React ,{useEffect} from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
useEffect(() => {
// 發生異常
error()
});
return (
<div className="App"> // ...略... </div>
);
}
export default App;
複製代碼
而且在src/index.js中增長錯誤事件監聽邏輯
window.addEventListener('error', args => {
console.log('error', error)
})
複製代碼
可是從運行結果看雖然輸出了錯誤日誌可是仍是服務捕獲。
錯誤邊界僅能夠捕獲其子組件的錯誤。錯誤邊界沒法捕獲其自身的錯誤。若是一個錯誤邊界沒法渲染錯誤信息,則錯誤會向上冒泡至最接近的錯誤邊界。這也相似於 JavaScript 中 catch {} 的工做機制。
建立ErrorBoundary組件
import React from 'react';
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
}
componentDidCatch(error, info) {
// 發生異常時打印錯誤
console.log('componentDidCatch',error)
}
render() {
return this.props.children;
}
}
複製代碼
在src/index.js中包裹App標籤
import ErrorBoundary from './ErrorBoundary'
ReactDOM.render(
<ErrorBoundary> <App /> </ErrorBoundary>
, document.getElementById('root'));
複製代碼
最終運行的結果
其實上報就是要將捕獲的異常信息發送到後端。最經常使用的方式首推進態建立標籤方式。由於這種方式無需加載任何通信庫,並且頁面是無需刷新的。基本上目前包括百度統計 Google統計都是基於這個原理作的埋點。
new Image().src = 'http://localhost:7001/monitor/error'+ '?info=xxxxxx'
複製代碼
經過動態建立一個img,瀏覽器就會向服務器發送get請求。能夠把你須要上報的錯誤數據放在querystring字符串中,利用這種方式就能夠將錯誤上報到服務器了。
實際上咱們也能夠用ajax的方式上報錯誤,這和咱們再業務程序中並無什麼區別。在這裏就不贅述。
咱們先看一下error事件參數:
屬性名稱 | 含義 | 類型 |
---|---|---|
message | 錯誤信息 | string |
filename | 異常的資源url | string |
lineno | 異常行號 | int |
colno | 異常列號 | int |
error | 錯誤對象 | object |
error.message | 錯誤信息 | string |
error.stack | 錯誤信息 | string |
其中核心的應該是錯誤棧,其實咱們定位錯誤最主要的就是錯誤棧。
錯誤堆棧中包含了絕大多數調試有關的信息。其中包括了異常位置(行號,列號),異常信息
有興趣的同窗能夠看看這篇文章
因爲通信的時候只能以字符串方式傳輸,咱們須要將對象進行序列化處理。
大概分紅如下三步:
將異常數據從屬性中解構出來存入一個JSON對象
將JSON對象轉換爲字符串
將字符串轉換爲Base64
固然在後端也要作對應的反向操做 這個咱們後面再說。
window.addEventListener('error', args => {
console.log(
'error event:', args
);
uploadError(args)
return true;
}, true);
function uploadError({ lineno, colno, error: { stack }, timeStamp, message, filename }) {
// 過濾
const info = {
lineno,
colno,
stack,
timeStamp,
message,
filename
}
// const str = new Buffer(JSON.stringify(info)).toString("base64");
const str = window.btoa(JSON.stringify(info))
const host = 'http://localhost:7001/monitor/error'
new Image().src = `${host}?info=${str}`
}
複製代碼
異常上報的數據必定是要有一個後端服務接收才能夠。
咱們就以比較流行的開源框架eggjs爲例來演示
# 全局安裝egg-cli
npm i egg-init -g
# 建立後端項目
egg-init backend --type=simple
cd backend
npm i
# 啓動項目
npm run dev
複製代碼
首先在app/router.js添加一個新的路由
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
// 建立一個新的路由
router.get('/monitor/error', controller.monitor.index);
};
複製代碼
建立一個新的controller (app/controller/monitor)
'use strict';
const Controller = require('egg').Controller;
const { getOriginSource } = require('../utils/sourcemap')
const fs = require('fs')
const path = require('path')
class MonitorController extends Controller {
async index() {
const { ctx } = this;
const { info } = ctx.query
const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
console.log('fronterror:', json)
ctx.body = '';
}
}
module.exports = MonitorController;
複製代碼
看一下接收後的結果
下一步就是講錯誤記入日誌。實現的方法能夠本身用fs寫,也能夠藉助log4js這樣成熟的日誌庫。
固然在eggjs中是支持咱們定製日誌那麼我麼你就用這個功能定製一個前端錯誤日誌好了。
在/config/config.default.js中增長一個定製日誌配置
// 定義前端錯誤日誌
config.customLogger = {
frontendLogger : {
file: path.join(appInfo.root, 'logs/frontend.log')
}
}
複製代碼
在/app/controller/monitor.js中添加日誌記錄
async index() {
const { ctx } = this;
const { info } = ctx.query
const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
console.log('fronterror:', json)
// 記入錯誤日誌
this.ctx.getLogger('frontendLogger').error(json)
ctx.body = '';
}
複製代碼
最後實現的效果
談到異常分析最重要的工做實際上是將webpack混淆壓縮的代碼還原。
在webpack的打包時會產生sourcemap文件,這個文件須要上傳到異常監控服務器。這個功能咱們試用webpack插件完成。
/source-map/plugin
const fs = require('fs')
var http = require('http');
class UploadSourceMapWebpackPlugin {
constructor(options) {
this.options = options
}
apply(compiler) {
// 打包結束後執行
compiler.hooks.done.tap("upload-sourcemap-plugin", status => {
console.log('webpack runing')
});
}
}
module.exports = UploadSourceMapWebpackPlugin;
複製代碼
webpack.config.js
// 自動上傳Map
UploadSourceMapWebpackPlugin = require('./plugin/uploadSourceMapWebPackPlugin')
plugins: [
// 添加自動上傳插件
new UploadSourceMapWebpackPlugin({
uploadUrl:'http://localhost:7001/monitor/sourcemap',
apiKey: 'kaikeba'
})
],
複製代碼
在apply函數中增長讀取sourcemap文件的邏輯
/plugin/uploadSourceMapWebPlugin.js
const glob = require('glob')
const path = require('path')
apply(compiler) {
console.log('UploadSourceMapWebPackPlugin apply')
// 定義在打包後執行
compiler.hooks.done.tap('upload-sourecemap-plugin', async status => {
// 讀取sourcemap文件
const list = glob.sync(path.join(status.compilation.outputOptions.path, `./**/*.{js.map,}`))
for (let filename of list) {
await this.upload(this.options.uploadUrl, filename)
}
})
}
複製代碼
upload(url, file) {
return new Promise(resolve => {
console.log('uploadMap:', file)
const req = http.request(
`${url}?name=${path.basename(file)}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
Connection: "keep-alive",
"Transfer-Encoding": "chunked"
}
}
)
fs.createReadStream(file)
.on("data", chunk => {
req.write(chunk);
})
.on("end", () => {
req.end();
resolve()
});
})
}
複製代碼
/backend/app/router.js
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
router.get('/monitor/error', controller.monitor.index);
// 添加上傳路由
router.post('/monitor/sourcemap',controller.monitor.upload)
};
複製代碼
添加sourcemap上傳接口
/backend/app/controller/monitor.js
async upload() {
const { ctx } = this
const stream = ctx.req
const filename = ctx.query.name
const dir = path.join(this.config.baseDir, 'uploads')
// 判斷upload目錄是否存在
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir)
}
const target = path.join(dir, filename)
const writeStream = fs.createWriteStream(target)
stream.pipe(writeStream)
}
複製代碼
最終效果:
執行webpack打包時調用插件sourcemap被上傳至服務器。
考慮到這個功能須要較多邏輯,咱們準備把他開發成一個獨立的函數而且用Jest來作單元測試
先看一下咱們的需求
輸入 | stack(錯誤棧) | ReferenceError: xxx is not defined\n' + ' at http://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js:1:1392' |
---|---|---|
SourceMap | 略 | |
輸出 | 源碼錯誤棧 | { source: 'webpack:///src/index.js', line: 24, column: 4, name: 'xxx' } |
搭建Jest框架
首先建立一個/utils/stackparser.js文件
module.exports = class StackPaser {
constructor(sourceMapDir) {
this.consumers = {}
this.sourceMapDir = sourceMapDir
}
}
複製代碼
在同級目錄下建立測試文件stackparser.spec.js
以上需求咱們用Jest表示就是
const StackParser = require('../stackparser')
const { resolve } = require('path')
const error = {
stack: 'ReferenceError: xxx is not defined\n' +
' at http://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js:1:1392',
message: 'Uncaught ReferenceError: xxx is not defined',
filename: 'http://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js'
}
it('stackparser on-the-fly', async () => {
const stackParser = new StackParser(__dirname)
// 斷言
expect(originStack[0]).toMatchObject(
{
source: 'webpack:///src/index.js',
line: 24,
column: 4,
name: 'xxx'
}
)
})
複製代碼
整理以下:
下面咱們運行Jest
npx jest stackparser --watch
複製代碼
顯示運行失敗,緣由很簡單由於咱們尚未實現對吧。下面咱們就實現一下這個方法。
首先建立一個新的Error對象 將錯誤棧設置到Error中,而後利用error-stack-parser這個npm庫來轉化爲stackFrame
const ErrorStackParser = require('error-stack-parser')
/** * 錯誤堆棧反序列化 * @param {*} stack 錯誤堆棧 */
parseStackTrack(stack, message) {
const error = new Error(message)
error.stack = stack
const stackFrame = ErrorStackParser.parse(error)
return stackFrame
}
複製代碼
運行效果
下一步咱們將錯誤棧中的代碼位置轉換爲源碼位置
const { SourceMapConsumer } = require("source-map");
async getOriginalErrorStack(stackFrame) {
const origin = []
for (let v of stackFrame) {
origin.push(await this.getOriginPosition(v))
}
// 銷燬全部consumers
Object.keys(this.consumers).forEach(key => {
console.log('key:',key)
this.consumers[key].destroy()
})
return origin
}
async getOriginPosition(stackFrame) {
let { columnNumber, lineNumber, fileName } = stackFrame
fileName = path.basename(fileName)
console.log('filebasename',fileName)
// 判斷是否存在
let consumer = this.consumers[fileName]
if (consumer === undefined) {
// 讀取sourcemap
const sourceMapPath = path.resolve(this.sourceMapDir, fileName + '.map')
// 判斷目錄是否存在
if(!fs.existsSync(sourceMapPath)){
return stackFrame
}
const content = fs.readFileSync(sourceMapPath, 'utf8')
consumer = await new SourceMapConsumer(content, null);
this.consumers[fileName] = consumer
}
const parseData = consumer.originalPositionFor({ line:lineNumber, column:columnNumber })
return parseData
}
複製代碼
咱們用Jest測試一下
it('stackparser on-the-fly', async () => {
const stackParser = new StackParser(__dirname)
console.log('Stack:',error.stack)
const stackFrame = stackParser.parseStackTrack(error.stack, error.message)
stackFrame.map(v => {
console.log('stackFrame', v)
})
const originStack = await stackParser.getOriginalErrorStack(stackFrame)
// 斷言
expect(originStack[0]).toMatchObject(
{
source: 'webpack:///src/index.js',
line: 24,
column: 4,
name: 'xxx'
}
)
})
複製代碼
看一下結果測試經過。
async index() {
console.log
const { ctx } = this;
const { info } = ctx.query
const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
console.log('fronterror:', json)
// 轉換爲源碼位置
const stackParser = new StackParser(path.join(this.config.baseDir, 'uploads'))
const stackFrame = stackParser.parseStackTrack(json.stack, json.message)
const originStack = await stackParser.getOriginalErrorStack(stackFrame)
this.ctx.getLogger('frontendLogger').error(json,originStack)
ctx.body = '';
}
複製代碼
運行效果:
Fundebug專一於JavaScript、微信小程序、微信小遊戲、支付寶小程序、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了10億+錯誤事件,付費客戶有陽光保險、荔枝FM、掌門1對一、核桃編程、微脈等衆多品牌企業。歡迎免費試用!
Sentry 是一個開源的實時錯誤追蹤系統,能夠幫助開發者實時監控並修復異常問題。它主要專一於持續集成、提升效率而且提高用戶體驗。Sentry 分爲服務端和客戶端 SDK,前者能夠直接使用它家提供的在線服務,也能夠本地自行搭建;後者提供了對多種主流語言和框架的支持,包括 React、Angular、Node、Django、RoR、PHP、Laravel、Android、.NET、JAVA 等。同時它可提供了和其餘流行服務集成的方案,例如 GitHub、GitLab、bitbuck、heroku、slack、Trello 等。目前公司的項目也都在逐步應用上 Sentry 進行錯誤日誌管理。
截止到目前爲止,咱們把前端異常監控的基本功能算是造成了一個MVP(最小化可行產品)。後面須要升級的還有不少,對錯誤日誌的分析和可視化方面可使用ELK。發佈和部署能夠採用Docker。對eggjs的上傳和上報最好要增長權限控制功能。
參考代碼位置: github.com/su37josephx…
歡迎指正,歡迎Star。