在 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 和执行耗时。火焰图中每一个方块的宽度代表执行耗时,方块叠加的高度代表调用栈的深度。
按照这个思路,我们来看下分析的结果:
可以看到,replayRRweb 显然是一个长任务,耗时接近 18s ,严重阻塞了主线程。
而 replayRRweb 耗时过长又是因为内部两个调用引起的,分别是左边浅绿色部分和右边深绿色部分。我们来看下调用栈,看看哪里哪里耗时比较严重:
熟悉 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 (没有事先进行依赖收集,不会递归响应式);
数据预先定义在 data 选项中,但是后续修改状态的时候,对象经过 Object.freeze 处理(让 Vue 忽略该对象的响应式处理);
数据定义在组件实例之外,以模块私有变量形式定义(这种方式要注意内存泄漏问题,Vue 不会在组件卸载的时候销毁状态);
这里我们使用第三种方法,将 rrWebplayer 改成 Non-reactive data 试一下:
let rrWebplayer = null;export default {
//...
methods: {
replayRRweb(eventsRes) {
rrWebplayer = new rrwebPlayer({
target: document.getElementById('replayer'),
props: {
events: eventsRes,
unpackFn: unpack,
// ...
}
})
}
}
}
重新加载页面,可以看到这时候页面虽然还卡顿,但是卡顿时间明显缩短到5秒内了。观察火焰图可知,replayRRweb 调用栈下,递归响应式的调用栈已经消失不见了:
但是对于用户来说,这样仍然是不可接受的,我们继续看一下哪里耗时严重:
可以看到问题还是出在 replayRRweb 这个函数里面,到底是哪一步呢:
那么 unpack 耗时的问题怎么解决呢?
由于 rrweb 录制回放 需要进行 dom 操作,必须在主线程运行,不能使用 worker 线程(获取不到 dom API)。对于主线程中的长任务,很容易想到的就是通过 时间分片,将长任务分割成一个个小任务,通过事件循环进行任务调度,在主线程空闲且当前帧有空闲时间的时候,执行任务,否则就渲染下一帧。方案确定了,下面就是选择哪个 API 和怎么分割任务的问题。
这里有同学可能会提出疑问,为什么 unpack 过程不能放到 worker 线程执行,worker
线程中对数据解压之后返回给主线程加载并回放,这样不就可以实现非阻塞了吗?如果仔细想一想,当 worker 线程中进行 unpack,主线程必须等待,直到数据解压完成才能进行回放,这跟直接在主线程中 unpack
没有本质区别。worker 线程只有在有若干并行任务需要执行的时候,才具有性能优势。
提到时间分片,很多同学可能都会想到 requestIdleCallback 这个 API。requestIdleCallback 可以在浏览器渲染一帧的空闲时间执行任务,从而不阻塞页面渲染、UI 交互事件等。目的是为了解决当任务需要长时间占用主进程,导致更高优先级任务(如动画或事件任务),无法及时响应,而带来的页面丢帧(卡死)情况。因此,requestIdleCallback 的定位是处理不重要且不紧急的任务。
requestIdleCallback 不是每一帧结束都会执行,只有在一帧的 16.6ms
中渲染任务结束且还有剩余时间,才会执行。这种情况下,下一帧需要在 requestIdleCallback 执行结束才能继续渲染,所以
requestIdleCallback 每个 Tick 执行不要超过
30ms,如果长时间不将控制权交还给浏览器,会影响下一帧的渲染,导致页面出现卡顿和事件响应不及时。
requestIdleCallback 参数说明:
// 接受回调任务
type RequestIdleCallback = (cb: (deadline: Deadline) => void, options?: Options) => number
// 回调函数接受的参数
type Deadline = {
timeRemaining: () => number // 当前剩余的可用时间。即该帧剩余时间。
didTimeout: boolean // 是否超时。
}
我们可以用 requestIdleCallback 写个简单的 demo:
// 一万个任务,这里使用 ES2021 数值分隔符
const unit = 10_000;
// 单个任务需要处理如下
const onOneUnit = () => {
for (let i = 0; i <= 500_000; i++) {}
}
// 每个任务预留执行时间
1msconst FREE_TIME = 1;
// 执行到第几个任务
let _u = 0;
function cb(deadline) {
// 当任务还没有被处理完 & 一帧还有的空闲时间 > 1ms
while (_u < unit && deadline.timeRemaining() >FREE_TIME) {
onOneUnit();
_u ++;
}
// 任务干完
if (_u >= unit) return;
// 任务没完成, 继续等空闲执行
window.requestIdleCallback(cb)
}
window.requestIdleCallback(cb)
这样看来 requestIdleCallback 似乎很完美,能否直接用在实际业务场景中呢?答案是不行。我们查阅 MDN 文档就可以发现,requestIdleCallback 还只是一个实验性 API,浏览器兼容性一般:
查阅 caniuse 也得到类似的结论,所有 IE 浏览器不支持,safari 默认情况下不启用:
而且还有一个问题,requestIdleCallback 触发频率不稳定,受很多因素影响。经过实际测试,FPS 只有 20ms 左右,正常情况下渲染一帧时长控制在16.67ms 。
为了解决上述问题,在 React Fiber 架构中,内部自行实现了一套 requestIdleCallback 机制:
按照上述思路,我们可以简单实现一个 requestIdleCallback 如下:
// 当前帧到期时间点
let deadlineTime;
// 回调任务
let callback;
// 使用宏任务进行任务调度
const channel = new MessageChannel();
const port1 = channel.port1;
const port2 = channel.port2;
// 接收并执行宏任务
port2.onmessage = () => {
// 判断当前帧是否还有空闲,即返回的是剩下的时间
const timeRemaining = () => deadlineTime - performance.now();
const _timeRemain = timeRemaining();
// 有空闲时间 且 有回调任务
if (_timeRemain > 0 && callback) {
const deadline = {
timeRemaining,
didTimeout: _timeRemain < 0,
};
// 执行回调
callback(deadline);
}
};
window.requestIdleCallback = function (cb) {
requestAnimationFrame((rafTime) => {
// 结束时间点 = 开始时间点 + 一帧用时16.667ms
deadlineTime = rafTime + 16.667;
// 保存任务
callback = cb;
// 发送个宏任务
port1.postMessage(null);
});
};
在项目中,考虑到 api fallback 方案、以及支持取消任务功能(上面的代码比较简单,仅仅只有添加任务功能,无法取消任务),最终选用 React 官方源码实现。
那么 API 的问题解决了,剩下就是怎么分割任务的问题。
查阅 rrweb 文档得知,rrWebplayer 实例上提供一个 addEvent 方法,用于动态添加回放数据,可用于实时直播等场景。按照这个思路,我们可以将录制回放数据进行分片,分多次调用 addEvent 添加。
import {
requestHostCallback, cancelHostCallback,
}
from "@/utils/SchedulerHostConfig";
export default {
// ...
methods: {
replayRRweb(eventsRes = []) {
const PACKAGE_SIZE = 100;
// 分片大小
const LEN = eventsRes.length;
// 录制回放数据总条数
const SLICE_NUM = Math.ceil(LEN / PACKAGE_SIZE);
// 分片数量
rrWebplayer = new rrwebPlayer({
target: document.getElementById("replayer"),
props: {
// 预加载分片
events: eventsRes.slice(0, PACKAGE_SIZE),
unpackFn: unpack,
},
});
// 如有任务先取消之前的任务
cancelHostCallback();
const cb = () => {
// 执行到第几个任务
let _u = 1;
return () => {
// 每一次执行的任务
// 注意数组的 forEach 没办法从中间某个位置开始遍历
for (let j = _u * PACKAGE_SIZE; j < (_u + 1) * PACKAGE_SIZE; j++) {
if (j >= LEN) break;
rrWebplayer.addEvent(eventsRes[j]);
}
_u++;
// 返回任务是否完成
return _u < SLICE_NUM;
};
};
requestHostCallback(cb(), () => {
// 加载完毕回调
});
},
},
};
注意最后加载完毕回调,源码中不提供这个功能,是本人自行修改源码加上的。
按照上面的方案,我们重新加载学员回放页面看看,现在已经基本察觉不到卡顿了。我们找一个 20M 大文件加载,观察下火焰图可知,录制文件加载任务已经被分割为一条条很细的小任务,每个任务执行的时间在 10-20ms 左右,已经不会明显阻塞主线程了:
优化后,页面仍有卡顿,这是因为我们拆分任务的粒度是 100 条,这种情况下加载录制回放仍有压力,我们观察 fps 只有十几,会有卡顿感。我们继续将粒度调整到 10 条,这时候页面加载明显流畅了,基本上 fps 能达到 50 以上,但录制回放加载的总时间略微变长了。使用时间分片方式可以避免页面卡死,但是录制回放的加载平均还需要几秒钟时间,部分大文件可能需要十秒左右,我们在这种耗时任务处理的时候加一个 loading 效果,以防用户在录制文件加载完成之前就开始播放。
有同学可能会问,既然都加 loading 了,为什么还要时间分片呢?假如不进行时间分片,由于 JS 脚本一直占用主线程,阻塞 UI 线程,这个 loading 动画是不会展示的,只有通过时间分片的方式,把主线程让出来,才能让一些优先级更高的任务(例如 UI 渲染、页面交互事件)执行,这样 loading 动画就有机会展示了。
使用时间分片并不是没有缺点,正如上面提到的,录制回放加载的总时间略微变长了。但是好在 10-20M 录制文件只出现在测试场景中,老师实际上课录制的文件都在 10M 以下,经过测试录制回放可以在 2s 左右就加载完毕,学员不会等待很久。
假如后续录制文件很大,需要怎么优化呢?之前提到的 unpack 过程,我们没有放到 worker 线程执行,这是因为考虑到放在 worker 线程,主线程还得等待 worker 线程执行完毕,跟放在主线程执行没有区别。但是受到时间分片启发,我们可以将 unpack 的任务也进行分片处理,然后根据 navigator.hardwareConcurrency 这个 API,开启多线程(线程数等于用户 CPU 逻辑内核数),以并行的方式执行 unpack ,由于利用多核 CPU 性能,应该能够显著提升录制文件加载速率。
这篇文章中,我们通过 performance 面板的火焰图分析了调用栈和执行耗时,进而排查出两个引起性能问题的因素:Vue 复杂对象递归响应式,和录制回放文件加载。
对于 Vue 复杂对象递归响应式引起的耗时问题,本文提出的解决方案是,将该对象转为非响应式数据。对于录制回放文件加载引起的耗时问题,本文提出的方案是使用时间分片。
由于 requestIdleCallback API 的兼容性及触发频率不稳定问题,本文参考了 React 17 源码分析了如何实现 requestIdleCallback 调度,并最终采用 React 源码实现了时间分片。经过实际测试,优化前页面卡顿 20s 左右,优化后已经察觉不到卡顿,fps 能达到 50 以上。但是使用时间分片之后,录制文件加载时间略微变长了。后续的优化方向是将 unpack 过程进行分片,开启多线程,以并行方式执行 unpack,充分利用多核 CPU 性能。
参考
· vue-9-perf-secrets
· React Fiber很难?六个问题助你理解
· requestIdleCallback – MDN
· requestIdleCallback – caniuse
· 实现React requestIdleCallback调度能力
详情可点击这里查看
if,size_20,color_FFFFFF,t_70,g_se,x_16)