# 前端监控

# 前端性能监控

利用 performance API let timing = performance.getEntriesByType('navigation')[0]; 或者 let timing = performance.timing。封装一个函数,在页面加载完毕后执行,做了一些各个阶段性能指标的计算,然后通过接口发送到服务器,用于统计判断。

在 Console 中输入 performance.getEntriesByType('navigation')[0]

connectEnd: 9.225000045262277
connectStart: 9.225000045262277
decodedBodySize: 113880
domComplete: 1361.1650000093505
domContentLoadedEventEnd: 522.205000044778
domContentLoadedEventStart: 522.1950000268407
domInteractive: 223.31999999005347
domainLookupEnd: 9.225000045262277
domainLookupStart: 9.225000045262277
duration: 1361.4700000034645
encodedBodySize: 23636
entryType: "navigation"
fetchStart: 9.225000045262277
initiatorType: "navigation"
loadEventEnd: 1361.4700000034645
loadEventStart: 1361.3600000389852
name: "https://juejin.cn/post/6844903540385644557"
nextHopProtocol: "h2"
redirectCount: 0
redirectEnd: 0
redirectStart: 0
requestStart: 12.350000033620745
responseEnd: 204.4350000214763
responseStart: 155.64000001177192
secureConnectionStart: 9.225000045262277
serverTiming: (3) [PerformanceServerTiming, PerformanceServerTiming, PerformanceServerTiming]
startTime: 0
transferSize: 24222
type: "navigate"
unloadEventEnd: 0
unloadEventStart: 0
workerStart: 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

输入 performance.timing 可以得到:

connectEnd: 1608797959509
connectStart: 1608797959509
domComplete: 1608797960861
domContentLoadedEventEnd: 1608797960022
domContentLoadedEventStart: 1608797960022
domInteractive: 1608797959723
domLoading: 1608797959663
domainLookupEnd: 1608797959509
domainLookupStart: 1608797959509
fetchStart: 1608797959509
loadEventEnd: 1608797960862
loadEventStart: 1608797960861
navigationStart: 1608797959501
redirectEnd: 0
redirectStart: 0
requestStart: 1608797959512
responseEnd: 1608797959705
responseStart: 1608797959656
secureConnectionStart: 0
unloadEventEnd: 0
unloadEventStart: 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

计算一些关键的性能指标

window.addEventListener('load', (event) => {
    // Time to Interactive
    let timing = performance.getEntriesByType('navigation')[0];
	// let timing = performance.timing
    console.log(timing.domInteractive);
    console.log(timing.fetchStart);
    let diff = timing.domInteractive - timing.fetchStart;
    console.log("TTI: " + diff);
})
1
2
3
4
5
6
7
8
9

以上代码只测量了"首次可交互时间",其它常用的性能指标还有:

DNS 解析耗时: domainLookupEnd - domainLookupStart
TCP 连接耗时: connectEnd - connectStart
SSL 安全连接耗时: connectEnd - secureConnectionStart
网络请求耗时 (TTFB): responseStart - requestStart
数据传输耗时: responseEnd - responseStart
DOM 解析耗时: domInteractive - responseEnd
资源加载耗时: loadEventStart - domContentLoadedEventEnd
First Byte时间: responseStart - domainLookupStart
白屏时间: responseEnd - fetchStart
首次可交互时间: domInteractive - fetchStart
DOM Ready 时间: domContentLoadEventEnd - fetchStart
页面完全加载时间: loadEventStart - fetchStart
http 头部大小: transferSize - encodedBodySize
重定向次数:performance.navigation.redirectCount
重定向耗时: redirectEnd - redirectStart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

利用 let timing = performance.getEntriesByType('navigation')[0]; 或者 let timing = performance.timing 计算结果是基本一致的。

# 前端错误监控

# 前端错误的分类

  • 即时运行错误(代码错误)

  • 资源加载错误

# 即时运行错误捕获方式

# 全局捕获

对于代码运行错误,通常的办法是使用 window.onerror 或者 addEventListener 拦截报错:

window.onerror = function(errorMessage, scriptURI, lineNo, columnNo, error) {
  console.log('errorMessage: ' + errorMessage); // 异常信息
  console.log('scriptURI: ' + scriptURI); // 异常文件路径
  console.log('lineNo: ' + lineNo); // 异常行号
  console.log('columnNo: ' + columnNo); // 异常列号
  console.log('error: ' + error); // 异常堆栈信息
  // ...
  // 异常上报
};
throw new Error('这是一个错误');
1
2
3
4
5
6
7
8
9
10
window.addEventListener('error', function() {
  console.log(error);
  // ...
  // 异常上报
});
throw new Error('这是一个错误');
1
2
3
4
5
6

