Принципиальный анализ некоторых технических моментов SDK фронтенд-мониторинга

внешний интерфейс JavaScript монитор

Эта статья участвовала в "Проект «Звезда раскопок»”, чтобы выиграть творческий подарочный пакет и бросить вызов творческим поощрительным деньгам.

Полноценная платформа внешнего мониторинга включает в себя три части: сбор данных и отчетность, сортировка и хранение данных, а также отображение данных.

В этой статье речь пойдет о первом звене — сборе данных и отчетности. На следующем рисунке показано содержание этой статьи, сначала вы можете получить общее представление:

image.png

image.png

Это трудно понять, только взглянув на теоретические знания, поэтому я написал простую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>элемент.

image.png

Чтобы обеспечить хорошее взаимодействие с пользователем, оценка FCP должна контролироваться в течение 1,8 секунды.

image.png

Код измерения:

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 секунд.

image.png

Код измерения:

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 относится к элементу DOM, нарисованному LCP.

Разница между FCP и LCP заключается в следующем: FCP срабатывает до тех пор, пока отрисовывается любой контент, а LCP срабатывает, когда завершается максимальный рендеринг контента.

image.png

Типы элементов, проверяемые LCP:

  • <img>элемент
  • встроенный в<svg>внутри элемента<image>элемент
  • <video>Элементы (с использованием обложки)
  • пройти черезurl()функция (вместо использованияCSS-градиент) загруженный элемент с фоновым изображением
  • Содержит текстовые узлы или другие дочерние текстовые элементы встроенного уровня.элемент блочного уровня.

CLS

CLS (layout-shift), начиная с загрузки страницы и еесостояние жизненного циклаСтановится кумулятивной оценкой всех неожиданных изменений макета, произошедших во время скрытия.

Оценка смещения компоновки рассчитывается следующим образом:

布局偏移分数 = 影响分数 * 距离分数

Оценка воздействияИзмерениенестабильный элементЭффект на видимую область между двумя кадрами.

оценка расстоянияозначает любойнестабильный элементМаксимальное расстояние (по горизонтали или вертикали), которое должно быть смещено в кадре, деленное на наибольший размер видимой области (ширину или высоту, в зависимости от того, что больше).

CLS — это сумма всех оценок смещения макета..

CLS запускается, когда DOM смещается между двумя визуализируемыми кадрами (как показано).

image.png

image.png

Прямоугольник на изображении выше переместился из верхнего левого угла вправо, что является смещением макета. Между тем, в CLS естьокно сеансаТермин для: одного или нескольких одиночных смен макета, которые происходят в быстрой последовательности, с интервалом между каждым смещением менее 1 секунды и максимальной продолжительностью 5 секунд для всего окна.

image.png

Например, второе окно сеанса на рисунке выше имеет четыре смещения макета.Интервал между каждым смещением должен быть меньше 1 секунды, а время между первым смещением и последним смещением не может быть больше 5 секунд, поэтому может быть считается окном сеанса. Если это условие не выполняется, это новое окно сеанса. Некоторые люди могут спросить, зачем это нужно? По сути, это результат анализа, полученный командой chrome на основе большого количества экспериментов и исследований.Evolving the CLS metric.

Существует три метода расчета CLS:

  1. накапливать
  2. Возьмите среднее значение всех окон сеанса
  3. Возьмите наибольшее значение среди всех окон сеанса

накапливать

То есть сложите вместе все оценки смещения макета с момента загрузки страницы. Однако этот метод расчета не подходит для страниц с длительным жизненным циклом: чем дольше сохраняется страница, тем выше показатель CLS.

Возьмите среднее значение всех окон сеанса

Этот расчет производится не в единицах смещения отдельных макетов, а в единицах окон сеанса. Значения для всех окон сеанса суммируются и усредняются. Но у этого метода расчета есть и недостатки.

image.png

Как видно из приведенного выше рисунка, первое окно сеанса дает относительно большую оценку CLS, а второе окно сеанса дает относительно небольшую оценку CLS. Если вы возьмете их среднее значение и используете его в качестве оценки CLS, вы вообще не сможете увидеть, как работает страница. Исходная страница имеет более раннее смещение и меньше позднее смещение, и текущее среднее значение не может отражать эту ситуацию.

Возьмите наибольшее значение среди всех окон сеанса

