前端監(jiān)控 SDK 的一些技術(shù)要點原理分析

以下文章來源于前端編程技術(shù)分享 ,作者譚光志

一個完整的前端監(jiān)控平臺包括三個部分:數(shù)據(jù)采集與上報、數(shù)據(jù)整理和存儲、數(shù)據(jù)展示。

本文要講的就是其中的第一個環(huán)節(jié)——數(shù)據(jù)采集與上報。下圖是本文要講述內(nèi)容的大綱,大家可以先大致了解一下:




僅看理論知識是比較難以理解的,為此我結(jié)合本文要講的技術(shù)要點寫了一個簡單的監(jiān)控 SDK,可以用它來寫一些簡單的 DEMO,幫助加深理解。再結(jié)合本文一起閱讀,效果更好。

性能數(shù)據(jù)采集
chrome 開發(fā)團隊提出了一系列用于檢測網(wǎng)頁性能的指標:

FP(first-paint),從頁面加載開始到第一個像素繪制到屏幕上的時間
FCP(first-contentful-paint),從頁面加載開始到頁面內(nèi)容的任何部分在屏幕上完成渲染的時間
LCP(largest-contentful-paint),從頁面加載開始到最大文本塊或圖像元素在屏幕上完成渲染的時間
CLS(layout-shift),從頁面加載開始和其生命周期狀態(tài)變?yōu)殡[藏期間發(fā)生的所有意外布局偏移的累積分數(shù)
這四個性能指標都需要通過 PerformanceObserver 來獲?。ㄒ部梢酝ㄟ^ performance.getEntriesByName() 獲取,但它不是在事件觸發(fā)時通知的)。PerformanceObserver 是一個性能監(jiān)測對象,用于監(jiān)測性能度量事件。

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 屬性表示是否觀察緩存數(shù)據(jù),也就是說觀察代碼添加時機比事情觸發(fā)時機晚也沒關(guān)系。
observer.observe({ type: 'paint', buffered: true })
通過以上代碼可以得到 FP 的內(nèi)容:

{
    duration: 0,
    entryType: "paint",
    name: "first-paint",
    startTime: 359, // fp 時間
}
其中 startTime 就是我們要的繪制時間。

FCP
FCP(first-contentful-paint),從頁面加載開始到頁面內(nèi)容的任何部分在屏幕上完成渲染的時間。對于該指標,"內(nèi)容"指的是文本、圖像(包括背景圖像)、<svg>元素或非白色的<canvas>元素。



為了提供良好的用戶體驗,F(xiàn)CP 的分數(shù)應該控制在 1.8 秒以內(nèi)。



測量代碼:

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 的內(nèi)容:

{
    duration: 0,
    entryType: "paint",
    name: "first-contentful-paint",
    startTime: 459, // fcp 時間
}
其中 startTime 就是我們要的繪制時間。

LCP
LCP(largest-contentful-paint),從頁面加載開始到最大文本塊或圖像元素在屏幕上完成渲染的時間。LCP 指標會根據(jù)頁面首次開始加載的時間點來報告可視區(qū)域內(nèi)可見的最大圖像或文本塊完成渲染的相對時間。

一個良好的 LCP 分數(shù)應該控制在 2.5 秒以內(nèi)。



測量代碼:

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 的內(nèi)容:

