一个残缺的前端监控平台包含三个局部:数据采集与上报、数据整顿和存储、数据展现。

本文要讲的就是其中的第一个环节——数据采集与上报。下图是本文要讲述内容的纲要,大家能够先大抵理解一下:

仅看理论知识是比拟难以了解的,为此我联合本文要讲的技术要点写了一个简略的监控 SDK,能够用它来写一些简略的 DEMO,帮忙加深了解。再联合本文一起浏览,成果更好。

性能数据采集

chrome 开发团队提出了一系列用于检测网页性能的指标:

  • FP(first-paint),从页面加载开始到第一个像素绘制到屏幕上的工夫
  • FCP(first-contentful-paint),从页面加载开始到页面内容的任何局部在屏幕上实现渲染的工夫
  • LCP(largest-contentful-paint),从页面加载开始到最大文本块或图像元素在屏幕上实现渲染的工夫
  • CLS(layout-shift),从页面加载开始和其生命周期状态变为暗藏期间产生的所有意外布局偏移的累积分数

这四个性能指标都须要通过 PerformanceObserver 来获取(也能够通过 performance.getEntriesByName() 获取,但它不是在事件触发时告诉的)。PerformanceObserver 是一个性能监测对象,用于监测性能度量事件。

FP

FP(first-paint),从页面加载开始到第一个像素绘制到屏幕上的工夫。其实把 FP 了解成白屏工夫也是没问题的。

测量代码如下:

const entryHandler = (list) => {            for (const entry of list.getEntries()) {        if (entry.name === 'first-paint') {            observer.disconnect()        }       console.log(entry)    }}const observer = new PerformanceObserver(entryHandler)// buffered 属性示意是否察看缓存数据,也就是说察看代码增加机会比事件触发机会晚也没关系。observer.observe({ type: 'paint', buffered: true })

通过以上代码能够失去 FP 的内容:

