在 code_pc 项目中,前端需要使用 rrweb 对老师教学内容进行录制,学员可以进行录制回放。为减小录制文件体积,当前的录制策略是先录制一次全量快照,后续录制增量快照,录制阶段实际就是通过 MutationObserver 监听 DOM 元素变化,然后将一个个事件 push 到数组中。
为了进行持久化存储,可以将录制数据压缩后序列化为 JSON 文件。老师会将 JSON 文件放入课件包中,打成压缩包上传到教务系统中。学员回放时,前端会先下载压缩包,通过 JSZip 解压,取到 JSON 文件后,反序列化再解压后,得到原始的录制数据,再传入 rrwebPlayer 实现录制回放。
在项目开发阶段,测试录制都不会太长,因此录制文件体积不大(在几百 kb),回放比较流畅。但随着项目进入测试阶段,模拟长时间上课场景的录制之后,发现录制文件变得很大,达到 10-20 M,QA 同学反映打开学员回放页面的时候,页面明显卡顿,卡顿时间在 20s 以上,在这段时间内,页面交互事件没有任何响应。
页面性能是影响用户体验的主要因素,对于如此长时间的页面卡顿,用户显然是无法接受的。
经过组内沟通后得知,可能导致页面卡顿的主要有两方面因素:前端解压 zip 包,和录制回放文件加载。同事怀疑主要是 zip 包解压的问题,同时希望我尝试将解压过程放到 worker 线程中进行。那么是否确实如同事所说,前端解压 zip 包导致页面卡顿呢?
对于页面卡顿问题,首先想到肯定是线程阻塞引起的,这就需要排查哪里出现长任务。
所谓长任务是指执行耗时在 50ms 以上的任务,大家知道 Chrome 浏览器页面渲染和 V8 引擎用的是一个线程,如果 JS 脚本执行耗时太长,就会阻塞渲染线程,进而导致页面卡顿。
对于 JS 执行耗时分析,这块大家应该都知道使用 performance 面板。在 performance 面板中,通过看火焰图分析 call stack 和执行耗时。火焰图中每一个方块的宽度代表执行耗时,方块叠加的高度代表调用栈的深度。
按照这个思路,我们来看下分析的结果:
[外链图片转存中…(img-nfpBonig_convert/61cbb6f6f8d1d2e7b232fc791ca00e4c.png)
可以看到,replayRRweb 显然是一个长任务,耗时接近 18s ,严重阻塞了主线程。
而 replayRRweb 耗时过长又是因为内部两个调用引起的,分别是左边浅绿色部分和右边深绿色部分。我们来看下调用栈,看看哪里哪里耗时比较严重:
[外链图片转存中…(img-Gthgovert/0302be66a0743642bbb24c0111ba5eb6.png)
熟悉 Vue 源码的同学可能已经看出来了,上面这些耗时比较严重的方法,都是 Vue 内部递归响应式的方法(右边显示这些方法来自 vue.runtime.esm.js)。
为什么这些方法会长时间占用主线程呢?在 Vue 性能优化中有一条:不要将复杂对象丢到 data 里面,否则会 Vue 会深度遍历对象中的属性添加 getter、setter(即使这些数据不需要用于视图渲染),进而导致性能问题。
那么在业务代码中是否有这样的问题呢?我们找到了一段非常可疑的代码:
export default {
data() {
return {
rrWebplayer: null
}
},
mounted() {
bus.$on("setRrwebEvents", (eventPromise) => {
eventPromise.then((res) => {
this.replayRRweb(JSON.parse(res));
})
})
}, methods: {
replayRRweb(eventsRes) {
this.rrWebplayer = new rrwebPlayer({
target: document.getElementById('replayer'),
props: {
events: eventsRes,
unpackFn: unpack,
// ...
}
})
}
}
}
在上面的代码中,创建了一个 rrwebPlayer 实例,并赋值给 rrWebplayer 的响应式数据。在创建实例的时候,还接受了一个 eventsRes 数组,这个数组非常大,包含几万条数据。
这种情况下,如果 Vue 对 rrWebplayer 进行递归响应式,想必非常耗时。因此,我们需要将 rrWebplayer 变为 Non-reactive data(避免 Vue 递归响应式)。
转为 Non-reactive data,主要有三种方法:
数据没有预先定义在 data 选项中,而是在组件实例 created 之后再动态定义 this.rrwebPlayer