web前端性能监控实现

129 阅读19分钟

为什么要做前端监控

  • 更快的发现问题和解决问题
  • 做产品的决策依据
  • 为业务扩展提供了更多可能性
  • 提升前端工程师的技术深度和广度

前端监控目标

  • js错误:js执行错误、promise异常
  • 资源错误:js、css资源加载异常
  • 接口错误:ajax、fetch请求接口异常
  • 白屏:页面空白

前端监控流程

  1. 前端埋点
  2. 数据上报
  3. 加工汇总
  4. 可视化展示
  5. 监控报警

image.png

常见的埋点方式

1.代码埋点

嵌入代码的形式

  • 优点:精确(任意时刻,数据量全面)
  • 缺点:代码工作量大

2.可视化埋点

  • 通过可视化交互手段,代替代码埋点
  • 将业务代码和埋点代码分离,提供一个可视化的页面,输入业务代码,通过这个系统,可以在业务代码中自定义的增加埋点事件等,最后输出的代码耦合了业务代码和埋点代码
  • 用系统来代替手工插入埋点代码

3.无痕埋点

前端的人一个事件被绑定一个表示,所有的事件都被记录下来 通过定期上传记录文件,配合文件解析,解析出想要的数据,生成可视化报告供专业人员分析 无痕埋点的优势点是采集全量数据,不会出现漏埋和误埋等现象 缺点是给数据传输和服务器增加压力,无法灵活定制数据结构

编写采集代码

新建 myMonitor 文件在终点站中打开 npm init -y 初始化项目

项目目录结构

myMonitor
|-- dist //编译打包后
|-- node_modules //依赖
|-- src
     |-- mointor //主要逻辑代码
            |-- lib   
            |-- utils 
            |-- index.js 
     |-- index.html 
     |-- index.js
|-- package.json //依赖的配置文件
|-- webpack.config.js //项目配置文件

webpack 配置

在myMonitor目录下新建 webpack.config.js

const path = require("path");
// const webpack打包项目的 HtmlWebpackPlugin 生产出html文件,user-agent 
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
    entry:'./src/index.js',//入口文件
    context: process.cwd(),//上下文目录
    mode:'development',//开发模式
    output:{
        path:path.resolve(__dirname,'dist'),//输出目录
        filename:'monitor.js',//文件名
    },
    devServer:{
        contentBase:path.resolve(__dirname,"dist"),//devServer静态文件根目录
        before(router){
            router.get('/success',function(req,res){
                res.json({id:1})//200
            })
            router.post('/error',function(req,res){
                res.sendStatus(500)//500
            })
        }
    },  
    plugins:[
        new HtmlWebpackPlugin({//自动打包出HTML 文件
            template:'./src/index.html',
            inject:'head'
        })
    ]
}

1.接入日志系统

各公司一般有自己的日志系统,接收数据上报。

也可以使用:阿里云日志服务 www.aliyun.com/product/sls…

以下案例使用阿里云日志服务进行日志上报 配置阿里云上报接口

在 myMonitor/src/utils/新建 tracker.js

let host = "cn-beijing.log.aliyuncs.com"
let project = "xxxxxx";
let logStore = "xxxxx";
let userAgent = require('user-agent')

function getExtraData(){
    return {
        title:document.title,//页面名称
        url:location.href,//请求地址
        timestamp:Date.now(),//发送时间
        userAgent:userAgent.parse(navigator.userAgent).name,//浏览器名称
    }
}

class SendTracker(){
    constructor(){
       this.url = `http://${project}.${host}/logstores/${logStore}/track`,//上报的路径
       this.xhr = new XMLHttpRequest;
    }
    send(data={}){
        let extraDate = getExtraData();
        let log = {...extraDate,...data};
        //对象储存值不能是数字
        for(let key in log){
            if(typeof log[key] === "number"){
                log[key] = `${log[key]}`;
            }
        }
        let body = JSON.stringify({
            __logs__:[log]
        });
        this.xhr.open('POST',this.url,true);
        this.xhr.setRequestHeader('Content-type','application/json');//请求体类型
        this.xhr.setRequestHeader('x-log-apiversion','0.6.0');//版本号
        this.xhr.setRequestHeader('x-log-bodyrawsize',body.length);//请求大小
        this.xhr.onload = function(){
            // console.log(this.xhr.response);
        }        
        this.xhr.onerror = function(error){
            // console.log(error);
        }
        this.xhr.send(body)
    }
}
export default new SendTracker();