{
    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 的區(qū)別是:FCP 只要任意內(nèi)容繪制完成就觸發(fā),LCP 是最大內(nèi)容渲染完成時觸發(fā)。



LCP 考察的元素類型為:

<img>元素
內(nèi)嵌在<svg>元素內(nèi)的<image>元素
<video>元素(使用封面圖像)
通過url()函數(shù)(而非使用CSS 漸變)加載的帶有背景圖像的元素
包含文本節(jié)點或其他行內(nèi)級文本元素子元素的塊級元素。
CLS
CLS(layout-shift),從頁面加載開始和其生命周期狀態(tài)變?yōu)殡[藏期間發(fā)生的所有意外布局偏移的累積分數(shù)。

布局偏移分數(shù)的計算方式如下:

布局偏移分數(shù) = 影響分數(shù) * 距離分數(shù)
影響分數(shù)測量不穩(wěn)定元素對兩幀之間的可視區(qū)域產(chǎn)生的影響。
距離分數(shù)指的是任何不穩(wěn)定元素在一幀中位移的最大距離(水平或垂直)除以可視區(qū)域的最大尺寸維度(寬度或高度,以較大者為準)。\

CLS 就是把所有布局偏移分數(shù)加起來的總和。

當一個 DOM 在兩個渲染幀之間產(chǎn)生了位移,就會觸發(fā) CLS(如圖所示)。




上圖中的矩形從左上角移動到了右邊,這就算是一次布局偏移。同時,在 CLS 中,有一個叫會話窗口的術(shù)語:一個或多個快速連續(xù)發(fā)生的單次布局偏移,每次偏移相隔的時間少于 1 秒,且整個窗口的最大持續(xù)時長為 5 秒。



例如上圖中的第二個會話窗口,它里面有四次布局偏移,每一次偏移之間的間隔必須少于 1 秒,并且第一個偏移和最后一個偏移之間的時間不能超過 5 秒,這樣才能算是一次會話窗口。如果不符合這個條件,就算是一個新的會話窗口??赡苡腥藭?,為什么要這樣規(guī)定?其實這是 chrome 團隊根據(jù)大量的實驗和研究得出的分析結(jié)果 Evolving the CLS metric。

CLS 一共有三種計算方式:1. 累加 2. 取所有會話窗口的平均數(shù) 3. 取所有會話窗口中的最大值

累加
也就是把從頁面加載開始的所有布局偏移分數(shù)加在一起。但是這種計算方式對生命周期長的頁面不友好,頁面存留時間越長,CLS 分數(shù)越高。

取所有會話窗口的平均數(shù)
這種計算方式不是按單個布局偏移為單位,而是以會話窗口為單位。將所有會話窗口的值相加再取平均值。但是這種計算方式也有缺點。



從上圖可以看出來,第一個會話窗口產(chǎn)生了比較大的 CLS 分數(shù),第二個會話窗口產(chǎn)生了比較小的 CLS 分數(shù)。如果取它們的平均值來當做 CLS 分數(shù),則根本看不出來頁面的運行狀況。原來頁面是早期偏移多,后期偏移少,現(xiàn)在的平均值無法反映出這種情況。

取所有會話窗口中的最大值
這種方式是目前最優(yōu)的計算方式,每次只取所有會話窗口的最大值,用來反映頁面布局偏移的最差情況。詳情請看 Evolving the CLS metric。

下面是第三種計算方式的測量代碼:

let sessionValue = 0
let 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 })
在看完上面的文字描述后,再看代碼就好理解了。一次布局偏移的測量內(nèi)容如下:

{
  duration: 0,
  entryType: "layout-shift",
  hadRecentInput: false,
  lastInputTime: 0,
  name: "",
  sources: (2) [LayoutShiftAttribution, LayoutShiftAttribution],
  startTime: 1176.199999999255,
  value: 0.000005752046026677329,
}
代碼中的 value 字段就是布局偏移分數(shù)。

DOMContentLoaded、load 事件
當純 HTML 被完全加載以及解析時,DOMContentLoaded 事件會被觸發(fā),不用等待 css、img、iframe 加載完。

當整個頁面及所有依賴資源如樣式表和圖片都已完成加載時,將觸發(fā) load 事件。

雖然這兩個性能指標比較舊了,但是它們?nèi)匀荒芊从稠撁娴囊恍┣闆r。對于它們進行監(jiān)聽仍然是必要的。

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)
}
首屏渲染時間
大多數(shù)情況下,首屏渲染時間可以通過 load 事件獲取。除了一些特殊情況,例如異步加載的圖片和 DOM。

<script>
    setTimeout(() => {
        document.body.innerHTML = `
            <div>
                <!-- 省略一堆代碼... -->
            </div>
        `
    }, 3000)
</script>
像這種情況就無法通過 load 事件獲取首屏渲染時間了。這時我們需要通過 MutationObserver 來獲取首屏渲染時間。MutationObserver 在監(jiān)聽的 DOM 元素屬性發(fā)生變化時會觸發(fā)事件。

首屏渲染時間計算過程:

利用 MutationObserver 監(jiān)聽 document 對象,每當 DOM 元素屬性發(fā)生變更時,觸發(fā)事件。
判斷該 DOM 元素是否在首屏內(nèi),如果在,則在 requestAnimationFrame() 回調(diào)函數(shù)中調(diào)用 performance.now() 獲取當前時間,作為它的繪制時間。
將最后一個 DOM 元素的繪制時間和首屏中所有加載的圖片時間作對比,將最大值作為首屏渲染時間。
監(jiān)聽 DOM
const next = window.requestAnimationFrame ? requestAnimationFrame : setTimeout
const 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,
})
上面的代碼就是監(jiān)聽 DOM 變化的代碼,同時需要過濾掉 style、script、link 等標簽。






判斷是否在首屏
一個頁面的內(nèi)容可能非常多,但用戶最多只能看見一屏幕的內(nèi)容。所以在統(tǒng)計首屏渲染時間的時候,需要限定范圍,把渲染內(nèi)容限定在當前屏幕內(nèi)。

const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight

// dom 對象是否在屏幕內(nèi)
function isInScreen(dom) {
    const rectInfo = dom.getBoundingClientRect()
    if (rectInfo.left < viewportWidth && rectInfo.top < viewportHeight) {
        return true
    }

    return false
}
使用 requestAnimationFrame() 獲取 DOM 繪制時間
當 DOM 變更觸發(fā) MutationObserver 事件時,只是代表 DOM 內(nèi)容可以被讀取到,并不代表該 DOM 被繪制到了屏幕上。



從上圖可以看出,當觸發(fā) MutationObserver 事件時,可以讀取到 document.body 上已經(jīng)有內(nèi)容了,但實際上左邊的屏幕并沒有繪制任何內(nèi)容。所以要調(diào)用 requestAnimationFrame() 在瀏覽器繪制成功后再獲取當前時間作為 DOM 繪制時間。

和首屏內(nèi)的所有圖片加載時間作對比
function getRenderTime() {
    let startTime = 0
    entries.forEach(entry => {
        if (entry.startTime > startTime) {
            startTime = entry.startTime
        }
    })

    // 需要和當前頁面所有加載圖片的時間做對比,取最大值
    // 圖片請求時間要小于 startTime,響應結(jié)束時間要大于 startTime
    performance.getEntriesByType('resource').forEach(item => {
        if (
            item.initiatorType === 'img'
            && item.fetchStart < startTime
            && item.responseEnd > startTime
        ) {
            startTime = item.responseEnd
        }
    })

    return startTime
}
優(yōu)化
現(xiàn)在的代碼還沒優(yōu)化完,主要有兩點注意事項:

什么時候上報渲染時間?
如果兼容異步添加 DOM 的情況?
第一點,必須要在 DOM 不再變化后再上報渲染時間,一般 load 事件觸發(fā)后,DOM 就不再變化了。所以我們可以在這個時間點進行上報。

第二點,可以在 LCP 事件觸發(fā)后再進行上報。不管是同步還是異步加載的 DOM,它都需要進行繪制,所以可以監(jiān)聽 LCP 事件,在該事件觸發(fā)后才允許進行上報。

將以上兩點方案結(jié)合在一起,就有了以下代碼:

let isOnLoaded = false
executeAfterLoad(() => {
    isOnLoaded = true
})


let timer
let observer
function checkDOMChange() {
    clearTimeout(timer)
    timer = setTimeout(() => {
        // 等 load、lcp 事件觸發(fā)后并且 DOM 樹不再變化時,計算首屏渲染時間
        if (isOnLoaded && isLCPDone()) {
            observer && observer.disconnect()
            lazyReportCache({
                type: 'performance',
                subType: 'first-screen-paint',
                startTime: getRenderTime(),
                pageURL: getPageURL(),
            })

            entries = null
        } else {
            checkDOMChange()
        }
    }, 500)
}
checkDOMChange() 代碼每次在觸發(fā) MutationObserver 事件時進行調(diào)用,需要用防抖函數(shù)進行處理。

接口請求耗時
接口請求耗時需要對 XMLHttpRequest 和 fetch 進行監(jiān)聽。

監(jiān)聽 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 請求是否成功?可以根據(jù)他的狀態(tài)碼是否在 200~299 之間。如果在,那就是成功,否則失敗。

監(jiān)聽 fetch

const originalFetch = window.fetch

function 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,可以根據(jù)返回數(shù)據(jù)中的的 ok 字段判斷請求是否成功,如果為 true 則請求成功,否則失敗。