{    duration: 0,    entryType: "paint",    name: "first-paint",    startTime: 359, // fp 工夫}

其中 startTime 就是咱们要的绘制工夫。

FCP

FCP(first-contentful-paint),从页面加载开始到页面内容的任何局部在屏幕上实现渲染的工夫。对于该指标,"内容"指的是文本、图像(包含背景图像)、<svg>元素或非红色的<canvas>元素。

为了提供良好的用户体验,FCP 的分数应该管制在 1.8 秒以内。

测量代码:

const entryHandler = (list) => {            for (const entry of list.getEntries()) {        if (entry.name === 'first-contentful-paint') {            observer.disconnect()        }                console.log(entry)    }}const observer = new PerformanceObserver(entryHandler)observer.observe({ type: 'paint', buffered: true })

通过以上代码能够失去 FCP 的内容:

{    duration: 0,    entryType: "paint",    name: "first-contentful-paint",    startTime: 459, // fcp 工夫}

其中 startTime 就是咱们要的绘制工夫。

LCP

LCP(largest-contentful-paint),从页面加载开始到最大文本块或图像元素在屏幕上实现渲染的工夫。LCP 指标会依据页面首次开始加载的工夫点来报告可视区域内可见的最大图像或文本块实现渲染的绝对工夫。

一个良好的 LCP 分数应该管制在 2.5 秒以内。

测量代码:

const entryHandler = (list) => {    if (observer) {        observer.disconnect()    }    for (const entry of list.getEntries()) {        console.log(entry)    }}const observer = new PerformanceObserver(entryHandler)observer.observe({ type: 'largest-contentful-paint', buffered: true })

通过以上代码能够失去 LCP 的内容:

{    duration: 0,    element: p,    entryType: "largest-contentful-paint",    id: "",    loadTime: 0,    name: "",    renderTime: 1021.299,    size: 37932,    startTime: 1021.299,    url: "",}

其中 startTime 就是咱们要的绘制工夫。element 是指 LCP 绘制的 DOM 元素。

FCP 和 LCP 的区别是:FCP 只有任意内容绘制实现就触发,LCP 是最大内容渲染实现时触发。

LCP 考查的元素类型为:

  • <img>元素
  • 内嵌在<svg>元素内的<image>元素
  • <video>元素(应用封面图像)
  • 通过url())函数(而非应用CSS 突变)加载的带有背景图像的元素
  • 蕴含文本节点或其余行内级文本元素子元素的块级元素。

CLS

CLS(layout-shift),从页面加载开始和其生命周期状态变为暗藏期间产生的所有意外布局偏移的累积分数。

布局偏移分数的计算形式如下:

布局偏移分数 = 影响分数 * 间隔分数

影响分数测量不稳固元素对两帧之间的可视区域产生的影响。

间隔分数指的是任何不稳固元素在一帧中位移的最大间隔(程度或垂直)除以可视区域的最大尺寸维度(宽度或高度,以较大者为准)。

CLS 就是把所有布局偏移分数加起来的总和

当一个 DOM 在两个渲染帧之间产生了位移,就会触发 CLS(如图所示)。

上图中的矩形从左上角挪动到了左边,这就算是一次布局偏移。同时,在 CLS 中,有一个叫会话窗口的术语:一个或多个疾速间断产生的单次布局偏移,每次偏移相隔的工夫少于 1 秒,且整个窗口的最大继续时长为 5 秒。

例如上图中的第二个会话窗口,它外面有四次布局偏移,每一次偏移之间的距离必须少于 1 秒,并且第一个偏移和最初一个偏移之间的工夫不能超过 5 秒,这样能力算是一次会话窗口。如果不合乎这个条件,就算是一个新的会话窗口。可能有人会问,为什么要这样规定?其实这是 chrome 团队依据大量的试验和钻研得出的剖析后果 Evolving the CLS metric。

CLS 一共有三种计算形式:

  1. 累加
  2. 取所有会话窗口的平均数
  3. 取所有会话窗口中的最大值

累加

也就是把从页面加载开始的所有布局偏移分数加在一起。然而这种计算形式对生命周期长的页面不敌对,页面存留工夫越长,CLS 分数越高。

取所有会话窗口的平均数

这种计算形式不是按单个布局偏移为单位,而是以会话窗口为单位。将所有会话窗口的值相加再取平均值。然而这种计算形式也有毛病。

从上图能够看进去,第一个会话窗口产生了比拟大的 CLS 分数,第二个会话窗口产生了比拟小的 CLS 分数。如果取它们的平均值来当做 CLS 分数,则基本看不出来页面的运行状况。原来页面是晚期偏移多,前期偏移少,当初的平均值无奈反映出这种状况。

取所有会话窗口中的最大值

这种形式是目前最优的计算形式,每次只取所有会话窗口的最大值,用来反映页面布局偏移的最差状况。详情请看 Evolving the CLS metric。

上面是第三种计算形式的测量代码:

let sessionValue = 0let sessionEntries = []const cls = {    subType: 'layout-shift',    name: 'layout-shift',    type: 'performance',    pageURL: getPageURL(),    value: 0,}const entryHandler = (list) => {    for (const entry of list.getEntries()) {        // Only count layout shifts without recent user input.        if (!entry.hadRecentInput) {            const firstSessionEntry = sessionEntries[0]            const lastSessionEntry = sessionEntries[sessionEntries.length - 1]            // If the entry occurred less than 1 second after the previous entry and            // less than 5 seconds after the first entry in the session, include the            // entry in the current session. Otherwise, start a new session.            if (                sessionValue                && entry.startTime - lastSessionEntry.startTime < 1000                && entry.startTime - firstSessionEntry.startTime < 5000            ) {                sessionValue += entry.value                sessionEntries.push(formatCLSEntry(entry))            } else {                sessionValue = entry.value                sessionEntries = [formatCLSEntry(entry)]            }            // If the current session value is larger than the current CLS value,            // update CLS and the entries contributing to it.            if (sessionValue > cls.value) {                cls.value = sessionValue                cls.entries = sessionEntries                cls.startTime = performance.now()                lazyReportCache(deepCopy(cls))            }        }    }}const observer = new PerformanceObserver(entryHandler)observer.observe({ type: 'layout-shift', buffered: true })

在看完下面的文字描述后,再看代码就好了解了。一次布局偏移的测量内容如下:

{  duration: 0,  entryType: "layout-shift",  hadRecentInput: false,  lastInputTime: 0,  name: "",  sources: (2) [LayoutShiftAttribution, LayoutShiftAttribution],  startTime: 1176.199999999255,  value: 0.000005752046026677329,}

代码中的 value 字段就是布局偏移分数。

DOMContentLoaded、load 事件

当纯 HTML 被齐全加载以及解析时,DOMContentLoaded 事件会被触发,不必期待 css、img、iframe 加载完。

当整个页面及所有依赖资源如样式表和图片都已实现加载时,将触发 load 事件。

尽管这两个性能指标比拟旧了,然而它们依然能反映页面的一些状况。对于它们进行监听依然是必要的。

import { lazyReportCache } from '../utils/report'['load', 'DOMContentLoaded'].forEach(type => onEvent(type))function onEvent(type) {    function callback() {        lazyReportCache({            type: 'performance',            subType: type.toLocaleLowerCase(),            startTime: performance.now(),        })        window.removeEventListener(type, callback, true)    }    window.addEventListener(type, callback, true)}

首屏渲染工夫

大多数状况下,首屏渲染工夫能够通过 load 事件获取。除了一些非凡状况,例如异步加载的图片和 DOM。

<script>    setTimeout(() => {        document.body.innerHTML = `            <div>                <!-- 省略一堆代码... -->            </div>        `    }, 3000)</script>

像这种状况就无奈通过 load 事件获取首屏渲染工夫了。这时咱们须要通过 MutationObserver 来获取首屏渲染工夫。MutationObserver 在监听的 DOM 元素属性发生变化时会触发事件。

首屏渲染工夫计算过程:

  1. 利用 MutationObserver 监听 document 对象,每当 DOM 元素属性产生变更时,触发事件。
  2. 判断该 DOM 元素是否在首屏内,如果在,则在 requestAnimationFrame() 回调函数中调用 performance.now() 获取以后工夫,作为它的绘制工夫。
  3. 将最初一个 DOM 元素的绘制工夫和首屏中所有加载的图片工夫作比照,将最大值作为首屏渲染工夫。

监听 DOM

const next = window.requestAnimationFrame ? requestAnimationFrame : setTimeoutconst ignoreDOMList = ['STYLE', 'SCRIPT', 'LINK']    observer = new MutationObserver(mutationList => {    const entry = {        children: [],    }    for (const mutation of mutationList) {        if (mutation.addedNodes.length && isInScreen(mutation.target)) {             // ...        }    }    if (entry.children.length) {        entries.push(entry)        next(() => {            entry.startTime = performance.now()        })    }})observer.observe(document, {    childList: true,    subtree: true,})

下面的代码就是监听 DOM 变动的代码,同时须要过滤掉 stylescriptlink 等标签。

判断是否在首屏

一个页面的内容可能十分多,但用户最多只能看见一屏幕的内容。所以在统计首屏渲染工夫的时候,须要限定范畴,把渲染内容限定在以后屏幕内。

const viewportWidth = window.innerWidthconst viewportHeight = window.innerHeight// dom 对象是否在屏幕内function isInScreen(dom) {    const rectInfo = dom.getBoundingClientRect()    if (rectInfo.left < viewportWidth && rectInfo.top < viewportHeight) {        return true    }    return false}

应用 requestAnimationFrame() 获取 DOM 绘制工夫

当 DOM 变更触发 MutationObserver 事件时,只是代表 DOM 内容能够被读取到,并不代表该 DOM 被绘制到了屏幕上。

从上图能够看出,当触发 MutationObserver 事件时,能够读取到 document.body 上曾经有内容了,但实际上右边的屏幕并没有绘制任何内容。所以要调用 requestAnimationFrame() 在浏览器绘制胜利后再获取以后工夫作为 DOM 绘制工夫。

和首屏内的所有图片加载工夫作比照

function getRenderTime() {    let startTime = 0    entries.forEach(entry => {        if (entry.startTime > startTime) {            startTime = entry.startTime        }    })    // 须要和以后页面所有加载图片的工夫做比照,取最大值    // 图片申请工夫要小于 startTime,响应完结工夫要大于 startTime    performance.getEntriesByType('resource').forEach(item => {        if (            item.initiatorType === 'img'            && item.fetchStart < startTime             && item.responseEnd > startTime        ) {            startTime = item.responseEnd        }    })        return startTime}

优化

当初的代码还没优化完,次要有两点注意事项:

  1. 什么时候上报渲染工夫?
  2. 如果兼容异步增加 DOM 的状况?

第一点,必须要在 DOM 不再变动后再上报渲染工夫,个别 load 事件触发后,DOM 就不再变动了。所以咱们能够在这个工夫点进行上报。

第二点,能够在 LCP 事件触发后再进行上报。不论是同步还是异步加载的 DOM,它都须要进行绘制,所以能够监听 LCP 事件,在该事件触发后才容许进行上报。

将以上两点计划联合在一起,就有了以下代码:

let isOnLoaded = falseexecuteAfterLoad(() => {    isOnLoaded = true})let timerlet observerfunction checkDOMChange() {    clearTimeout(timer)    timer = setTimeout(() => {        // 等 load、lcp 事件触发后并且 DOM 树不再变动时,计算首屏渲染工夫        if (isOnLoaded && isLCPDone()) {            observer && observer.disconnect()            lazyReportCache({                type: 'performance',                subType: 'first-screen-paint',                startTime: getRenderTime(),                pageURL: getPageURL(),            })            entries = null        } else {            checkDOMChange()        }    }, 500)}

checkDOMChange() 代码每次在触发 MutationObserver 事件时进行调用,须要用防抖函数进行解决。

接口申请耗时

接口申请耗时须要对 XMLHttpRequest 和 fetch 进行监听。

监听 XMLHttpRequest

originalProto.open = function newOpen(...args) {    this.url = args[1]    this.method = args[0]    originalOpen.apply(this, args)}originalProto.send = function newSend(...args) {    this.startTime = Date.now()    const onLoadend = () => {        this.endTime = Date.now()        this.duration = this.endTime - this.startTime        const { status, duration, startTime, endTime, url, method } = this        const reportData = {            status,            duration,            startTime,            endTime,            url,            method: (method || 'GET').toUpperCase(),            success: status >= 200 && status < 300,            subType: 'xhr',            type: 'performance',        }        lazyReportCache(reportData)        this.removeEventListener('loadend', onLoadend, true)    }    this.addEventListener('loadend', onLoadend, true)    originalSend.apply(this, args)}

如何判断 XML 申请是否胜利?能够依据他的状态码是否在 200~299 之间。如果在,那就是胜利,否则失败。

监听 fetch

const originalFetch = window.fetchfunction overwriteFetch() {    window.fetch = function newFetch(url, config) {        const startTime = Date.now()        const reportData = {            startTime,            url,            method: (config?.method || 'GET').toUpperCase(),            subType: 'fetch',            type: 'performance',        }        return originalFetch(url, config)        .then(res => {            reportData.endTime = Date.now()            reportData.duration = reportData.endTime - reportData.startTime            const data = res.clone()            reportData.status = data.status            reportData.success = data.ok            lazyReportCache(reportData)            return res        })        .catch(err => {            reportData.endTime = Date.now()            reportData.duration = reportData.endTime - reportData.startTime            reportData.status = 0            reportData.success = false            lazyReportCache(reportData)            throw err        })    }}

对于 fetch,能够依据返回数据中的的 ok 字段判断申请是否胜利,如果为 true 则申请胜利,否则失败。

留神,监听到的接口申请工夫和 chrome devtool 上检测到的工夫可能不一样。这是因为 chrome devtool 上检测到的是 HTTP 申请发送和接口整个过程的工夫。然而 xhr 和 fetch 是异步申请,接口申请胜利后须要调用回调函数。事件触发时会把回调函数放到音讯队列,而后浏览器再解决,这两头也有一个期待过程。

资源加载工夫、缓存命中率

通过 PerformanceObserver 能够监听 resourcenavigation 事件,如果浏览器不反对 PerformanceObserver,还能够通过 performance.getEntriesByType(entryType) 来进行降级解决。

resource 事件触发时,能够获取到对应的资源列表,每个资源对象蕴含以下一些字段:

从这些字段中咱们能够提取到一些有用的信息:

{    name: entry.name, // 资源名称    subType: entryType,    type: 'performance',    sourceType: entry.initiatorType, // 资源类型    duration: entry.duration, // 资源加载耗时    dns: entry.domainLookupEnd - entry.domainLookupStart, // DNS 耗时    tcp: entry.connectEnd - entry.connectStart, // 建设 tcp 连贯耗时    redirect: entry.redirectEnd - entry.redirectStart, // 重定向耗时    ttfb: entry.responseStart, // 首字节工夫    protocol: entry.nextHopProtocol, // 申请协定    responseBodySize: entry.encodedBodySize, // 响应内容大小    responseHeaderSize: entry.transferSize - entry.encodedBodySize, // 响应头部大小    resourceSize: entry.decodedBodySize, // 资源解压后的大小    isCache: isCache(entry), // 是否命中缓存    startTime: performance.now(),}

判断该资源是否命中缓存

在这些资源对象中有一个 transferSize 字段,它示意获取资源的大小,包含响应头字段和响应数据的大小。如果这个值为 0,阐明是从缓存中间接读取的(强制缓存)。如果这个值不为 0,然而 encodedBodySize 字段为 0,阐明它走的是协商缓存(encodedBodySize 示意申请响应数据 body 的大小)。

function isCache(entry) {    // 间接从缓存读取或 304    return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0)}

不合乎以上条件的,阐明未命中缓存。而后将所有命中缓存的数据/总数据就能得出缓存命中率。

浏览器往返缓存 BFC(back/forward cache)

bfcache 是一种内存缓存,它会将整个页面保留在内存中。当用户返回时能够马上看到整个页面,而不必再次刷新。据该文章 bfcache 介绍,firfox 和 safari 始终反对 bfc,chrome 只有在高版本的挪动端浏览器反对。但我试了一下,只有 safari 浏览器反对,可能我的 firfox 版本不对。

然而 bfc 也是有毛病的,当用户返回并从 bfc 中复原页面时,原来页面的代码不会再次执行。为此,浏览器提供了一个 pageshow 事件,能够把须要再次执行的代码放在外面。

window.addEventListener('pageshow', function(event) {  // 如果该属性为 true,示意是从 bfc 中复原的页面  if (event.persisted) {    console.log('This page was restored from the bfcache.');  } else {    console.log('This page was loaded normally.');  }});

从 bfc 中复原的页面,咱们也须要收集他们的 FP、FCP、LCP 等各种工夫。

onBFCacheRestore(event => {    requestAnimationFrame(() => {        ['first-paint', 'first-contentful-paint'].forEach(type => {            lazyReportCache({                startTime: performance.now() - event.timeStamp,                name: type,                subType: type,                type: 'performance',                pageURL: getPageURL(),                bfc: true,            })        })    })})

下面的代码很好了解,在 pageshow 事件触发后,用以后工夫减去事件触发工夫,这个工夫差值就是性能指标的绘制工夫。留神,从 bfc 中复原的页面的这些性能指标,值个别都很小,个别在 10 ms 左右。所以要给它们加个标识字段 bfc: true。这样在做性能统计时能够对它们进行疏忽。

FPS

利用 requestAnimationFrame() 咱们能够计算以后页面的 FPS。

const next = window.requestAnimationFrame     ? requestAnimationFrame : (callback) => { setTimeout(callback, 1000 / 60) }const frames = []export default function fps() {    let frame = 0    let lastSecond = Date.now()    function calculateFPS() {        frame++        const now = Date.now()        if (lastSecond + 1000 <= now) {            // 因为 now - lastSecond 的单位是毫秒,所以 frame 要 * 1000            const fps = Math.round((frame * 1000) / (now - lastSecond))            frames.push(fps)                            frame = 0            lastSecond = now        }            // 防止上报太快,缓存肯定数量再上报        if (frames.length >= 60) {            report(deepCopy({                frames,                type: 'performace',                subType: 'fps',            }))                frames.length = 0        }        next(calculateFPS)    }    calculateFPS()}

代码逻辑如下:

  1. 先记录一个初始工夫,而后每次触发 requestAnimationFrame() 时,就将帧数加 1。过来一秒后用帧数/流逝的工夫就能失去以后帧率。

当间断三个低于 20 的 FPS 呈现时,咱们能够判定页面呈现了卡顿,详情请看 如何监控网页的卡顿。

export function isBlocking(fpsList, below = 20, last = 3) {    let count = 0    for (let i = 0; i < fpsList.length; i++) {        if (fpsList[i] && fpsList[i] < below) {            count++        } else {            count = 0        }        if (count >= last) {            return true        }    }    return false}

Vue 路由变更渲染工夫

首屏渲染工夫咱们曾经晓得如何计算了,然而如何计算 SPA 利用的页面路由切换导致的页面渲染工夫呢?本文用 Vue 作为示例,讲一下我的思路。

export default function onVueRouter(Vue, router) {    let isFirst = true    let startTime    router.beforeEach((to, from, next) => {        // 首次进入页面曾经有其余统计的渲染工夫可用        if (isFirst) {            isFirst = false            return next()        }        // 给 router 新增一个字段,示意是否要计算渲染工夫        // 只有路由跳转才须要计算        router.needCalculateRenderTime = true        startTime = performance.now()        next()    })    let timer    Vue.mixin({        mounted() {            if (!router.needCalculateRenderTime) return            this.$nextTick(() => {                // 仅在整个视图都被渲染之后才会运行的代码                const now = performance.now()                clearTimeout(timer)                timer = setTimeout(() => {                    router.needCalculateRenderTime = false                    lazyReportCache({                        type: 'performance',                        subType: 'vue-router-change-paint',                        duration: now - startTime,                        startTime: now,                        pageURL: getPageURL(),                    })                }, 1000)            })        },    })}

代码逻辑如下:

  1. 监听路由钩子,在路由切换时会触发 router.beforeEach() 钩子,在该钩子的回调函数里将以后工夫记为渲染开始工夫。
  2. 利用 Vue.mixin() 对所有组件的 mounted() 注入一个函数。每个函数都执行一个防抖函数。
  3. 当最初一个组件的 mounted() 触发时,就代表该路由下的所有组件曾经挂载结束。能够在 this.$nextTick() 回调函数中获取渲染工夫。

同时,还要思考到一个状况。不切换路由时,也会有变更组件的状况,这时不应该在这些组件的 mounted() 里进行渲染工夫计算。所以须要增加一个 needCalculateRenderTime 字段,当切换路由时将它设为 true,代表能够计算渲染工夫了。

谬误数据采集

资源加载谬误

应用 addEventListener() 监听 error 事件,能够捕捉到资源加载失败谬误。

// 捕捉资源加载失败谬误 js css img...window.addEventListener('error', e => {    const target = e.target    if (!target) return    if (target.src || target.href) {        const url = target.src || target.href        lazyReportCache({            url,            type: 'error',            subType: 'resource',            startTime: e.timeStamp,            html: target.outerHTML,            resourceType: target.tagName,            paths: e.path.map(item => item.tagName).filter(Boolean),            pageURL: getPageURL(),        })    }}, true)

js 谬误

应用 window.onerror 能够监听 js 谬误。

// 监听 js 谬误window.onerror = (msg, url, line, column, error) => {    lazyReportCache({        msg,        line,        column,        error: error.stack,        subType: 'js',        pageURL: url,        type: 'error',        startTime: performance.now(),    })}

promise 谬误

应用 addEventListener() 监听 unhandledrejection 事件,能够捕捉到未解决的 promise 谬误。

// 监听 promise 谬误 毛病是获取不到列数据window.addEventListener('unhandledrejection', e => {    lazyReportCache({        reason: e.reason?.stack,        subType: 'promise',        type: 'error',        startTime: e.timeStamp,        pageURL: getPageURL(),    })})

sourcemap

个别生产环境的代码都是通过压缩的,并且生产环境不会把 sourcemap 文件上传。所以生产环境上的代码报错信息是很难读的。因而,咱们能够利用 source-map 来对这些压缩过的代码报错信息进行还原。

当代码报错时,咱们能够获取到对应的文件名、行数、列数:

{    line: 1,    column: 17,    file: 'https:/www.xxx.com/bundlejs',}

而后调用上面的代码进行还原:

async function parse(error) {    const mapObj = JSON.parse(getMapFileContent(error.url))    const consumer = await new sourceMap.SourceMapConsumer(mapObj)    // 将 webpack://source-map-demo/./src/index.js 文件中的 ./ 去掉    const sources = mapObj.sources.map(item => format(item))    // 依据压缩后的报错信息得出未压缩前的报错行列数和源码文件    const originalInfo = consumer.originalPositionFor({ line: error.line, column: error.column })    // sourcesContent 中蕴含了各个文件的未压缩前的源码,依据文件名找出对应的源码    const originalFileContent = mapObj.sourcesContent[sources.indexOf(originalInfo.source)]    return {        file: originalInfo.source,        content: originalFileContent,        line: originalInfo.line,        column: originalInfo.column,        msg: error.msg,        error: error.error    }}function format(item) {    return item.replace(/(\.\/)*/g, '')}function getMapFileContent(url) {    return fs.readFileSync(path.resolve(__dirname, `./maps/${url.split('/').pop()}.map`), 'utf-8')}

每次我的项目打包时,如果开启了 sourcemap,那么每一个 js 文件都会有一个对应的 map 文件。

bundle.jsbundle.js.map

这时 js 文件放在动态服务器上供用户拜访,map 文件存储在服务器,用于还原错误信息。source-map 库能够依据压缩过的代码报错信息还原出未压缩前的代码报错信息。例如压缩后报错地位为 1 行 47 列,还原后真正的地位可能为 4 行 10 列。除了地位信息,还能够获取到源码原文。

上图就是一个代码报错还原后的示例。鉴于这部分内容不属于 SDK 的范畴,所以我另开了一个 仓库 来做这个事,有趣味能够看看。

Vue 谬误

利用 window.onerror 是捕捉不到 Vue 谬误的,它须要应用 Vue 提供的 API 进行监听。

Vue.config.errorHandler = (err, vm, info) => {    // 将报错信息打印到控制台    console.error(err)    lazyReportCache({        info,        error: err.stack,        subType: 'vue',        type: 'error',        startTime: performance.now(),        pageURL: getPageURL(),    })}

行为数据采集

PV、UV

PV(page view) 是页面浏览量,UV(Unique visitor)用户访问量。PV 只有拜访一次页面就算一次,UV 同一天内屡次拜访只算一次。

对于前端来说,只有每次进入页面上报一次 PV 就行,UV 的统计放在服务端来做,次要是剖析上报的数据来统计得出 UV。

export default function pv() {    lazyReportCache({        type: 'behavior',        subType: 'pv',        startTime: performance.now(),        pageURL: getPageURL(),        referrer: document.referrer,        uuid: getUUID(),    })}

页面停留时长

用户进入页面记录一个初始工夫,用户来到页面时用以后工夫减去初始工夫,就是用户停留时长。这个计算逻辑能够放在 beforeunload 事件里做。

export default function pageAccessDuration() {    onBeforeunload(() => {        report({            type: 'behavior',            subType: 'page-access-duration',            startTime: performance.now(),            pageURL: getPageURL(),            uuid: getUUID(),        }, true)    })}

页面拜访深度

记录页面拜访深度是很有用的,例如不同的流动页面 a 和 b。a 均匀拜访深度只有 50%,b 均匀拜访深度有 80%,阐明 b 更受用户喜爱,依据这一点能够有针对性的批改 a 流动页面。

除此之外还能够利用拜访深度以及停留时长来甄别电商刷单。例如有人进来页面后一下就把页面拉到底部而后期待一段时间后购买,有人是缓缓的往下滚动页面,最初再购买。尽管他们在页面的停留时间一样,但显著第一个人更像是刷单的。

页面拜访深度计算过程略微简单一点:

  1. 用户进入页面时,记录以后工夫、scrollTop 值、页面可视高度、页面总高度。
  2. 用户滚动页面的那一刻,会触发 scroll 事件,在回调函数中用第一点失去的数据算出页面拜访深度和停留时长。
  3. 当用户滚动页面到某一点时,停下持续观看页面。这时记录以后工夫、scrollTop 值、页面可视高度、页面总高度。
  4. 反复第二点...

具体代码请看:

let timerlet startTime = 0let hasReport = falselet pageHeight = 0let scrollTop = 0let viewportHeight = 0export default function pageAccessHeight() {    window.addEventListener('scroll', onScroll)    onBeforeunload(() => {        const now = performance.now()        report({            startTime: now,            duration: now - startTime,            type: 'behavior',            subType: 'page-access-height',            pageURL: getPageURL(),            value: toPercent((scrollTop + viewportHeight) / pageHeight),            uuid: getUUID(),        }, true)    })    // 页面加载实现后初始化记录以后拜访高度、工夫    executeAfterLoad(() => {        startTime = performance.now()        pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight        scrollTop = document.documentElement.scrollTop || document.body.scrollTop        viewportHeight = window.innerHeight    })}function onScroll() {    clearTimeout(timer)    const now = performance.now()        if (!hasReport) {        hasReport = true        lazyReportCache({            startTime: now,            duration: now - startTime,            type: 'behavior',            subType: 'page-access-height',            pageURL: getPageURL(),            value: toPercent((scrollTop + viewportHeight) / pageHeight),            uuid: getUUID(),        })    }    timer = setTimeout(() => {        hasReport = false        startTime = now        pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight        scrollTop = document.documentElement.scrollTop || document.body.scrollTop        viewportHeight = window.innerHeight            }, 500)}function toPercent(val) {    if (val >= 1) return '100%'    return (val * 100).toFixed(2) + '%'}

用户点击

利用 addEventListener() 监听 mousedowntouchstart 事件,咱们能够收集用户每一次点击区域的大小,点击坐标在整个页面中的具体位置,点击元素的内容等信息。

export default function onClick() {    ['mousedown', 'touchstart'].forEach(eventType => {        let timer        window.addEventListener(eventType, event => {            clearTimeout(timer)            timer = setTimeout(() => {                const target = event.target                const { top, left } = target.getBoundingClientRect()                                lazyReportCache({                    top,                    left,                    eventType,                    pageHeight: document.documentElement.scrollHeight || document.body.scrollHeight,                    scrollTop: document.documentElement.scrollTop || document.body.scrollTop,                    type: 'behavior',                    subType: 'click',                    target: target.tagName,                    paths: event.path?.map(item => item.tagName).filter(Boolean),                    startTime: event.timeStamp,                    pageURL: getPageURL(),                    outerHTML: target.outerHTML,                    innerHTML: target.innerHTML,                    width: target.offsetWidth,                    height: target.offsetHeight,                    viewport: {                        width: window.innerWidth,                        height: window.innerHeight,                    },                    uuid: getUUID(),                })            }, 500)        })    })}

页面跳转

利用 addEventListener() 监听 popstatehashchange 页面跳转事件。须要留神的是调用history.pushState()history.replaceState()不会触发popstate事件。只有在做出浏览器动作时,才会触发该事件,如用户点击浏览器的回退按钮(或者在Javascript代码中调用history.back()或者history.forward()办法)。同理,hashchange 也一样。

export default function pageChange() {    let from = ''    window.addEventListener('popstate', () => {        const to = getPageURL()        lazyReportCache({            from,            to,            type: 'behavior',            subType: 'popstate',            startTime: performance.now(),            uuid: getUUID(),        })        from = to    }, true)    let oldURL = ''    window.addEventListener('hashchange', event => {        const newURL = event.newURL        lazyReportCache({            from: oldURL,            to: newURL,            type: 'behavior',            subType: 'hashchange',            startTime: performance.now(),            uuid: getUUID(),        })        oldURL = newURL    }, true)}

Vue 路由变更

Vue 能够利用 router.beforeEach 钩子进行路由变更的监听。

export default function onVueRouter(router) {    router.beforeEach((to, from, next) => {        // 首次加载页面不必统计        if (!from.name) {            return next()        }        const data = {            params: to.params,            query: to.query,        }        lazyReportCache({            data,            name: to.name || to.path,            type: 'behavior',            subType: ['vue-router-change', 'pv'],            startTime: performance.now(),            from: from.fullPath,            to: to.fullPath,            uuid: getUUID(),        })        next()    })}

数据上报

上报办法

数据上报能够应用以下几种形式:

  • sendBeacon
  • XMLHttpRequest
  • image

我写的繁难 SDK 采纳的是第一、第二种形式相结合的形式进行上报。利用 sendBeacon 来进行上报的劣势非常明显。

应用 sendBeacon() 办法会使用户代理在有机会时异步地向服务器发送数据,同时不会提早页面的卸载或影响下一导航的载入性能。这就解决了提交剖析数据时的所有的问题:数据牢靠,传输异步并且不会影响下一页面的加载。

在不反对 sendBeacon 的浏览器下咱们能够应用 XMLHttpRequest 来进行上报。一个 HTTP 申请蕴含发送和接管两个步骤。其实对于上报来说,咱们只有确保能收回去就能够了。也就是发送胜利了就行,接不接管响应无所谓。为此,我做了个试验,在 beforeunload 用 XMLHttpRequest 传送了 30kb 的数据(个别的待上报数据很少会有这么大),换了不同的浏览器,都能够胜利收回去。当然,这和硬件性能、网络状态也是有关联的。

上报机会

上报机会有三种:

  1. 采纳 requestIdleCallback/setTimeout 延时上报。
  2. 在 beforeunload 回调函数里上报。
  3. 缓存上报数据,达到肯定数量后再上报。

倡议将三种形式联合一起上报:

  1. 先缓存上报数据,缓存到肯定数量后,利用 requestIdleCallback/setTimeout 延时上报。
  2. 在页面来到时对立将未上报的数据进行上报。

总结

仅看理论知识是比拟难以了解的,为此我联合本文所讲的技术要点写了一个简略的监控 SDK,能够用它来写一些简略的 DEMO,帮忙加深了解。再联合本文一起浏览,成果更好。

参考资料

性能监控

  • Performance API
  • PerformanceResourceTiming
  • Using_the_Resource_Timing_API
  • PerformanceTiming
  • Metrics
  • evolving-cls
  • custom-metrics
  • web-vitals
  • PerformanceObserver
  • Element_timing_API
  • PerformanceEventTiming
  • Timing-Allow-Origin
  • bfcache
  • MutationObserver
  • XMLHttpRequest
  • 如何监控网页的卡顿
  • sendBeacon

谬误监控

  • noerror
  • source-map

行为监控

  • popstate
  • hashchange