通过 window.onerror 事件,可以得到具体的异常信息、异常文件的URL、异常的行号与列号及异常的堆栈信息,再捕获异常后,统一上报至我们的日志服务器。

该方法能拦截到大部分的详细报错信息,但是也有例外:

  • 对于跨域的代码运行错误会显示 Script error. 对于这种情况我们需要给 script 标签添加 crossorigin="anonymous" 属性,或后端配置 Access-Control-Allow-Origin
  • 对于某些浏览器可能不会显示调用栈信息,这种情况可以通过 arguments.callee.caller 来做栈递归

# try catch

使用 try... catch 虽然能够较好地进行异常捕获,不至于使得页面由于一处错误挂掉,但 try ... catch 捕获方式显得过于臃肿,大多代码使用 try ... catch 包裹,影响代码可读性。

不过,对于异步代码来说,可以使用 catch 的方式捕获错误。比如 Promise 可以直接使用 catch 函数,async await 可以使用 try catch。

# 资源加载错误的捕获方式

# object.onerror

img 标签、script 标签等节点都可以添加 onerror 事件,用来捕获资源加载的错误。

# performance.getEntries

performance.getEntries 可以获取所有已加载资源的加载时长,通过这种方式,可以间接的拿到没有加载的资源错误。

举例:

浏览器打开一个网站,在Console控制台下,输入:

performance.getEntries().forEach(function(item){console.log(item.name)})

// 或者输入:
performance.getEntries().forEach(item=>{console.log(item.name)})
1
2
3
4

上面这个api,返回的是数组,既然是数组,就可以用forEach遍历。打印出来的资源就是已经成功加载的资源。

再入document.getElementsByTagName('img'),就会显示出所有需要加载的的img集合。

于是,document.getElementsByTagName('img') 获取的资源数组减去通过 performance.getEntries() 获取的资源数组,剩下的就是没有成功加载的,这种方式可以间接捕获到资源加载错误。

# 错误上报的两种方式

# ajax

采用 Ajax 通信的方式上报(此方式虽然可以上报错误,但是如果是简单的监控我们并不采用这种方式)

# 利用Image对象上报

利用 Image 对象上报(推荐,网站的监控体系都是采用的这种方式)

//通过Image对象进行错误上报
(new Image()).src = 'http://smyhvae.com/myPath?badjs=msg';   // myPath表示上报的路径(我要上报到哪里去)。后面的内容是自己加的参数。
1
2

这种方式,不需要借助第三方的库,一行代码即可搞定。

# 遇到的问题

通常在生产环境下的代码是经过 webpack 打包后压缩混淆的代码,所以我们可能会遇到所有的报错的代码行数都在第一行了,这是因为在生产环境下,我们的代码被压缩成了一行。

解决办法是开启 webpack 的 source-map,我们利用 webpack 打包后的生成的一份 .map 的脚本文件就可以让浏览器对错误位置进行追踪了。

但是问题又来了:

  • 直接在生产环境使用 source-map 文件,会暴露源码,怎么办?
  • source-map 在部分浏览器不兼容,怎么办?

针对第一个问题,我们可能可以把完整的 source-map 包换成 cheap-module-source-map,这样只会暴露错误所在的文件和代码行号,就不会暴露源码了。但是这样还是无法解决不兼容的问题。

针对第二个问题,可以使用引入 npm 库来支持 source-map,可以参考 mozilla/source-map (opens new window)。这个 npm 库既可以运行在客户端也可以运行在服务端,不过更为推荐的是在服务端使用 Node.js 对接收到的日志信息时使用 source-map 解析,以避免源代码的泄露造成风险,如下代码所示:

const express = require('express');
const fs = require('fs');
const router = express.Router();
const sourceMap = require('source-map');
const path = require('path');
const resolve = file => path.resolve(__dirname, file);
// 定义post接口
router.get('/error/', async function(req, res) {
    // 获取前端传过来的报错对象
    let error = JSON.parse(req.query.error);
    let url = error.scriptURI; // 压缩文件路径
    if (url) {
        let fileUrl = url.slice(url.indexOf('client/')) + '.map'; // map文件路径
        // 解析sourceMap
        let consumer = await new sourceMap.SourceMapConsumer(fs.readFileSync(resolve('../' + fileUrl), 'utf8')); // 返回一个promise对象
        // 解析原始报错数据
        let result = consumer.originalPositionFor({
            line: error.lineNo, // 压缩后的行号
            column: error.columnNo // 压缩后的列号
        });
        console.log(result);
    }
});
module.exports = router;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

针对第二个问题找到的其它解决方案:

需要服务器配置 .js.map 后缀的文件不可访问。  如果这样的话,服务器解析的时候不能直接去下载静态资源 .map 文件,而是需要去找到服务器本地对应的 map 文件,这样要单独配置路径和写逻辑很麻烦,而且文件夹结构有变动的话也不灵活。  所以我们的方案是做token权限校验,map 文件必须加正确的 token 参数,服务器才会返回资源(xxx.js.map?token=xxxx),否则 nginx 会屏蔽没有 token 或者 token 错误的请求。

这种方式就不需要单独在通过服务端接口解析了

# Vue 和 React 捕获异常

Vue

正常我们捕获不到 Vue 组件的异常,查阅资料得知,在 Vue 中,异常可能被 Vue 自身给 try ... catch 了,不会传到 window.onerror 事件触发,那么我们如何把 Vue 组件中的异常作统一捕获呢?

使用 Vue.config.errorHandler (opens new window) 这样的 Vue 全局配置,可以在 Vue 指定组件的渲染和观察期间未捕获错误的处理函数。这个处理函数被调用时,可获取错误信息和 Vue 实例。

Vue.config.errorHandler = function (err, vm, info) {
  // handle error
  // `info` 是 Vue 特定的错误信息,比如错误所在的生命周期钩子
  // 只在 2.2.0+ 可用
}
1
2
3
4
5

React

在 React 中,可以使用 ErrorBoundary 组件包括业务组件的方式进行异常捕获,配合 React 16.0+ 新出的 componentDidCatch API,可以实现统一的异常捕获和日志上报。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

使用方式如下:

<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>
1
2
3

# 实际使用后的优化

  • 我们发现不同的浏览器报错的变量可能不一样,同一个报错在chrome浏览器和firefox上 columnNo 参数一点偏差。  用两种报错解析了一下,如下图,报错的代码都是18行,是没问题的,Firefox报错是下图第一个:console 18 0 true,chrome是testBase 18 0 true,行数没问题,偏差不影响我们最终查错,我的18行源代码是:console.log(testBase)。  testBase是故意没有申明,testBase是undefined,出问题的应该是testBase这个变量,过从报错情况上看,确实是谷歌浏览器更精准一点。  虽然不在意IE,不过IE11报错列数和firefox一致。

  • 页面触发事件报错,用户一直触发按钮,这时就会不停上报错误信息。解决:存储上一个报错信息和时间,进行比对,同一个报错,短时间内避免一直重复发送。

  • 引入监控的项目,由于业务原因可能需要上传一些业务信息方便分析,所以预留一个配置字段,上传错误的时候请求会带上业务相关信息。

# 页面埋点

页面埋点应该是大家最常写的监控了,一般起码会监控以下几个数据:

  • PV / UV
  • 停留时长
  • 流量来源
  • 用户交互

对于这几类统计,一般的实现思路大致可以分为两种,分别为手写埋点和无埋点的方式。

相信第一种方式也是大家最常用的方式,可以自主选择需要监控的数据然后在相应的地方写入代码。这种方式的灵活性很大,但是唯一的缺点就是工作量较大,每个需要监控的地方都得插入代码。

另一种无埋点的方式基本不需要开发者手写埋点了,而是统计所有的事件并且定时上报。这种方式虽然没有前一种方式繁琐了,但是因为统计的是所有事件,所以还需要后期过滤出需要的数据。

# 开源的解决方案

一些开源的解决方案,比如腾讯的 badjs,淘宝的 JSTracker,阿里巴巴的 FdSafe,支付宝的 saijs,国外的 sentry 和对应的前端 sdk ravenjs,包括对应的 TraceKit。

目前主流的前端监控系统,包括一些收费服务,国内比如 fundebug,产品功能和 sentry 还真像,只不过他只关注前端,sentry 关注所有的 Error 收集场景,他们解决的问题其实都是本文要说到的一些通用问题,但是针对到具体项目适合不适合,就要看业务方自己选择了。

其中个人觉得 sentry 是比较好的选择,有以下几点:

  • 开源
  • 对各种前端框架的友好支持 (Vue、React、Angular)
  • 支持 SourceMap

Sentry 官方提供的免费服务有次数限制,达到一定限制后继续使用就需要收费了,但是我们可以利用 Sentry 的开源库在自己的服务器上搭建服务,官方已经提供了完善的操作文档。

有兴趣的童鞋可以参考 Sentry 搭建错误监控系统: 超详细!搭建一个前端错误监控系统 (opens new window)

参考链接:

前端性能与异常上报 (opens new window)

从无到有<前端异常监控系统>落地 (opens new window)

前端面试之道 (opens new window)