注意,監(jiān)聽到的接口請求時間和 chrome devtool 上檢測到的時間可能不一樣。這是因為 chrome devtool 上檢測到的是 HTTP 請求發(fā)送和接口整個過程的時間。但是 xhr 和 fetch 是異步請求,接口請求成功后需要調(diào)用回調(diào)函數(shù)。事件觸發(fā)時會把回調(diào)函數(shù)放到消息隊列,然后瀏覽器再處理,這中間也有一個等待過程。

資源加載時間、緩存命中率
通過 PerformanceObserver 可以監(jiān)聽 resource 和 navigation 事件,如果瀏覽器不支持 PerformanceObserver,還可以通過 performance.getEntriesByType(entryType) 來進行降級處理。

當 resource 事件觸發(fā)時,可以獲取到對應的資源列表,每個資源對象包含以下一些字段:



從這些字段中我們可以提取到一些有用的信息:

{
    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, // 首字節(jié)時間
    protocol: entry.nextHopProtocol, // 請求協(xié)議
    responseBodySize: entry.encodedBodySize, // 響應內(nèi)容大小
    responseHeaderSize: entry.transferSize - entry.encodedBodySize, // 響應頭部大小
    resourceSize: entry.decodedBodySize, // 資源解壓后的大小
    isCache: isCache(entry), // 是否命中緩存
    startTime: performance.now(),
}
判斷該資源是否命中緩存

在這些資源對象中有一個 transferSize 字段,它表示獲取資源的大小,包括響應頭字段和響應數(shù)據(jù)的大小。如果這個值為 0,說明是從緩存中直接讀取的(強制緩存)。如果這個值不為 0,但是 encodedBodySize 字段為 0,說明它走的是協(xié)商緩存(encodedBodySize 表示請求響應數(shù)據(jù) body 的大?。?。

function isCache(entry) {
    // 直接從緩存讀取或 304
    return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0)
}
不符合以上條件的,說明未命中緩存。然后將所有命中緩存的數(shù)據(jù)/總數(shù)據(jù)就能得出緩存命中率。

瀏覽器往返緩存 BFC(back/forward cache)
bfcache 是一種內(nèi)存緩存,它會將整個頁面保存在內(nèi)存中。當用戶返回時可以馬上看到整個頁面,而不用再次刷新。據(jù)該文章 bfcache 介紹,firfox 和 safari 一直支持 bfc,chrome 只有在高版本的移動端瀏覽器支持。但我試了一下,只有 safari 瀏覽器支持,可能我的 firfox 版本不對。

但是 bfc 也是有缺點的,當用戶返回并從 bfc 中恢復頁面時,原來頁面的代碼不會再次執(zhí)行。為此,瀏覽器提供了一個 pageshow 事件,可以把需要再次執(zhí)行的代碼放在里面。

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 事件觸發(fā)后,用當前時間減去事件觸發(fā)時間,這個時間差值就是性能指標的繪制時間。注意,從 bfc 中恢復的頁面的這些性能指標,值一般都很小,一般在 10 ms 左右。所以要給它們加個標識字段 bfc: true。這樣在做性能統(tǒng)計時可以對它們進行忽略。

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
        }

        // 避免上報太快,緩存一定數(shù)量再上報
        if (frames.length >= 60) {
            report(deepCopy({
                frames,
                type: 'performace',
                subType: 'fps',
            }))

            frames.length = 0
        }

        next(calculateFPS)
    }

    calculateFPS()
}
代碼邏輯如下:

先記錄一個初始時間,然后每次觸發(fā) requestAnimationFrame() 時,就將幀數(shù)加 1。過去一秒后用幀數(shù)/流逝的時間就能得到當前幀率。
當連續(xù)三個低于 20 的 FPS 出現(xiàn)時,我們可以斷定頁面出現(xiàn)了卡頓,詳情請看 如何監(jiān)控網(wǎng)頁的卡頓。

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 路由變更渲染時間
首屏渲染時間我們已經(jīng)知道如何計算了,但是如何計算 SPA 應用的頁面路由切換導致的頁面渲染時間呢?本文用 Vue 作為示例,講一下我的思路。

export default function onVueRouter(Vue, router) {
    let isFirst = true
    let startTime
    router.beforeEach((to, from, next) => {
        // 首次進入頁面已經(jīng)有其他統(tǒng)計的渲染時間可用
        if (isFirst) {
            isFirst = false
            return next()
        }

        // 給 router 新增一個字段,表示是否要計算渲染時間
        // 只有路由跳轉(zhuǎn)才需要計算
        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)
            })
        },
    })
}
代碼邏輯如下:

監(jiān)聽路由鉤子,在路由切換時會觸發(fā) router.beforeEach() 鉤子,在該鉤子的回調(diào)函數(shù)里將當前時間記為渲染開始時間。
利用 Vue.mixin() 對所有組件的 mounted() 注入一個函數(shù)。每個函數(shù)都執(zhí)行一個防抖函數(shù)。
當最后一個組件的 mounted() 觸發(fā)時,就代表該路由下的所有組件已經(jīng)掛載完畢??梢栽?this.$nextTick() 回調(diào)函數(shù)中獲取渲染時間。
同時,還要考慮到一個情況。不切換路由時,也會有變更組件的情況,這時不應該在這些組件的 mounted() 里進行渲染時間計算。所以需要添加一個 needCalculateRenderTime 字段,當切換路由時將它設為 true,代表可以計算渲染時間了。

錯誤數(shù)據(jù)采集
資源加載錯誤
使用 addEventListener() 監(jiān)聽 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 可以監(jiān)聽 js 錯誤。

// 監(jiān)聽 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() 監(jiān)聽 unhandledrejection 事件,可以捕獲到未處理的 promise 錯誤。

// 監(jiān)聽 promise 錯誤 缺點是獲取不到列數(shù)據(jù)
window.addEventListener('unhandledrejection', e => {
    lazyReportCache({
        reason: e.reason?.stack,
        subType: 'promise',
        type: 'error',
        startTime: e.timeStamp,
        pageURL: getPageURL(),
    })
})
sourcemap
一般生產(chǎn)環(huán)境的代碼都是經(jīng)過壓縮的,并且生產(chǎn)環(huán)境不會把 sourcemap 文件上傳。所以生產(chǎn)環(huán)境上的代碼報錯信息是很難讀的。因此,我們可以利用 source-map 來對這些壓縮過的代碼報錯信息進行還原。

當代碼報錯時,我們可以獲取到對應的文件名、行數(shù)、列數(shù):

{
    line: 1,
    column: 17,
    file: 'https:/www.xxx.com/bundlejs',
}
然后調(diào)用下面的代碼進行還原:

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))
    // 根據(jù)壓縮后的報錯信息得出未壓縮前的報錯行列數(shù)和源碼文件
    const originalInfo = consumer.originalPositionFor({ line: error.line, column: error.column })
    // sourcesContent 中包含了各個文件的未壓縮前的源碼,根據(jù)文件名找出對應的源碼
    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.js
bundle.js.map
這時 js 文件放在靜態(tài)服務器上供用戶訪問,map 文件存儲在服務器,用于還原錯誤信息。source-map 庫可以根據(jù)壓縮過的代碼報錯信息還原出未壓縮前的代碼報錯信息。例如壓縮后報錯位置為 1 行 47 列,還原后真正的位置可能為 4 行 10 列。除了位置信息,還可以獲取到源碼原文。



上圖就是一個代碼報錯還原后的示例。鑒于這部分內(nèi)容不屬于 SDK 的范圍,所以我另開了一個 倉庫 來做這個事,有興趣可以看看。

Vue 錯誤
利用 window.onerror 是捕獲不到 Vue 錯誤的,它需要使用 Vue 提供的 API 進行監(jiān)聽。

Vue.config.errorHandler = (err, vm, info) => {
    // 將報錯信息打印到控制臺
    console.error(err)

    lazyReportCache({
        info,
        error: err.stack,
        subType: 'vue',
        type: 'error',
        startTime: performance.now(),
        pageURL: getPageURL(),
    })
}
行為數(shù)據(jù)采集
PV、UV
PV(page view) 是頁面瀏覽量,UV(Unique visitor)用戶訪問量。PV 只要訪問一次頁面就算一次,UV 同一天內(nèi)多次訪問只算一次。

對于前端來說,只要每次進入頁面上報一次 PV 就行,UV 的統(tǒng)計放在服務端來做,主要是分析上報的數(shù)據(jù)來統(tǒng)計得出 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 更受用戶喜歡,根據(jù)這一點可以有針對性的修改 a 活動頁面。

除此之外還可以利用訪問深度以及停留時長來鑒別電商刷單。例如有人進來頁面后一下就把頁面拉到底部然后等待一段時間后購買,有人是慢慢的往下滾動頁面,最后再購買。雖然他們在頁面的停留時間一樣,但明顯第一個人更像是刷單的。