2.监控错误

错误分类

js 错误(js 执行错误,promise 异常)

错误结构数据分析

jsError

{
    "title": "前端监控系统", // 页面标题
    "url": "http://localhost:8080/", // 页面URL
    "timestamp": "1590815288710", // 访问时间戳
    "userAgent": "Chrome", // 用户浏览器类型
    "kind": "stability", // 大类
    "type": "error", // 小类
    "errorType": "jsError", // 错误类型
    "message": "Uncaught TypeError: Cannot set property 'error' of undefined", // 类型详情
    "filename": "http://localhost:8080/", // 访问的文件名
    "position": "0:0", // 行列信息
    "stack": "btnClick (http://localhost:8080/:20:39)^HTMLInputElement.onclick (http://localhost:8080/:14:72)", // 堆栈信息
    "selector": "HTML BODY #container .content INPUT" // 选择器
}

PromiseError

    ...
    "errorType": "promiseError",//错误类型 
    "message": "someVar is not defined",//类型详情
    "stack": "http://localhost:8080/:24:29^new Promise (<anonymous>)^btnPromiseClick (http://localhost:8080/:23:13)^HTMLInputElement.onclick (http://localhost:8080/:15:86)",//堆栈信息 
    "selector": "HTML BODY #container .content INPUT"//选择器

监控 js 错误逻辑实现

实现逻辑:通过window.addEventListener监听error错误进行错误日志上报
在 myMonitor/src/utils/新建 getLastEvent.js 用于获取报错事件类型

let lastEvent;
['click','touchstart','mousedown','keydown','mouseover'].forEach(eventType => {
    document.addEventListener(eventType,(event)=>{
        event.path =  event.path || (event.composedPath && event.composedPath())//浏览器兼容问题
        lastEvent = event 
    },{
        capture:true,//捕获阶段
        passive:true//默认不阻止默认事件
    })
})

export default function(){
    return lastEvent;
}

在 myMonitor/src/utils/新建 getSelector.js

//将  [input#errorBtn, div.content, div#container, body, html, document, Window] 转换为lastEvent html body div#container div.content input#errorBtn 

function getSelectors(path){
    return path.reverse().filter(element=>{
        return element !== document && element !== window;
    }).map(element => {
        let selector = ''
        if(element.id){
            return `${element.nodeName.toLowerCase()}#${element.id}`;
        }else if(element.className && typeof element.className === "string"){
            return `${element.nodeName.toLowerCase()}.${element.className}`
        }else{
            selector = element.nodeName.toLowerCase()
        }
        // console.log('selector',selector);
        return selector;
    }).join(' ');
}

export default function (pathOrTarget){
    if(Array.isArray(pathOrTarget)){
        return getSelectors(pathOrTarget) //可能是一个数组
    }else{
        let path = [];
        while(pathOrTarget){
            path.push(pathOrTarget);
            pathOrTarget = pathOrTarget.parentNode;
        }
        return getSelectors(path)//也有可以能是一个对象
    }
}

在 myMonitor/src/lib/新建 jsError.js

import gatLastEvent from "../utils/getLastEvent"
import getSelector from "../utils/getSelector";
import tracker from "../utils/tracker";

//全局监听错误信息,如报错就上报日志
window.addEventListener('error',function(){
    let lastEvent = getLastEvent();//获取最后一个交互事件
    tracker.send({
       kind:'stability',//监控指标大类
        type:"error",//小类型这是一个错误
        errorType:"jsError",// 错误类型(js执行错误)
        message:evnet.message,//错误详情
        filename:evnet.filename,//访问文件名
        position:`${evnet.lineno}:${evnet.colno}`, //行列信息
        stack: evnet.error ?  getLines(evnet.error.stack) : '',//堆栈信息
        selector: lastEvent ? getSelector(lastEvent.path) : '',//选择器
    })
},true)//true代表在捕获阶段调用,false代表在冒泡阶段捕获,使用true或false都可以

