【前端监控】单页应用首屏测速
前端监控系列,SDK,服务、存储 ,会全部总结一遍,写文不易,希望给最后点个赞鼓励鼓励
之前写过一篇首屏自动化测速,但是这篇文章不是很适用于单页应用的测速,需要稍作调整
主要是因为单页应用生命周期很长,切换页面其实还是一个页面,事件 和 变量等数据没有销毁的,就会存在一些问题
performance 资源保存限制
performance 资源重复
事件重复监听
performance 资源响应时间的基准点不同
单页应用Dom渲染顺序…
等等这些问题,后面会一一详细说明并解决
本文分为下面2个部分
1、监听 spa 页面切换
2、spa 首屏测速
3、代码仓库
我们知道 spa 页面的切换不会真的向浏览器发起请求,页面也不会重新加载。
所以如果我们想对 spa 完成 pv、首屏测速,我们就只能去监听切换事件,在事件回调里面完成上报
1基本原理
window.addEventListener("popstate",()=>{
// 上报逻辑
})
更具体事件触发时机
1、点击浏览器的 后退、前进按钮
2、 JS 中 调用 history.back()、history.forward()、history.go() 方法
2数据上报
监听 spa 的切换
第一是为了 pv (page view)上报,spa 虽然是单页面,但是每次切换都是相当于独立的页面,所以需要 pv 上报
第二是为了spa 首屏的测速。
pv 上报当然是上报页面链接以及一些基本的数据
spa 首屏测速计算比较复杂一些,会放到下面讲解
3问题一览
监听切换这么容易就搞定了吗,当然不是,我们还会面临下面这些问题
1、是否使用 hashChange 替代 popstate 事件
并不能用 hashChange 替代 popstate 事件,比较的主要有几点。
第一,hashChange 兼容性好一些。
hashChange 支持 IE8 ,popstate 支持IE10。
第二,popstate 支持场景多。
比如 test.com/page1 和 test.com/page2 , 这样 spa 使用 history 模式也不会有问题
第三,popstate 比 hashChange 触发要快。
因为我们需要在 事件回调中完成首屏测速,所以需要监听 DOM 加载,所以事件触发越快,越能保证DOM 加载监听动作。
并且在我们实际React应用中,发现 hashChange 竟然比 componentDidMount 触发还要慢,我滴乖乖,dom 都挂载完了,还监听个鬼
2、history.pushState 和 history.replaceState 调用不会触发 popState
对此,我们只能进行兼容,劫持这两个方法,并且手动触发事件,完成和 popState 一样的事情
具体 pushState 劫持如下
const originPushState = history.pushState;
history.pushState = function (...args) {
let event = null;
if (typeof Event === "function") {
event = new Event("pushState");
} else {
// 兼容ie
event = document.createEvent("HTMLEvents");
event.initEvent("pushState", false /* 是否冒泡 */, true/* 是否可以用 preventDefault() 方法取消事件 */);
}
window.dispatchEvent(event);
return originPushState.apply(this, args);
};
replaceState 也是同样的写法。
我们主要在重写方法里触发了自定义的事件 pushState 和 replaceState
只是为了监听到这两个行为,而监听他们也是为了完成和 popstate 同样的事情
所以监听就变成
function report(){ // 上报逻辑 }
window.addEventListener("popstate",()=>{
report()
})
window.addEventListener("pushState",()=>{
report()
})
window.addEventListener("replaceState",()=>{
report()
})
并且在 IE 中 不支持 Event ,所以需要兼容一下
之前我写过一篇 首屏测速的文章
但是这篇文章不太适用于 spa 的首屏测速,因为 spa 需要额外处理一些问题,但是思路基本是一致的
不过还是会重复先说一下基本思路,和 spa 下要解决的点
1基本思路
1、监听DOM的加载,记录 DOM 渲染的时间,取最大的渲染时间
2、获取首屏内 img 元素,取所有img 最大的加载时间
3、取 DOM 渲染 和 img 加载 的 最大一方
如下图
其中使用到的 API 就是
1、使用 MutationObserver 监听 DOM 加载
2、从 performance.getEntriesByType 获取到 img 加载完成的时间
基本思路和 之前的 文章是一致的,这里也是在其基础上做了优化 和 兼容一下,具体可以先看那篇文章
2问题一览
现在开始说一下针对 spa 做的兼容以及新的优化点
针对spa做的兼容主要有 6点
1、更新资源以及计算的时间基点
2、spa 无法监听细致的 DOM 挂载
3、避免多个 mutation 监听工作
4、过滤重复资源
5、资源存储有限,可能会被清除
6、spa 公共的加载时间取舍
另外针对 首屏时间计算的一个优化点
1、网络原因导致 img 加载超过既定3s,从而首屏时间不准确
1、更新资源以及计算的时间基点
在我们记录DOM 渲染时间的时候,使用 performance.now ,而 它返回的是从页面开始加载到 当前调用的时间。
如果是独立的多页面,计算使用它是没有问题的。如果是单页,页面切换没有刷新,所有时间都基于页面开始,那这个时间可就大了去了
比如 从 performance.getEntries 获取的资源,因为从页面加载开始算,所以时间非常大
虽然取 duration 是正常的,但是把 开始加载前那一段时间算进去才是正确的
在监听spa 切换的时候,获取当前时间基点所以我们需要更新时间基点,但是我们不需要对每个资源都减去新的时间基点
只要在最后拿到首屏时间的时候 减去新的时间基点就好了
像这样
window.addEventListener("popstate", () => {
const baseTime = performance.now();
getFirstScreenTime().then((time) => {
console.log("getFirstScreenTime", time - baseTime);
});
});
2、spa 无法监听细致的 DOM 挂载
如果是服务端渲染的页面,返回的是完整的 html。浏览器为了尽快渲染页面,会一边接收html 信息,一边解析内容并渲染。不会等到整个 HTML 文档解析完毕,而是一个渐进的过程。
所以这样情况下去监听DOM 挂载,通常我们可以监听到每个DOM的挂载,所以从这一步就可以拿到 img 元素
但是 spa 的渲染则不同,因为 spa 为了性能考虑,都是把所有 dom 构造完毕之后,统一挂载的,所以导致我们无法获取到具体的每个dom 挂载信息,只能拿到零星几个包裹元素
这样就无法获取到img 元素,所以我们直接从监听到的dom 中直接查找 img 元素
具体就是通过 getElementsByTags("img")
3、避免多个 mutation 监听工作
在 spa 切换的时候,我们会开启 MutationObserver 监听DOM 的挂载,但是因为我们会有一个 等待时间,3s 或者5s
如果这个定时器没有结束的时候,用户就切换页面,就会产生一个新的页面的 MutationObserver 监听,并且旧的 MutationObserver 还在工作,最后还会进行首屏时间计算上报,但是这个数据是不准确的
所以我们需要在保证 MutationObserver 监听单例,在 spa 切换的时候,重置 MutationObserver ,结束上一个监听
具体处理可以看后面贴出的代码仓库 Demo
4、过滤资源重复
performance.getEntries 记录的资源,是从页面开始的,所以当你一个页面切走又切回来。
这个页面的 资源重新加载,会在 performance 中存在两份相同的资源
所以需要过滤旧的资源
1、从结尾开始找。因为资源是按加载顺序排列的,所以最新的资源在后面,我们可以从结尾开始查找
2、判断是否 切换后才加载的资源
有两个过滤条件
responseEnd 小于 popstate 触发时间
duration < reponseEnd-popstate触发时间
具体处理可以看后面贴出的代码仓库 Demo
5、资源存储有限,可能会被清除
因为spa 切换不会刷新,就算切了几十个页面, performance 还是存储了一开始到现在的资源。浏览器通常存储有限,比如 Chrome 只会存储250个,超过后的新资源可能无法被记录!
这样就会导致我们无法获取到新的 img 加载时间。
所以我们需要监听资源缓冲区是否满了,如果满了,就要清除一下,另外需要自己保存一份
let imgResource = [];
const origin = performance.onresourcetimingbufferfull;
performance.onresourcetimingbufferfull = (...args) => {
const imgs = performance
.getEntriesByType("resource")
.filter((res) => res.initiatorType === "img");
imgResource = [...imgResource, ...imgs];
performance.clearResourceTimings();
return origin.apply(this, args);
};
这里关于 资源缓冲区的,在 静态资源上报那一文中,我们有更详细的说明
另外,我们还需要劫持 清除的方法,避免被其他地方清除资源,导致我们无法获取到信息(比比如在资源上报中,就会清除)
const origin = performance.clearResourceTimings;
performance.clearResourceTimings = function (...args) {
const imgs = performance
.getEntriesByType("resource")
.filter((res) => res.initiatorType === "img");
imgResource = [...imgResource, ...imgs];
return origin.apply(this, args);
};
这里如果是自己清除的,可能存在重复缓存的可能,所以这里需要用一个标志位判断一下
let isClearByMe = false;
performance.onresourcetimingbufferfull = (...args) => {
//....缓存img资源
isClearByMe = true;
performance.clearResourceTimings();
isClearByMe = false;
return origin.apply(this, args);
};
performance.clearResourceTimings = function (...args) {
if (!isClearByMe) {
//....缓存img资源
}
return origin.apply(this, args);
};
更具体处理可以看后面贴出的代码仓库 Demo
6、spa 公共的加载时间取舍
一个页面,直接访问的首屏时间 和 spa 切换访问的时间 是会差一部分的,差的就是公共时间,比如红色圈起来的这部分
可以从 performance.timing 获取到这部分数据,主要获取 connectEnd 这部分
然后spa 计算得到的首屏时间中,加上这部分就会得到一个比较完善的 首屏时间
但是我认为这里并不是强制要加上的
因为直接访问和spa切换访问,本来动作就不一样,时间基点不一样,所以没有公共部分是合理的。
但是实际上还是仍然有这部分需求,想要首屏时间尽量在同一比较线上,所以需要支持自定义,通过一个参数去决定是否需要加上这部分
7、img 加载超过3s,导致首屏计算不准确
在之前的首屏计算中,我们会设置一个定时器,3s 或者5s,为了保证 img 加载完毕,然后再计算首屏时间。
但是实际应用中,发现网络差的时候,图片真的加载很久都不出来,此时我们就获取不到 img 加载的时间,从而使用了 dom 渲染的时间。
这样首屏时间就是错误的
对此,主要有两种想法
1、超过3s 或者5s,如果存在 img 元素,但是 img 没有加载完毕,那么就认为首屏时间是 3s 或者 5s。因为网络原因的话,再长的时间好像都没有什么意义了,毕竟用户可能早就已经切走了
2、监听 img 的事件,所有图片的 onload 或者 onerror 触发,才最终结算首屏时间。
最好通过参数让用户决定选择哪种实现方式
所以这里主要说下 第二种的实现。
因为我们监听DOM时会额外存一份首屏内的 img 数组
所以最后我们会对 img 分类一下(通过 img 元素上的 complete 属性)
已经加载完成的从 performance.getEntries 中获取时间,没有加载完成的,则监听 load 和 error
简单示例代码如下,只是讲解思路而已
const unloadImgs = allImgs.filter((img) => !img.complete);
const loadImgs = allImgs.filter((img) => img.complete);
unloadImgs.forEach((item) => {
const originLoad = node.onload;
const originError = node.onerror;
node.onload = function (...args) {
// ... performance.now() 拿到加载完成时间
return originLoad?.apply?.(this, args);
};
node.onerror = function (...args) {
// ... 加载失败,默认是0
return originError?.apply?.(this, args);
};
});
关于 spa 首屏测速的核心计算Demo,大家可以参考一下
https://gitee.com/hoholove/study-code-snippet/blob/master/LOGGER/spaFirstScreen.js
鉴于本人能力有限,难免会有疏漏错误的地方,请大家多多包涵, 如果有任何描述不当的地方,欢迎后台联系本人,领取红包