前端监控 SDK 的一些技术要点原理分析(中)

本文涉及的产品
应用实时监控服务-应用监控,每月50GB免费额度
简介: 前端监控 SDK 的一些技术要点原理分析(中)

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 : 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 变化的代码,同时需要过滤掉 stylescriptlink 等标签。

判断是否在首屏

一个页面的内容可能非常多,但用户最多只能看见一屏幕的内容。所以在统计首屏渲染时间的时候,需要限定范围,把渲染内容限定在当前屏幕内。

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 被绘制到了屏幕上。

从上图可以看出,当触发 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 = 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 和 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.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,可以根据返回数据中的的 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
}
相关实践学习
通过轻量消息队列(原MNS)主题HTTP订阅+ARMS实现自定义数据多渠道告警
本场景将自定义告警信息同时分发至多个通知渠道的需求,例如短信、电子邮件及钉钉群组等。通过采用轻量消息队列(原 MNS)的主题模型的HTTP订阅方式,并结合应用实时监控服务提供的自定义集成能力,使得您能够以简便的配置方式实现上述多渠道同步通知的功能。
目录
相关文章
|
7月前
|
数据采集 搜索推荐 前端开发
鸿蒙HarmonyOS埋点SDK,ClkLog适配鸿蒙埋点分析
ClkLog鸿蒙埋点SDK通过手动埋点的方式实现HarmonyOS 原生应用的前端数据采集。快速接入即可获取埋点数据,同时支持分析功能(基础统计分析、自定义分析、用户画像等)。
329 59
|
7月前
|
前端开发 Java 物联网
智慧班牌源码,采用Java + Spring Boot后端框架,搭配Vue2前端技术,支持SaaS云部署
智慧班牌系统是一款基于信息化与物联网技术的校园管理工具,集成电子屏显示、人脸识别及数据交互功能,实现班级信息展示、智能考勤与家校互通。系统采用Java + Spring Boot后端框架,搭配Vue2前端技术,支持SaaS云部署与私有化定制。核心功能涵盖信息发布、考勤管理、教务处理及数据分析,助力校园文化建设与教学优化。其综合性和可扩展性有效打破数据孤岛,提升交互体验并降低管理成本,适用于日常教学、考试管理和应急场景,为智慧校园建设提供全面解决方案。
486 70
|
9月前
|
前端开发 安全 开发工具
【11】flutter进行了聊天页面的开发-增加了即时通讯聊天的整体页面和组件-切换-朋友-陌生人-vip开通详细页面-即时通讯sdk准备-直播sdk准备-即时通讯有无UI集成的区别介绍-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
【11】flutter进行了聊天页面的开发-增加了即时通讯聊天的整体页面和组件-切换-朋友-陌生人-vip开通详细页面-即时通讯sdk准备-直播sdk准备-即时通讯有无UI集成的区别介绍-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
592 90
【11】flutter进行了聊天页面的开发-增加了即时通讯聊天的整体页面和组件-切换-朋友-陌生人-vip开通详细页面-即时通讯sdk准备-直播sdk准备-即时通讯有无UI集成的区别介绍-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
|
缓存 监控 前端开发
优化 Flutter 应用启动速度的策略,涵盖理解启动过程、资源加载优化、减少初始化工作、界面布局优化、异步初始化、预加载关键数据、性能监控与分析等方面
本文探讨了优化 Flutter 应用启动速度的策略,涵盖理解启动过程、资源加载优化、减少初始化工作、界面布局优化、异步初始化、预加载关键数据、性能监控与分析等方面,并通过案例分析展示了具体措施和效果,强调了持续优化的重要性及未来优化方向。
521 10
|
前端开发 JavaScript 测试技术
前端测试技术中,如何提高集成测试的效率?
前端测试技术中,如何提高集成测试的效率?
|
9月前
|
前端开发 Java Shell
【08】flutter完成屏幕适配-重建Android,增加GetX路由,屏幕适配,基础导航栏-多版本SDK以及gradle造成的关于fvm的使用(flutter version manage)-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
【08】flutter完成屏幕适配-重建Android,增加GetX路由,屏幕适配,基础导航栏-多版本SDK以及gradle造成的关于fvm的使用(flutter version manage)-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
627 20
【08】flutter完成屏幕适配-重建Android,增加GetX路由,屏幕适配,基础导航栏-多版本SDK以及gradle造成的关于fvm的使用(flutter version manage)-卓伊凡换人优雅草Alex-开发完整的社交APP-前端客户端开发+数据联调|以优雅草商业项目为例做开发-flutter开发-全流程-商业应用级实战开发-优雅草Alex
|
11月前
|
移动开发 缓存 前端开发
深入理解前端路由:原理、实现与应用
本书《深入理解前端路由:原理、实现与应用》全面解析了前端路由的核心概念、工作原理及其实现方法,结合实际案例探讨了其在现代Web应用中的广泛应用,适合前端开发者和相关技术人员阅读。
|
12月前
|
前端开发 JavaScript 搜索推荐
HTML与CSS在Web组件化中的核心作用及前端技术趋势
本文探讨了HTML与CSS在Web组件化中的核心作用及前端技术趋势。从结构定义、语义化到样式封装与布局控制,两者不仅提升了代码复用率和可维护性,还通过响应式设计、动态样式等技术增强了用户体验。面对兼容性、代码复杂度等挑战,文章提出了相应的解决策略,强调了持续创新的重要性,旨在构建高效、灵活的Web应用。
263 6
|
前端开发 JavaScript 搜索推荐
前端懒加载:提升页面性能的关键技术
前端懒加载是一种优化网页加载速度的技术,通过延迟加载非首屏内容,减少初始加载时间,提高用户访问体验和页面性能。
|
前端开发 开发者
本文将深入探讨 BEM 的概念、原理以及其在前端开发中的应用
BEM(Block-Element-Modifier)是一种前端开发中的命名规范和架构方法,旨在提高代码的可维护性和复用性。通过将界面拆分为独立的模块,BEM 提供了一套清晰的命名规则,增强了代码的结构化和模块化设计,促进了团队协作。本文深入探讨了 BEM 的概念、原理及其在前端开发中的应用,分析了其优势与局限性,为开发者提供了宝贵的参考。
431 8
下一篇
oss云网关配置