//getLines()
//将TypeError: Cannot set properties of undefined (setting 'error')
//    at errorClick (index.html:27:34)
//    at HTMLInputElement.onclick (index.html:15:87)
//转换为"errorClick (http://127.0.0.1:5555/monitor/dist/index.html:27:34)^HTMLInputElement.onclick (http://127.0.0.1:5555/monitor/dist/index.html:15:87)"
function getLines(stack){
    return stack.split('\n').slice(1).map(item => item.replace(/^\s+at\s+/g,"")).join('^');
}

资源加载错误 判断

import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";
import tracker from "../utils/tracker";
export function injectJsError(){
//全局监听错误信息,如报错就上报日志
    window.addEventListener('error',function(event){
        let lastEvent = getLastEvent();//获取最后一个交互事件
        /* ++++ start  ++++*/
        //判断是否资源加载错误
        if(event.targat && (evnet.target.src || event.target.href)){
             tracker.send({
                kind:'stability',//监控指标大类
                type:"error",//小类型这是一个错误
                errorType:"resourceError",//js或css资源加载错误
                filename:evnet.target.src || evnet.target.href,//那个文件报错了
                tarName:evnet.target.tarName, //SCRIPT
                selector: getSelector(evnet.target),//代表最后一个操作的元素
             })
        }else{
            //js 错误日志上报
            tracker.send({
                kind:'stability',//监控指标大类
                type:"error",//小类型这是一个错误
                errorType:"jsError",//js执行错误
                message:evnet.message,//报错信息
                filename:evnet.filename,//那个文件报错了
                position:`${evnet.lineno}:${evnet.colno}`,
                stack: evnet.error ?  getLines(evnet.error.stack) : '',
                selector: lastEvent ? getSelector(lastEvent.path) : '',//代表最后一个操作的元素
            })

        }
        /* ++++ end    ++++*/
    },true)
    function getLines(stack){
        return stack.split('\n').slice(1).map(item => item.replace(/^\s+at\s+/g,"")).join('^');
    }
}

Promise 通过监听 unhandledrejection 错误进行日志上报

import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";
import tracker from "../utils/tracker";
export function injectJsError(){
    //全局监听错误信息,如报错就上报日志
    window.addEventListener('error',function(event){
        let lastEvent = getLastEvent();//获取最后一个交互事件
        //判断是否资源加载错误
        if(event.targat && (evnet.target.src || event.target.href)){
             tracker.send({
                kind:'stability',//监控指标大类
                type:"error",//小类型这是一个错误
                errorType:"resourceError",//js或css资源加载错误
                filename:evnet.target.src || evnet.target.href,//那个文件报错了
                tarName:evnet.target.tarName, //SCRIPT
                selector: getSelector(evnet.target),//代表最后一个操作的元素
             })
        }else{
            //js 错误日志上报
            tracker.send({
                kind:'stability',//监控指标大类
                type:"error",//小类型这是一个错误
                errorType:"jsError",//js执行错误
                message:evnet.message,//报错信息
                filename:evnet.filename,//那个文件报错了
                position:`${evnet.lineno}:${evnet.colno}`,
                stack: evnet.error ?  getLines(evnet.error.stack) : '',
                selector: lastEvent ? getSelector(lastEvent.path) : '',//代表最后一个操作的元素
            })

        }
    },true)
    function getLines(stack){
        return stack.split('\n').slice(1).map(item => item.replace(/^\s+at\s+/g,"")).join('^');
    }
    /* ++++ start  ++++*/
    //Promose 异常捕获
    window.addEventListener('unhandledrejection',function(event){
        let lastEvent = getLastEvent();//最后一个交互事件
        let massage,filename,line =0,column = 0, stack="",reason = event.reason;
        if(typeof reason === "string"){
            message = reason;
        }else if(typeof reason === "object"){
            if(reason.stack){
                let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/);
                filename = matchResult[1];
                line = matchResult[2]
                column = matchResult[3];
            }
            stack = getLones(reason.stack)
        }
        tracker.send({
            kind:'stability',//监控指标大类
            type:"error",//小类型这是一个错误
            errorType:"promiseError",//js执行错误
            message,//报错信息
            filename,//那个文件报错了
            position:`${line}:${column}`,
            stack,
            selector: lastEvent ? getSelector(lastEvent.path) : '',//代表最后一个操作的元素
        })
    },true);
    /* ++++ end    ++++*/
    function getLines(stack){
        return stack.split('\n').slice(1).map(item => item.replace(/^\s+at\s+/g,"")).join('^');
    }
}

