共计 7086 个字符,预计需要花费 18 分钟才能阅读完成。
我的项目背景
在 code_pc 我的项目中,前端须要应用 rrweb 对老师教学内容进行录制,学员能够进行录制回放。为减小录制文件体积,以后的录制策略是先录制一次全量快照,后续录制增量快照,录制阶段理论就是通过 MutationObserver 监听 DOM 元素变动,而后将一个个事件 push 到数组中。
为了进行长久化存储,能够将录制数据压缩后序列化为 JSON 文件。老师会将 JSON 文件放入课件包中,打成压缩包上传到教务零碎中。学员回放时,前端会先下载压缩包,通过 JSZip 解压,取到 JSON 文件后,反序列化再解压后,失去原始的录制数据,再传入 rrwebPlayer 实现录制回放。
发现问题
在我的项目开发阶段,测试录制都不会太长,因而录制文件体积不大(在几百 kb),回放比拟晦涩。但随着我的项目进入测试阶段,模仿长时间上课场景的录制之后,发现录制文件变得很大,达到 10-20 M,QA 同学反映关上学员回放页面的时候,页面显著卡顿,卡顿工夫在 20s 以上,在这段时间内,页面交互事件没有任何响应。
页面性能是影响用户体验的次要因素,对于如此长时间的页面卡顿,用户显然是无奈承受的。
问题排查
通过组内沟通后得悉,可能导致页面卡顿的次要有两方面因素:前端解压 zip 包,和录制回放文件加载。共事狐疑次要是 zip 包解压的问题,同时心愿我尝试将解压过程放到 worker 线程中进行。那么是否的确如共事所说,前端解压 zip 包导致页面卡顿呢?
3.1 解决 Vue 递归简单对象引起的耗时问题
对于页面卡顿问题,首先想到必定是线程阻塞引起的,这就须要排查哪里呈现长工作。
所谓长工作是指执行耗时在 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 调用栈下,递归响应式的调用栈曾经隐没不见了:
3.2 应用工夫分片解决回放文件加载耗时问题
然而对于用户来说,这样依然是不可承受的,咱们持续看一下哪里耗时重大:
能够看到问题还是出在 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 机制:
- 应用 requestAnimationFrame 获取渲染某一帧的开始工夫,进而计算出以后帧到期工夫点;
- 应用 performance.now() 实现微秒级高精度工夫戳,用于计算以后帧剩余时间;
- 应用 MessageChannel 零提早宏工作实现任务调度,如应用 setTimeout() 则有一个最小的工夫阈值,个别是 4ms;
依照上述思路,咱们能够简略实现一个 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 调度能力
详情可点击这里查看