Этот метод является оптимальным методом расчета на данный момент, каждый раз берется только максимальное значение всех окон сеанса, которое используется для отражения наихудшего случая смещения макета страницы. Подробнее см.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 })

После прочтения приведенного выше текстового описания его легко понять, если посмотреть на код. Измерение смещения макета выглядит следующим образом:

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

в кодеvalueПоле представляет собой оценку смещения макета.

DOMContentLoaded, события загрузки

Когда обычный 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 для прослушивания объекта документа и инициирования события всякий раз, когда изменяется свойство элемента DOM.
  2. Определить, находится ли элемент DOM внутри сгиба, и если да, тоrequestAnimationFrame()Вызывается в функции обратного вызоваperformance.now()Получите текущее время в качестве времени отрисовки.
  3. Сравните время прорисовки последнего элемента DOM со временем всех загруженных изображений в верхней части сгиба и используйте максимальное значение в качестве времени рендеринга в верхней части сгиба.

Мониторинг 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,
})

Приведенный выше код — это код, который отслеживает изменения DOM и должен быть отфильтрован.style,script,linkэтикетка.

Определите, находится ли он на сгибе

Содержимое страницы может быть очень большим, но пользователь может видеть содержимое не более одного экрана. Поэтому при подсчете времени рендеринга первого экрана необходимо ограничивать область действия и ограничивать контент рендеринга текущим экраном.

const viewportWidth = window.innerWidth
const 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 отрисовывается на экране.

image.png

Как видно из рисунка выше, при срабатывании события 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 больше не изменяется.Как правило, после запуска события загрузки DOM больше не будет меняться. Таким образом, мы можем сообщить в данный момент времени.

Во-вторых, об этом можно сообщить после запуска события LCP. Независимо от того, загружается ли DOM синхронно или асинхронно, его необходимо отрисовывать, чтобы вы могли прослушивать событие LCP и разрешать отчеты после запуска события.

Объединив два вышеуказанных решения вместе, мы получим следующий код:

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


let timer
let observer
function 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 и FECTH необходимо контролировать, когда интерфейсный запрос потребляет.

Слушайте 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. Если это так, это удается, в противном случае это терпит неудачу.

слушать для извлечения

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
        })
    }
}

Для извлечения вы можетеokПоле определяет успешность запроса, еслиtrueЗапрос выполняется успешно, в противном случае он терпит неудачу.

Уведомление, время запроса отслеживаемого интерфейса может отличаться от времени, обнаруженного в chrome devtool. Это связано с тем, что в chrome devtool определяется время отправки HTTP-запроса и весь процесс взаимодействия. Но xhr и fetch — это асинхронные запросы, и функцию обратного вызова нужно вызывать после успешного запроса интерфейса. При срабатывании события callback-функция будет помещена в очередь сообщений, а затем ее обработает браузер, а посередине также находится ожидающий процесс.

Время загрузки ресурса, частота попаданий в кеш

пройти черезPerformanceObserverможет контролироватьresourceа такжеnavigationсобытие, если браузер не поддерживаетPerformanceObserver, также черезperformance.getEntriesByType(entryType)понизить.

когдаresourceПри срабатывании события можно получить соответствующий список ресурсов.Каждый ресурсный объект содержит следующие поля:

image.png

Из этих полей мы можем извлечь некоторую полезную информацию:

{
    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Указывает размер тела данных ответа на запрос).

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

Если вышеуказанные условия не выполняются, это означает, что кеш не попал. потом所有命中缓存的数据/总数据Можно получить частоту попаданий в кэш.

Обратный кэш браузера BFC (обратный/прямой кеш)

bfcache — это кеш в памяти, который хранит в памяти целые страницы. Когда пользователь возвращается, вся страница может быть просмотрена сразу же без необходимости ее повторного обновления. Согласно статьеbfcacheВведение, firfox и safari всегда поддерживали bfc, а chrome поддерживается только в мобильных браузерах с высокой версией. Но я пробовал, поддерживает только браузер сафари, возможно моя версия фирфокса не та.

Но у bfc есть и недостатки: когда пользователь возвращается и восстанавливает страницу из bfc, код исходной страницы не будет выполняться снова. Для этого браузеры предоставляютpageshowEvents, в него можно поместить код, который необходимо выполнить повторно.

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 мс. Поэтому нам нужно добавить к ним поле идентификации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. Используйте по прошествии одной секунды帧数/流逝的时间чтобы получить текущую частоту кадров.