测试

<!DOCTYPE html>

<html lang="en">

<head>

    <meta charset="UTF-8">

    <meta http-equiv="X-UA-Compatible" content="IE=edge">

    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <title>前端监控SDK</title>

    <link rel="stylesheet" href="./index.css">

</head>

<body>

    <div id="container">   </div>

    <script src="cxxx"></script>

    <script>

       function errorClick(e){

          window.someVar.error = 'error'

        }

    </script>

    <script src="./index.js"></script>

</body>

</html>

网络请求错误

数据接口设计

{
  "title": "前端监控系统", //标题
  "url": "http://localhost:8080/", //url
  "timestamp": "1590817024490", //timestamp
  "userAgent": "Chrome", //浏览器版本
  "kind": "stability", //大类
  "type": "xhr", //小类
  "eventType": "load", //事件类型
  "pathname": "/success", //路径
  "status": "200-OK", //状态码
  "duration": "7", //持续时间
  "response": "{"id":1}", //响应内容
  "params": "" //参数
}

ajax axios fetch 实现网络请求错误日志上报

  • 重写xhr的open、send方法
  • 监听load、error、abort事件

在 myMonitor/src/lib/新建 xxx.js

原生XMLHttpRequest 实现

这段代码是一个用于监控XHR请求的工具函数,其主要作用是使得我们能够记录下所有发送XHR请求的信息,包括请求的url,请求的方法(GET、POST等),请求的参数等,并且将这些信息通过一个tracker模块发送到后台服务器上,以便进行可靠性分析和出错定位。

具体来说,这个injectXHR函数是一个用于注入XHR的方法,它将会对XMLHttpRequest的open和send方法进行重写。

在重写open方法时,我们会先判断当前请求的url是否和日志收集服务以及websocket通信相关的请求,如果请求的url不属于这些范畴,我们就会将请求的相关信息存储在logData对象中,以备后续使用。然后,我们会调用原生open方法,但不会将返回值直接返回,因为我们需要在调用send方法时记录下XHR的状态。

在重写send方法时,我们会判断是否存在logData对象,如果存在,则表明这个XHR请求是我们所需要进行监控的,我们会在send方法中为XHR添加各个状态的回调函数,并在回调函数中记录下XHR的状态信息,包括请求持续时间,响应体等。然后,我们将这些数据通过tracker模块发送到后台服务器上,以便进行可靠性分析和出错定位。

// 引入tracker模块
import tracker from '../utils/tracker'
// 定义injectXHR函数对XMLHttpRequest的open和send进行重写
export function injectXHR(){
    console.log('错误监控开始了')
    // 存储原生XMLHttpRequest对象
    const XHMLHttpRequest = window.XMLHttpRequest;
    // 重写open方法
    const oldOpen = XHMLHttpRequest.prototype.open;
    XHMLHttpRequest.prototype.open = function(method, url, async){
        // 过滤掉和日志收集服务以及websocket通信相关的请求
        if(!url.match(/logstores/) && !url.match(/sockjs/)){
            // 存储请求数据
            this.logData = {method, url, async}
        }
        // 调用原生open方法
        return oldOpen.apply(this, arguments)
    }  
    // 重写send方法
    const oldSend = XHMLHttpRequest.prototype.send;
    XHMLHttpRequest.prototype.send = function(body){
        if(this.logData){
            // 记录发送前的时间
            const startTime = Date.now();
            // 定义回调函数记录XHR的状态
            const handler = (type) => (event) => {
                // 计算持续时间
                const duration = Date.now() - startTime;
                // 获取XHR的状态码和状态信息
                const status = this.status; // 200 or 500
                const statusText = this.statusText; // ok or Server Error
                // 将收集到的数据发送到后台
                tracker.send({
                    kind: 'stability', // 稳定性
                    type: 'xhr', // xhr请求
                    eventType: type, // 请求的事件类型(load/error/abort)
                    pathname: this.logData.url, // 请求的路径
                    status: `${status}-${statusText}`, // 状态码和状态信息
                    duration, // 请求持续时间
                    response: this.response ? JSON.stringify(this.response) : '', // 响应体
                    params: body || "" // 发送的参数
                });
            }
            // 添加监听回调
            this.addEventListener('load', handler('load'), true)
            this.addEventListener('error', handler('error'), true)
            this.addEventListener('abort', handler('abort'), true);
        }
        // 调用原生send方法
        return oldSend.apply(this, arguments)
    }
}