頁面訪問深度計算過程稍微復雜一點:

用戶進入頁面時,記錄當前時間、scrollTop 值、頁面可視高度、頁面總高度。
用戶滾動頁面的那一刻,會觸發(fā) scroll 事件,在回調(diào)函數(shù)中用第一點得到的數(shù)據(jù)算出頁面訪問深度和停留時長。
當用戶滾動頁面到某一點時,停下繼續(xù)觀看頁面。這時記錄當前時間、scrollTop 值、頁面可視高度、頁面總高度。
重復第二點...
具體代碼請看:

let timer
let startTime = 0
let hasReport = false
let pageHeight = 0
let scrollTop = 0
let viewportHeight = 0

export 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() 監(jiān)聽 mousedown、touchstart 事件,我們可以收集用戶每一次點擊區(qū)域的大小,點擊坐標在整個頁面中的具體位置,點擊元素的內(nèi)容等信息。

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)
        })
    })
}
頁面跳轉(zhuǎn)
利用 addEventListener() 監(jiān)聽 popstate、hashchange 頁面跳轉(zhuǎn)事件。需要注意的是調(diào)用history.pushState()或history.replaceState()不會觸發(fā)popstate事件。只有在做出瀏覽器動作時,才會觸發(fā)該事件,如用戶點擊瀏覽器的回退按鈕(或者在Javascript代碼中調(diào)用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 鉤子進行路由變更的監(jiān)聽。

export default function onVueRouter(router) {
    router.beforeEach((to, from, next) => {
        // 首次加載頁面不用統(tǒng)計
        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()
    })
}
數(shù)據(jù)上報
上報方法
數(shù)據(jù)上報可以使用以下幾種方式:

sendBeacon
XMLHttpRequest
image
我寫的簡易 SDK 采用的是第一、第二種方式相結(jié)合的方式進行上報。利用 sendBeacon 來進行上報的優(yōu)勢非常明顯。

使用 sendBeacon()  方法會使用戶代理在有機會時異步地向服務器發(fā)送數(shù)據(jù),同時不會延遲頁面的卸載或影響下一導航的載入性能。這就解決了提交分析數(shù)據(jù)時的所有的問題:數(shù)據(jù)可靠,傳輸異步并且不會影響下一頁面的加載。

在不支持 sendBeacon 的瀏覽器下我們可以使用 XMLHttpRequest 來進行上報。一個 HTTP 請求包含發(fā)送和接收兩個步驟。其實對于上報來說,我們只要確保能發(fā)出去就可以了。也就是發(fā)送成功了就行,接不接收響應無所謂。為此,我做了個實驗,在 beforeunload 用 XMLHttpRequest 傳送了 30kb 的數(shù)據(jù)(一般的待上報數(shù)據(jù)很少會有這么大),換了不同的瀏覽器,都可以成功發(fā)出去。當然,這和硬件性能、網(wǎng)絡狀態(tài)也是有關(guān)聯(lián)的。

上報時機
上報時機有三種:1. 采用 requestIdleCallback/setTimeout 延時上報。2. 在 beforeunload 回調(diào)函數(shù)里上報。3. 緩存上報數(shù)據(jù),達到一定數(shù)量后再上報。

建議將三種方式結(jié)合一起上報:1. 先緩存上報數(shù)據(jù),緩存到一定數(shù)量后,利用 requestIdleCallback/setTimeout 延時上報。2. 在頁面離開時統(tǒng)一將未上報的數(shù)據(jù)進行上報。

總結(jié)
僅看理論知識是比較難以理解的,為此我結(jié)合本文所講的技術(shù)要點寫了一個簡單的監(jiān)控 SDK,可以用它來寫一些簡單的 DEMO,幫助加深理解。再結(jié)合本文一起閱讀,效果更好。

同時收錄于小程序《互聯(lián)網(wǎng)小兵》,技術(shù)人小程序,收錄前端、后端、移動端、人工智能、算法等,同時也有面經(jīng)、招聘、科技資訊等分享,歡迎大家去搜索,體驗體驗,給我提些意見!

作者:譚光志



歡迎關(guān)注微信公眾號 :前端晚間課

更多文章,收錄于小程序-互聯(lián)網(wǎng)小兵