Когда появляется FPS из трех последовательных ниже 20, мы можем сделать вывод, что страница отображается на странице.Подробности см.Как контролировать зависание веб-страницы.

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()Прослушайте событие ошибки, чтобы поймать ошибки сбоя загрузки ресурсов.

// 捕获资源加载失败错误 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(),
    })
}

ошибка обещания

использоватьaddEventListener()Слушайте событие unhandledrejection, чтобы обнаружить необработанные ошибки промисов.

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

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')
}

Каждый раз, когда проект упаковывается, если включена исходная карта, каждый файл js будет иметь соответствующий файл карты.

bundle.js
bundle.js.map

В это время файл js размещается на статическом сервере для доступа пользователей, а файл карты сохраняется на сервере для восстановления информации об ошибках.source-mapБиблиотека может восстановить сообщение об ошибке несжатого кода в соответствии с сообщением об ошибке сжатого кода. Например, позиция ошибки после сжатия1 行 47 列, реальное положение после восстановления может быть4 行 10 列. В дополнение к информации о местонахождении также можно получить исходный код.

image.png

На картинке выше пример ошибки кода после восстановления. Поскольку эта часть контента не входит в объем SDK, я открыл еще одинскладПриходите, чтобы сделать это, если вам интересно, вы можете взглянуть.

ошибка vue

использоватьwindow.onerrorНевозможно поймать ошибки Vue, для мониторинга необходимо использовать API, предоставляемый Vue.

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 нужно посетить страницу только один раз, а 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 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()мониторmousedown,touchstartСобытия, мы можем собирать такую ​​информацию, как размер области щелчка пользователя, конкретное расположение координат щелчка на всей странице и содержимое элемента, на который нажали.

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()мониторpopstate,hashchangeСобытие перехода на страницу. Обратите внимание, что вызовhistory.pushState()илиhistory.replaceState()не сработаетpopstateмероприятие. Это событие запускается только тогда, когда выполняется действие браузера, например, когда пользователь нажимает кнопку «Назад» в браузере (или вызывает в коде Javascripthistory.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()
    })
}

отчетность данных

Метод отчетности

Сообщается, что данные используются следующими способами:

Простой SDK, который я написал, использует комбинацию первого и второго методов для создания отчетов. Преимущество использования sendBeacon для создания отчетов очевидно.

использовать sendBeacon()Метод заставит пользовательский агент асинхронно отправлять данные на сервер, когда у него есть возможность, без задержки выгрузки страницы или влияния на производительность загрузки следующей навигации. Это решает все проблемы с отправкой данных аналитики: данные достоверны, передача асинхронна и не влияет на загрузку следующей страницы.

В браузерах, которые не поддерживают sendBeacon, мы можем использовать XMLHttpRequest для отчета. HTTP-запрос состоит из двух шагов: отправки и получения. По сути, для отчетности нам просто нужно убедиться, что ее можно рассылать. То есть отправка прошла успешно, и неважно, получен ответ или нет. С этой целью я провел эксперимент и использовал XMLHttpRequest для передачи 30 КБ данных перед выгрузкой (как правило, данные, которые должны быть переданы, редко бывают такими большими), и их можно успешно отправить с помощью разных браузеров. Конечно, это также связано с производительностью оборудования и состоянием сети.

Время отчета

Есть три отчетных периода:

  1. использоватьrequestIdleCallback/setTimeoutЗапоздалая отчетность.
  2. Сообщите об этом в функции обратного вызова beforeunload.
  3. Кэшируйте сообщаемые данные и сообщайте об этом после достижения определенного числа.

Для отчетности рекомендуется сочетание трех методов:

  1. Сначала кэшируйте сообщаемые данные, кэшируйте их до определенного количества и используйтеrequestIdleCallback/setTimeoutЗапоздалая отчетность.
  2. Когда страница покинет, незарегистрированные данные будут сообщены единообразно.

Суммировать

Это сложно понять, только взглянув на теоретические знания, поэтому я написал простуюSDK для мониторинга, вы можете использовать его, чтобы написать несколько простых DEMO, которые помогут углубить ваше понимание. Прочтите ее вместе с этой статьей, чтобы получить лучшие результаты.

использованная литература

мониторинг производительности

Мониторинг ошибок

мониторинг поведения