H/ z* _! S/ l4 K! l
项目背景在 code_pc 项目中,前端需要使用 rrweb 对老师教学内容进行录制,学员可以进行录制回放为减小录制文件体积,当前的录制策略是先录制一次全量快照,后续录制增量快照,录制阶段实际就是通过 MutationObserver 监听 DOM 元素变化,然后将一个个事件 push 到数组中。
# f' r5 j' T1 `4 H 为了进行持久化存储,可以将录制数据压缩后序列化为 JSON 文件老师会将 JSON 文件放入课件包中,打成压缩包上传到教务系统中学员回放时,前端会先下载压缩包,通过 JSZip 解压,取到 JSON 文件后,反序列化再解压后,得到原始的录制数据,再传入 rrwebPlayer 实现录制回放。 * d+ i: Z" N6 B- ]
发现问题在项目开发阶段,测试录制都不会太长,因此录制文件体积不大(在几百 kb),回放比较流畅但随着项目进入测试阶段,模拟长时间上课场景的录制之后,发现录制文件变得很大,达到 10-20 M,QA 同学反映打开学员回放页面的时候,页面明显卡顿,卡顿时间在 20s 以上,在这段时间内,页面交互事件没有任何响应。
, @7 [2 ]- y4 p 页面性能是影响用户体验的主要因素,对于如此长时间的页面卡顿,用户显然是无法接受的问题排查经过组内沟通后得知,可能导致页面卡顿的主要有两方面因素:前端解压 zip 包,和录制回放文件加载同事怀疑主要是 zip 包解压的问题,同时希望我尝试将解压过程放到 worker 线程中进行。
/ m4 |" \7 V# _4 o: W* t 那么是否确实如同事所说,前端解压 zip 包导致页面卡顿呢?3.1 解决 Vue 递归复杂对象引起的耗时问题对于页面卡顿问题,首先想到肯定是线程阻塞引起的,这就需要排查哪里出现长任务所谓长任务是指执行耗时在 50ms 以上的任务,大家知道 Chrome 浏览器页面渲染和 V8 引擎用的是一个线程,如果 JS 脚本执行耗时太长,就会阻塞渲染线程,进而导致页面卡顿。 1 Y1 e1 V1 Q6 C
对于 JS 执行耗时分析,这块大家应该都知道使用 performance 面板在 performance 面板中,通过看火焰图分析 call stack 和执行耗时火焰图中每一个方块的宽度代表执行耗时,方块叠加的高度代表调用栈的深度。
u$ m, X/ |9 } ^8 w8 C 按照这个思路,我们来看下分析的结果: " F$ N8 Q( I8 d5 S$ R5 D' O
可以看到,replayRRweb 显然是一个长任务,耗时接近 18s ,严重阻塞了主线程而 replayRRweb 耗时过长又是因为内部两个调用引起的,分别是左边浅绿色部分和右边深绿色部分我们来看下调用栈,看看哪里哪里耗时比较严重:。
) c" c) o9 ?$ \! J! i 熟悉 Vue 源码的同学可能已经看出来了,上面这些耗时比较严重的方法,都是 Vue 内部递归响应式的方法(右边显示这些方法来自 vue.runtime.esm.js)为什么这些方法会长时间占用主线程呢?在 Vue 性能优化中有一条:。 3 E3 f% L' U: Q$ F, E* Z" n9 [
不要将复杂对象丢到 data 里面,否则会 Vue 会深度遍历对象中的属性添加 getter、setter(即使这些数据不需要用于视图渲染),进而导致性能问题那么在业务代码中是否有这样的问题呢?我们找到了一段。 9 \/ v3 Y) R# @3 d& F& A
非常可疑的代码:exportdefault{data(){return{rrWebplayer:null}},mounted(){bus.$on("setRrwebEvents",(eventPromise % Q& `+ C2 W; o: m
)=>{eventPromise.then((res)=>{this.replayRRweb(JSON.parse(res));})})},methods:{replayRRweb(eventsRes)
+ k; k4 ~7 a0 m3 B- M; g8 f {this.rrWebplayer=newrrwebPlayer({target:document.getElementById(replayer),props:{events:eventsRes,unpackFn
0 p! b1 w4 E4 a2 ` :unpack,// ...1 P0 C9 G. k! q P& ^/ l
}})}}}在上面的代码中,创建了一个 rrwebPlayer 实例,并赋值给 rrWebplayer 的响应式数据在创建实例的时候,还接受了一个 eventsRes 数组,这个数组非常大,包含几万条数据。
7 q |9 v8 \; w) V& _ 这种情况下,如果 Vue 对 rrWebplayer 进行递归响应式,想必非常耗时因此,我们需要将 rrWebplayer 变为 Non-reactive data(避免 Vue 递归响应式)转为 Non-reactive data,。
+ J: N5 h F4 K: h7 f 主要有三种方法:数据没有预先定义在 data 选项中,而是在组件实例 created 之后再动态定义 this.rrwebPlayer (没有事先进行依赖收集,不会递归响应式);数据预先定义在 data 选项中,但是后续修改状态的时候,对象经过 Object.freeze 处理(让 Vue 忽略该对象的响应式处理); / h1 j, Y- m% W8 E5 P
数据定义在组件实例之外,以模块私有变量形式定义(这种方式要注意内存泄漏问题,Vue 不会在组件卸载的时候销毁状态);这里我们使用第三种方法,将 rrWebplayer 改成 Non-reactive data 试一下: / j1 z& h m, ~! V/ n6 e
letrrWebplayer=null;exportdefault{//...# I h1 f" H$ d [% c9 z
methods:{replayRRweb(eventsRes){rrWebplayer=newrrwebPlayer({target ' A/ t5 I1 M( U4 T# v# X
:document.getElementById(replayer),props:{events:eventsRes,unpackFn:unpack,// ...$ P1 x% b% S2 U, R) g7 M9 B$ z7 b
}})}}}重新加载页面,可以看到这时候页面虽然还卡顿,但是卡顿时间明显缩短到5秒内了。 - y* L1 \+ G2 y2 V: L4 d" ?; }
观察火焰图可知,replayRRweb 调用栈下,递归响应式的调用栈已经消失不见了:
2 q7 a: z' r* B& ?4 @* C 3.2 使用时间分片解决回放文件加载耗时问题但是对于用户来说,这样仍然是不可接受的,我们继续看一下哪里耗时严重: / F; ~0 h" [. T+ @ y/ g1 L
可以看到问题还是出在 replayRRweb 这个函数里面,到底是哪一步呢:
p; `- k: B6 T, @! k 那么 unpack 耗时的问题怎么解决呢?由于 rrweb 录制回放 需要进行 dom 操作,必须在主线程运行,不能使用 worker 线程(获取不到 dom API)对于主线程中的长任务,很容易想到的就是通过 时间分片,将长任务分割成一个个小任务,通过事件循环进行任务调度,在主线程空闲且当前帧有空闲时间的时候,执行任务,否则就渲染下一帧。 5 @* ]0 y; C0 x7 G
方案确定了,下面就是选择哪个 API 和怎么分割任务的问题这里有同学可能会提出疑问,为什么 unpack 过程不能放到 worker 线程执行,worker线程中对数据解压之后返回给主线程加载并回放,这样不就可以实现非阻塞了吗?
8 M& ]3 s7 n/ R0 E* c. v 如果仔细想一想,当 worker 线程中进行 unpack,主线程必须等待,直到数据解压完成才能进行回放,这跟直接在主线程中 unpack没有本质区别worker 线程只有在有若干并行任务需要执行的时候,才具有性能优势。
" o* b4 x8 T2 `$ V% } 提到时间分片,很多同学可能都会想到 requestIdleCallback 这个 APIrequestIdleCallback 可以在浏览器渲染一帧的空闲时间执行任务,从而不阻塞页面渲染、UI 交互事件等。 D. u8 F, ^4 O! ]
目的是为了解决当任务需要长时间占用主进程,导致更高优先级任务(如动画或事件任务),无法及时响应,而带来的页面丢帧(卡死)情况因此,requestIdleCallback 的定位是处理不重要且不紧急的任务。 . Z0 S9 a6 M+ x' h( M8 ]
requestIdleCallback 不是每一帧结束都会执行,只有在一帧的 16.6ms中渲染任务结束且还有剩余时间,才会执行这种情况下,下一帧需要在 requestIdleCallback 执行结束才能继续渲染,所以。
! {) l* [, w1 a. h! K requestIdleCallback 每个 Tick 执行不要超过30ms,如果长时间不将控制权交还给浏览器,会影响下一帧的渲染,导致页面出现卡顿和事件响应不及时requestIdleCallback 参数说明:。 g& t d/ W# T' ~( R5 z
// 接受回调任务& x9 { ]+ R) q- K4 ]
typeRequestIdleCallback=(cb deadline eadline)=>void,options?:Options)=>number// 回调函数接受的参数
4 T# |2 A/ [0 D9 H5 u" ?: A , ~" }6 N# r1 o( F
typeDeadline={timeRemaining )=>number// 当前剩余的可用时间即该帧剩余时间
3 ?& E. }5 O, n3 Y8 I, P( ] didTimeout:boolean// 是否超时
$ q. L% L7 d( F }我们可以用 requestIdleCallback 写个简单的 demo:。 p6 V6 Y* |0 }1 a! ~- C
// 一万个任务,这里使用 ES2021 数值分隔符: R/ Y( W3 l T$ s. h, X
constunit=10_000;// 单个任务需要处理如下
2 D/ S# E$ _. v. ~ constonOneUnit=()=>{for(leti=0;i<=500_000;
% F# {5 s6 p; E: B& m) { i++){}}// 每个任务预留执行时间1 Q. M B! V3 j7 F! v$ I! a( U( \
1msconstFREE_TIME=1;// 执行到第几个任务 let _u = 0;2 N# T' `! x7 k! Y
functioncb(deadline){// 当任务还没有被处理完 & 一帧还有的空闲时间 > 1ms
$ M4 f( N: F0 n! R. G, i3 Z ; Q5 K. T7 `/ v2 K+ q3 J: q1 \
while(_uFREE_TIME){onOneUnit();_u++;}// 任务干完, J& [9 {7 r8 j- v+ V6 K* Y5 Q& G
if(_u>=unit)return;// 任务没完成, 继续等空闲执行9 _: S8 \- A) `3 Q$ f
4 Q; t* N' s) b5 i
window.requestIdleCallback(cb)}window.requestIdleCallback(cb)这样看来 requestIdleCallback 似乎很完美,能否直接用在实际业务场景中呢?答案是不行。 ' i+ M% A- w4 l) r
我们查阅 MDN 文档就可以发现,requestIdleCallback 还只是一个实验性 API,浏览器兼容性一般: " f# q& o; I+ ~, P
查阅 caniuse 也得到类似的结论,所有 IE 浏览器不支持,safari 默认情况下不启用:
e, H, p5 R% ] | 而且还有一个问题,requestIdleCallback 触发频率不稳定,受很多因素影响经过实际测试,FPS 只有 20ms 左右,正常情况下渲染一帧时长控制在16.67ms 为了解决上述问题,在 React Fiber 架构中,内部自行实现了一套 requestIdleCallback 机制:。
# ^1 R, o9 p X& U2 S6 Y 使用 requestAnimationFrame 获取渲染某一帧的开始时间,进而计算出当前帧到期时间点;使用 performance.now() 实现微秒级高精度时间戳,用于计算当前帧剩余时间;使用 MessageChannel 零延迟宏任务实现任务调度,如使用 setTimeout() 则有一个最小的时间阈值,一般是 4ms;
5 h* T" l+ _5 F+ w- |4 V 按照上述思路,我们可以简单实现一个 requestIdleCallback 如下:// 当前帧到期时间点
8 _7 x0 B) g* a letdeadlineTime;// 回调任务
5 k g- b) \/ }7 }: w s) p5 H letcallback;// 使用宏任务进行任务调度
( r1 E* {/ y+ u& R8 S 0 O. X) S! ?: H& Y2 v4 C( G2 s
constchannel=newMessageChannel();constport1=channel.port1;constport2=channel.port2;// 接收并执行宏任务" l4 t W, K% N1 ?' q, b) Q
port2. , Q7 M1 D& s; t1 L T. s% N
onmessage=()=>{// 判断当前帧是否还有空闲,即返回的是剩下的时间
. P2 ]2 A* m% d. L1 L! N8 U: Q, O consttimeRemaining=()=>deadlineTime-performance.now();const_timeRemain ! B5 Z L2 l c' o
=timeRemaining();// 有空闲时间 且 有回调任务
+ i q4 N' }* S if(_timeRemain>0&&callback){constdeadline={timeRemaining,didTimeout " Q3 a8 K4 }/ [
:_timeRemain<0,};// 执行回调5 i; D8 [* E0 e/ E$ F. {
callback(deadline);}};window.requestIdleCallback=function(cb){requestAnimationFrame & p; o+ ~4 X/ b$ X! Z1 m! i
((rafTime)=>{// 结束时间点 = 开始时间点 + 一帧用时16.667ms# l5 A6 ?8 _7 G" K4 Q. e3 C& O; s
deadlineTime=rafTime+16.667;// 保存任务 T' D1 |9 y) t1 u. I
callback=cb;// 发送个宏任务) h0 H" X: J6 m0 n, l* }
$ I& s' a c' i3 L$ T
port1.postMessage(null);});};在项目中,考虑到 api fallback 方案、以及支持取消任务功能(上面的代码比较简单,仅仅只有添加任务功能,无法取消任务),最终选用 React 官方源码实现。 0 [( z( s: `3 E% J
那么 API 的问题解决了,剩下就是怎么分割任务的问题查阅 rrweb 文档得知,rrWebplayer 实例上提供一个 addEvent 方法,用于动态添加回放数据,可用于实时直播等场景按照这个思路,我们可以将录制回放数据进行分片,分多次调用 addEvent 添加。 ) P* ^% g$ m6 O0 ]( W2 s8 K9 }0 b
import{requestHostCallback,cancelHostCallback,}from"@/utils/SchedulerHostConfig";exportdefault{// ...- E, k; N0 _! g* _$ V* |1 G' ]
$ h6 A# y# _& J: _+ } methods:{replayRRweb(eventsRes=[]){constPACKAGE_SIZE=100;// 分片大小7 x1 \; w/ T5 x" O" ^6 }2 e- D
constLEN=eventsRes.length;// 录制回放数据总条数
. [- I. [9 G) y4 [0 x. i `
) d( ^0 m3 e8 O4 ~# x/ | constSLICE_NUM=Math.ceil(LEN/PACKAGE_SIZE);// 分片数量9 `4 O2 t/ m, [; i/ y; G7 r/ ~
rrWebplayer=newrrwebPlayer({target:document.getElementById " W+ \" X* ]0 @# | L) d+ T
("replayer"),props:{// 预加载分片
. Q+ h0 E5 N" _9 o" q# R events:eventsRes.slice(0,PACKAGE_SIZE),unpackFn:unpack,},});// 如有任务先取消之前的任务9 u c" u. ~$ h. F& R; q
% w% r& w! |- o5 s1 R9 j cancelHostCallback();constcb=()=>{// 执行到第几个任务
6 x% _6 P! J( @5 c/ y; j1 U let_u=1;return()=>{// 每一次执行的任务
7 a1 R/ g u9 a2 f" u1 V8 V( z // 注意数组的 forEach 没办法从中间某个位置开始遍历
7 C: s, J6 V4 @& G 4 }" ?0 d& ^1 O
for(letj=_u*PACKAGE_SIZE;j=LEN)break;rrWebplayer.addEvent(eventsRes[j]); $ I$ C1 h' G& C) N6 E
}_u++;// 返回任务是否完成
+ J/ W& n2 {' ?7 T0 L4 w return_u{// 加载完毕回调
! |# z. k( E/ C- ? x });},},};注意最后加载完毕回调,源码中不提供这个功能,是本人自行修改源码加上的。
' P' K% j) T; A; l 按照上面的方案,我们重新加载学员回放页面看看,现在已经基本察觉不到卡顿了我们找一个 20M 大文件加载,观察下火焰图可知,录制文件加载任务已经被分割为一条条很细的小任务,每个任务执行的时间在 10-20ms 左右,已经不会明显阻塞主线程了:。
+ ?6 ?, f8 \. e' n 优化后,页面仍有卡顿,这是因为我们拆分任务的粒度是 100 条,这种情况下加载录制回放仍有压力,我们观察 fps 只有十几,会有卡顿感我们继续将粒度调整到 10 条,这时候页面加载明显流畅了,基本上 fps 能达到 50 以上,但录制回放加载的总时间略微变长了。 4 |: I, S) v( o% Q i9 p6 s) F- `
使用时间分片方式可以避免页面卡死,但是录制回放的加载平均还需要几秒钟时间,部分大文件可能需要十秒左右,我们在这种耗时任务处理的时候加一个 loading 效果,以防用户在录制文件加载完成之前就开始播放有同学可能会问,既然都加 loading 了,为什么还要时间分片呢?假如不进行时间分片,由于 JS 脚本一直占用主线程,阻塞 UI 线程,这个 loading 动画是不会展示的,只有通过时间分片的方式,把主线程让出来,才能让一些优先级更高的任务(例如 UI 渲染、页面交互事件)执行,这样 loading 动画就有机会展示了。 6 B. E# o, ?. W; y8 ]! ?
进一步优化使用时间分片并不是没有缺点,正如上面提到的,录制回放加载的总时间略微变长了但是好在 10-20M 录制文件只出现在测试场景中,老师实际上课录制的文件都在 10M 以下,经过测试录制回放可以在 2s 左右就加载完毕,学员不会等待很久。
8 q) X2 b7 e5 M2 o. F 假如后续录制文件很大,需要怎么优化呢?之前提到的 unpack 过程,我们没有放到 worker 线程执行,这是因为考虑到放在 worker 线程,主线程还得等待 worker 线程执行完毕,跟放在主线程执行没有区别。 1 @) k' ~+ @3 S
但是受到时间分片启发,我们可以将 unpack 的任务也进行分片处理,然后根据 navigator.hardwareConcurrency 这个 API,开启多线程(线程数等于用户 CPU 逻辑内核数),以并行的方式执行 unpack ,由于利用多核 CPU 性能,应该能够显著提升录制文件加载速率。
5 m" u- A( C1 |0 J! A" w 总结这篇文章中,我们通过 performance 面板的火焰图分析了调用栈和执行耗时,进而排查出两个引起性能问题的因素:Vue 复杂对象递归响应式,和录制回放文件加载对于 Vue 复杂对象递归响应式引起的耗时问题,本文提出的解决方案是,将该对象转为非响应式数据。 8 w6 p( T0 h& \# T4 g7 b- J, `
对于录制回放文件加载引起的耗时问题,本文提出的方案是使用时间分片由于 requestIdleCallback API 的兼容性及触发频率不稳定问题,本文参考了 React 17 源码分析了如何实现 requestIdleCallback 调度,并最终采用 React 源码实现了时间分片。 6 s- O+ T8 h/ s3 g2 W5 e
经过实际测试,优化前页面卡顿 20s 左右,优化后已经察觉不到卡顿,fps 能达到 50 以上但是使用时间分片之后,录制文件加载时间略微变长了后续的优化方向是将 unpack 过程进行分片,开启多线程,以并行方式执行 unpack,充分利用多核 CPU 性能。
8 p! {/ t1 t1 a4 Y$ P 参考· vue-9-perf-secrets· React Fiber很难?六个问题助你理解· requestIdleCallback - MDN· requestIdleCallback - caniuse ( I" S" N% S h3 G& _
· 实现React requestIdleCallback调度能力详情可点击这里查看 7 x; Y8 d3 K2 M5 y% k, Q7 A
- M/ X2 U' d) S' a9 e* X [. w
`- j2 Q- L6 D' y& a
: l! ^1 j0 t% Q( q$ b/ @) F, F. E2 H
|