axios 听过拦截实现错误日志上报

在这里,我们使用axios的拦截器,对请求和响应进行拦截,并处理相关日志数据,最后进行上报。其中:

  1. 在请求拦截器中,我们过滤掉和日志收集服务以及websocket通信相关的请求,并将请求数据存储在config.logData中。
  2. 在响应拦截器中,我们根据config.logData判断当前请求是否需要上报,计算持续时间,并把收集到的数据发送到后台。
  3. 对于error,我们在响应拦截器的rejected中捕获,同时用Promise.reject(error)继续向下传递,实现错误的统一处理。

至此,完成了用axios实现请求日志收集服务的功能。

import axios from 'axios';
import tracker from '../utils/tracker';
export function axiosXHRLogs(){
    // 拦截请求之前的处理
    axios.interceptors.request.use(config => {
        console.log('axios','请求拦截开始')
        // 过滤掉和日志收集服务以及websocket通信相关的请求
        if (!config.url.match(/logstores/) && !config.url.match(/sockjs/)) {
            // 存储请求数据
            config.logData = {
              method: config.method,
              url: config.url,
              async: config.async
            };
        }
        return config;
    });
    // 拦截响应之后的处理
    axios.interceptors.response.use(response => {
        if(response.config.logData){
            // 记录发送前的时间
            const startTime = Date.now();
            // 获取XHR的状态码和状态信息
            const status = response.status; // 200 or 500
            const statusText = response.statusText; // ok or Server Error
            // 计算持续时间
            const duration = Date.now() - startTime;
            // 将收集到的数据发送到后台
            data = {
                kind: 'stability', // 稳定性
                type: 'xhr', // xhr请求
                eventType: 'success', // 请求的事件类型(success/error/abort)
                pathname: response.config.logData.url, // 请求的路径
                status: `${status}-${statusText}`, // 状态码和状态信息
                duration, // 请求持续时间
                response: response.data ? JSON.stringify(response.data) : '', // 响应体
                params: response.config.data || "" // 发送的参数
            }
            console.log("response",data)
            tracker.send(data);
        }
        return response;
    }, error => {
        if(error.config.logData){
            // 记录发送前的时间
            const startTime = Date.now();
            // 获取XHR的状态码和状态信息
            const status = error.response ? error.response.status : '';
            const statusText = error.response ? error.response.statusText : '';
            // 计算持续时间
            const duration = Date.now() - startTime;
            // 将收集到的数据发送到后台
            data = {
                kind: 'stability', // 稳定性
                type: 'xhr', // xhr请求
                eventType: 'error', // 请求的事件类型(success/error/abort)
                pathname: error.config.logData.url, // 请求的路径
                status: `${status}-${statusText}`, // 状态码和状态信息
                duration, // 请求持续时间
                response: error.response ? JSON.stringify(error.response.data) : '', // 响应体
                params: error.config.data || "" // 发送的参数
            }
            console.log('error',data)
            tracker.send(data);
        }
        return Promise.reject(error);
    });
}

监听所有fetch 请求进行拦截

与XHR的拦截不同,fetch的拦截需要对所有fetch请求进行拦截并进行判断,如果请求地址匹配,则不进行日志收集,直接返回原有的fetch,否则对请求和响应进行处理并进行日志收集。其中:

  1. 在请求过程中,我们通过传入的options参数判断请求是否需要被拦截并进行日志收集,并把请求数据以及初始的fetch函数参数传递进入then方法以处理响应。对于不需要被处理的请求,我们直接返回原有的fetch函数。
  2. 在响应过程中,我们计算持续时间,并把收集到的数据发送到后台。对于发生错误的请求,我们记录错误响应体,并用throw将错误继续向下传递。

至此,完成了用fetch实现请求日志收集服务的功能。

import tracker from '../utils/tracker';

/**
 * 重写fetch函数
 */
function fetchInterceptor(fetchFunc) {
  return function (url, options) {
    // 过滤掉和日志收集服务以及websocket通信相关的请求
    if (!url.match(/logstores/) && !url.match(/sockjs/)) {
      return fetchFunc.apply(this, arguments).then(res => {
        // 记录发送前的时间
        const startTime = Date.now();
        // 获取XHR的状态码和状态信息
        const status = res.status; // 200 or 500
        const statusText = res.statusText; // ok or Server Error
        // 计算持续时间
        const duration = Date.now() - startTime;
        // 发送请求日志
        tracker.send({
          kind: 'stability', // 稳定性
          type: 'fetch', // fetch请求
          eventType: 'success', // 请求的事件类型(success/error/abort)
          pathname: url, // 请求的路径
          status: `${status}-${statusText}`, // 状态码和状态信息
          duration, // 请求持续时间
          params: options.body ? options.body : '', // 发送的参数
          response: res.text() // 响应体(转成text()后再处理)
        });
        return res;
      }).catch(error => {
        // 记录发送前的时间
        const startTime = Date.now();
        // 获取XHR的状态码和状态信息
        const status = error.response ? error.response.status : '';
        const statusText = error.response ? error.response.statusText : '';
        // 计算持续时间
        const duration = Date.now() - startTime;
        // 发送请求日志
        tracker.send({
          kind: 'stability', // 稳定性
          type: 'fetch', // fetch请求
          eventType: 'error', // 请求的事件类型(success/error/abort)
          pathname: url, // 请求的路径
          status: `${status}-${statusText}`, // 状态码和状态信息
          duration, // 请求持续时间
          params: options.body ? options.body : '', // 发送的参数
          response: error.response ? error.response.text() : '' // 错误响应体(转成text()后再处理)
        });
        throw error;
      });
    } else {
      // 如果匹配成功,直接返回原有的fetch,不进行日志收集
      return fetchFunc.apply(this, arguments);
    }
  }
}

// 重新定义fetch
window.fetch = fetchInterceptor(window.fetch);

白屏

白屏就是页面中什么都没有

数据设计

{
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590822618759",
  "userAgent": "chrome",
  "kind": "stability", //大类
  "type": "blank", //小类
  "emptyPoints": "0", //空白点
  "screen": "2049x1152", //分辨率
  "viewPoint": "2048x994", //视口
  "selector": "HTML BODY #container" //选择器
}

实现

  • elementsFromPoint方法可以获取到当前视口内指定坐标处,由里到外排列的所有元素
  • 根据 elementsFromPoint api,获取屏幕水平中线和竖直中线所在的元素

这段代码的功能是检测页面是否为空白,如果页面为空白则将相关信息发送给tracker,用于监控网站稳定性。具体实现逻辑如下:

  1. 引入tracker和onload模块。
  2. 定义blankScreen函数,该函数用于检测页面是否为空白。
  3. 在函数中定义一个wrapperElement数组,保存需要检测的节点。
  4. 定义emptyPoints变量,用于记录空白点数量。
  5. 定义getSelector函数,用于获取元素选择器。
  6. 定义isWrapper函数,用于检测元素节点是否在wrapperElement中。
  7. 在onload函数中,遍历屏幕10行10列的地方,并检查每个点处的元素是否wrapper,如果是则将空白点数量加1。
  8. 如果空白点数量大于等于18,则认为页面是空白的,将页面信息以及空白点数量发送给tracker。
  9. 最后,blankScreen函数的作用就是检测页面是否为空白,如果是则将相关信息发送给tracker,用于监控网站稳定性。
// 引入tracker和onload模块
import tracker from "../utils/tracker";
import {onload} from '../utils/onload'
// 定义blankScreen函数
export function blankScreen(){
// 定义wrapperElement数组,保存需要检测的节点
let wrapperElement = ['html', 'body', '#container', '.content'];
let emptyPoints = 0; // 定义空白点数量的变量
// 定义获取元素选择器的函数
function getSelector(element){
    if(element.id){
        return '#' + element.id;
    }else if(element.className){  // 如果有类名,则将类名拼接成选择器
        return '.' + element.className.split(' ').filter(item => !!item).join('.');
    }else{  // 否则,返回元素节点名如div, p等
        return element.nodeName.toLowerCase();
    }
}
// 定义是否wrapper的函数,用来检测元素节点是否在wrapperElement中
function isWrapper(element){
    let selector = getSelector(element);
    if(wrapperElement.indexOf(selector) != -1){  // 判断是否在wrapperElement中
        emptyPoints++; // 是wrapperElement则将空白点数量加1
    }
}
// 当页面加载完成后进行检测
onload(function(){
    // 遍历屏幕10行10列的地方,并检查每个点处的元素是否wrapper
    for(let i = 1; i <= 9; i++){
        let xElements = document.elementsFromPoint( window.innerWidth * i / 10, window.innerHeight / 2);
        let yElements = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight * i / 10);
        console.log('sss',xElements[0])
        isWrapper(xElements[0])
        isWrapper(yElements[0])
    }
    // 如果空白点数量大于等于18,则认为页面是空白的
    console.log('emptyPoints',emptyPoints)
    if(emptyPoints >= 18){
        // 将页面信息以及空白点数量发送给tracker
        let centerElements = document.elementsFromPoint( window.innerWidth / 2, window.innerHeight /2);
        const data = {
            kind:"stability",
            type:"blank",
            emptyPoints,
            screen:window.screen.width + "X" + window.screen.height,
            viewPoint:window.innerHeight + "X" + window.innerHeight,
            selector:getSelector(centerElements[0])
        }
        tracker.send(data)
    };
})
}

加载时间

阶段含义

图片.png

字段含义
navigationStart初始化页面,在同一个浏览器上下文中前一个页面unload的时间戳,如果没有前一个页面的unload,则与fetchStart值相等
redirectStart第一个HTTP重定向发生的时间,有跳转且是同域的重定向,否则为0
redirectEnd最后一个重定向完成时的时间,否则为0
fetchStart浏览器准备好使用http请求获取文档的时间,这发生在检查缓存之前
domainLookupStartDNS域名开始查询的时间,如果有本地的缓存或keep-alive则时间为0
domainLookupEndDNS域名结束查询的时间
connectStartTCP开始建立连接的时间,如果是持久连接,则与fetchStart值相等
secureConnectionStarthttps 连接开始的时间,如果不是安全连接则为0
connectEndTCP完成握手的时间,如果是持久连接则与fetchStart值相等
requestStartHTTP请求读取真实文档开始的时间,包括从本地缓存读取
requestEndHTTP请求读取真实文档结束的时间,包括从本地缓存读取
responseStart返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的Unix毫秒时间戳
responseEnd返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时的Unix毫秒时间戳
unloadEventStart前一个页面的unload的时间戳 如果没有则为0
unloadEventEndunloadEventStart相对应,返回的是unload函数执行完成的时间戳
domLoading返回当前网页DOM结构开始解析时的时间戳,此时document.readyState变成loading,并将抛出readyStateChange事件
domInteractive返回当前网页DOM结构结束解析、开始加载内嵌资源时时间戳,document.readyState 变成interactive,并将抛出readyStateChange事件(注意只是DOM树解析完成,这时候并没有开始加载网页内的资源)
domContentLoadedEventStart网页domContentLoaded事件发生的时间
domContentLoadedEventEnd网页domContentLoaded事件脚本执行完毕的时间,domReady的时间
domCompleteDOM树解析完成,且资源也准备就绪的时间,document.readyState变成complete.并将抛出readystatechange事件
loadEventStartload 事件发送给文档,也即load回调函数开始执行的时间
loadEventEndload回调函数执行完成的时间

阶段计算

字段描述计算方式意义
unload前一个页面卸载耗时unloadEventEnd – unloadEventStart-
redirect重定向耗时redirectEnd – redirectStart重定向的时间
appCache缓存耗时domainLookupStart – fetchStart读取缓存的时间
dnsDNS 解析耗时domainLookupEnd – domainLookupStart可观察域名解析服务是否正常
tcpTCP 连接耗时connectEnd – connectStart建立连接的耗时
sslSSL 安全连接耗时connectEnd – secureConnectionStart反映数据安全连接建立耗时
ttfbTime to First Byte(TTFB)网络请求耗时responseStart – requestStartTTFB是发出页面请求到接收到应答数据第一个字节所花费的毫秒数
response响应数据传输耗时responseEnd – responseStart观察网络是否正常
domDOM解析耗时domInteractive – responseEnd观察DOM结构是否合理,是否有JS阻塞页面解析
dclDOMContentLoaded 事件耗时domContentLoadedEventEnd – domContentLoadedEventStart当 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,无需等待样式表、图像和子框架的完成加载
resources资源加载耗时domComplete – domContentLoadedEventEnd可观察文档流是否过大
domReadyDOM阶段渲染耗时domContentLoadedEventEnd – fetchStartDOM树和页面资源加载完成时间,会触发domContentLoaded事件
首次渲染耗时首次渲染耗时responseEnd-fetchStart加载文档到看到第一帧非空图像的时间,也叫白屏时间
首次可交互时间首次可交互时间domInteractive-fetchStartDOM树解析完成时间,此时document.readyState为interactive
首包时间耗时首包时间responseStart-domainLookupStartDNS解析到响应返回给浏览器第一个字节的时间
页面完全加载时间页面完全加载时间loadEventStart - fetchStart-
onLoadonLoad事件耗时loadEventEnd – loadEventStart

图片.png

数据结构

{
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590828364183",
  "userAgent": "chrome",
  "kind": "experience",
  "type": "timing",
  "connectTime": "0",
  "ttfbTime": "1",
  "responseTime": "1",
  "parseDOMTime": "80",
  "domContentLoadedTime": "0",
  "timeToInteractive": "88",
  "loadTime": "89"
}

加载时间代码实现

import tracker from "../utils/tracker";
// 在页面加载完毕3秒后执行性能数据收集
function sendPerformanceData() {
    setTimeout(() => {
        // 获取Navigation Timing信息
        const navigationTiming = performance.getEntriesByType("navigation")[0].toJSON();
        // 对Navigation Timing进行解构
        const {
            fetchStart,
            connectStart,
            connectEnd,
            requestStart,
            responseStart,
            responseEnd,
            domLoading,
            domInteractive,
            domContentLoadedEventStart,
            domContentLoadedEventEnd,
            loadEventStart
        } = navigationTiming;
        // 获取首次绘制指标:first-paint
        const { startTime: firstPaint } = performance.getEntriesByName('first-paint')[0] || {};
        // 获取首次有意义绘制指标:first-contentful-paint
        const { startTime: firstContentfulPaint } = performance.getEntriesByName("first-contentful-paint")[0] || {};
        // 获取页面最大内容绘制指标:largest-contentful-paint
        const { startTime: largestContentFulPaint } = performance.getEntriesByName("largest-contentful-paint")[0] || {};
        // 对获取的各个指标的值进行处理
        const connectTime = connectEnd - connectStart;
        const ttfdTime = responseStart - requestStart;
        const responseTime = responseEnd - requestStart;
        const parseDOMTime = loadEventStart - domLoading;
        const domContentLoadedTime = domContentLoadedEventEnd - domContentLoadedEventStart;
        const timeToInteractive = domInteractive - fetchStart;
        const loadTime = loadEventStart - fetchStart;
        // 判断tracker是否可用,若可用则发送数据
        if (typeof tracker !== "undefined") {
            // 发送用户体验指标数据
            tracker.send({
                kind: "experience", // 用户体验指标
                type: "timer", // 统计每个阶段时间
                connectTime, // 链接时间
                ttfdTime, // 首字节到达时间
                responseTime, // 响应读取时间
                parseDOMTime, // DOM 解析时间
                domContentLoadedTime, // DOM内容加载完成时间
                timeToInteractive, // 首次可交互时间
                loadTime // 完整的加载时间
            });
            // 发送用户体验指标:首次绘制指标、首次有意义绘制指标和最大内容绘制指标
            tracker.send({
                kind: "experience", // 用户体验指标
                type: "paint", // 统计每个阶段的时间
                firstPaint, // 首次绘制时间
                firstContentfulPaint, // 首次有意义绘制时间
                largestContentFulPaint // 最大内容绘制时间
            });
        }
    }, 3000);
};