找回密码
 加入怎通
查看: 246|回复: 5

前端技术分享:页面性能优化问题复盘(页面的性能优化)

[复制链接]
我来看看 发表于 2023-03-15 15:44:04 | 显示全部楼层 |阅读模式
. u. [9 }$ a' w5 a1 X, g: t1 O

项目背景在 code_pc 项目中,前端需要使用 rrweb 对老师教学内容进行录制,学员可以进行录制回放为减小录制文件体积,当前的录制策略是先录制一次全量快照,后续录制增量快照,录制阶段实际就是通过 MutationObserver 监听 DOM 元素变化,然后将一个个事件 push 到数组中。

+ @# h, H# ]! T5 l

为了进行持久化存储,可以将录制数据压缩后序列化为 JSON 文件老师会将 JSON 文件放入课件包中,打成压缩包上传到教务系统中学员回放时,前端会先下载压缩包,通过 JSZip 解压,取到 JSON 文件后,反序列化再解压后,得到原始的录制数据,再传入 rrwebPlayer 实现录制回放。

# G0 `" _5 A! b9 w. N! s4 T9 R

发现问题在项目开发阶段,测试录制都不会太长,因此录制文件体积不大(在几百 kb),回放比较流畅但随着项目进入测试阶段,模拟长时间上课场景的录制之后,发现录制文件变得很大,达到 10-20 M,QA 同学反映打开学员回放页面的时候,页面明显卡顿,卡顿时间在 20s 以上,在这段时间内,页面交互事件没有任何响应。

) K. G# V3 @2 H5 D8 W4 B& }

页面性能是影响用户体验的主要因素,对于如此长时间的页面卡顿,用户显然是无法接受的问题排查经过组内沟通后得知,可能导致页面卡顿的主要有两方面因素:前端解压 zip 包,和录制回放文件加载同事怀疑主要是 zip 包解压的问题,同时希望我尝试将解压过程放到 worker 线程中进行。

, Y0 p) F( h* U0 }3 D2 @$ `0 D

那么是否确实如同事所说,前端解压 zip 包导致页面卡顿呢?3.1 解决 Vue 递归复杂对象引起的耗时问题对于页面卡顿问题,首先想到肯定是线程阻塞引起的,这就需要排查哪里出现长任务所谓长任务是指执行耗时在 50ms 以上的任务,大家知道 Chrome 浏览器页面渲染和 V8 引擎用的是一个线程,如果 JS 脚本执行耗时太长,就会阻塞渲染线程,进而导致页面卡顿。

1 p6 q$ H% Y8 K

对于 JS 执行耗时分析,这块大家应该都知道使用 performance 面板在 performance 面板中,通过看火焰图分析 call stack 和执行耗时火焰图中每一个方块的宽度代表执行耗时,方块叠加的高度代表调用栈的深度。

. O- s* g7 K/ X' \' l

按照这个思路,我们来看下分析的结果:

, v2 y! A/ u7 W

可以看到,replayRRweb 显然是一个长任务,耗时接近 18s ,严重阻塞了主线程而 replayRRweb 耗时过长又是因为内部两个调用引起的,分别是左边浅绿色部分和右边深绿色部分我们来看下调用栈,看看哪里哪里耗时比较严重:。

5 c$ V3 c( T& M& ?* E

熟悉 Vue 源码的同学可能已经看出来了,上面这些耗时比较严重的方法,都是 Vue 内部递归响应式的方法(右边显示这些方法来自 vue.runtime.esm.js)为什么这些方法会长时间占用主线程呢?在 Vue 性能优化中有一条:。

$ s& \ d! l# I* d

不要将复杂对象丢到 data 里面,否则会 Vue 会深度遍历对象中的属性添加 getter、setter(即使这些数据不需要用于视图渲染),进而导致性能问题那么在业务代码中是否有这样的问题呢?我们找到了一段。

7 g* D. S" Y) T s5 Z( \

非常可疑的代码:exportdefault{data(){return{rrWebplayer:null}},mounted(){bus.$on("setRrwebEvents",(eventPromise

% H! R! p& [8 h/ n; _

)=>{eventPromise.then((res)=>{this.replayRRweb(JSON.parse(res));})})},methods:{replayRRweb(eventsRes)

8 b' ?' f0 u4 f' o) P) O7 Q; C

{this.rrWebplayer=newrrwebPlayer({target:document.getElementById(replayer),props:{events:eventsRes,unpackFn

; D* q& Q) A3 z" h! t$ _3 G, n

:unpack,// .../ m+ u0 K8 V! t( ` }})}}}在上面的代码中,创建了一个 rrwebPlayer 实例,并赋值给 rrWebplayer 的响应式数据在创建实例的时候,还接受了一个 eventsRes 数组,这个数组非常大,包含几万条数据。

( v3 a5 J. A8 }' N2 E" K1 L3 ] B+ Q

这种情况下,如果 Vue 对 rrWebplayer 进行递归响应式,想必非常耗时因此,我们需要将 rrWebplayer 变为 Non-reactive data(避免 Vue 递归响应式)转为 Non-reactive data,。

) x4 W6 T, E" Y4 `+ P2 T, U" h

主要有三种方法:数据没有预先定义在 data 选项中,而是在组件实例 created 之后再动态定义 this.rrwebPlayer (没有事先进行依赖收集,不会递归响应式);数据预先定义在 data 选项中,但是后续修改状态的时候,对象经过 Object.freeze 处理(让 Vue 忽略该对象的响应式处理);

- {! r! }" H9 T% \ c

数据定义在组件实例之外,以模块私有变量形式定义(这种方式要注意内存泄漏问题,Vue 不会在组件卸载的时候销毁状态);这里我们使用第三种方法,将 rrWebplayer 改成 Non-reactive data 试一下:

& _% J! ]% P! }; @5 X

letrrWebplayer=null;exportdefault{//...: A& a' C( `/ a/ k3 m4 J4 j methods:{replayRRweb(eventsRes){rrWebplayer=newrrwebPlayer({target

0 a" e1 }3 s0 _( s. d

:document.getElementById(replayer),props:{events:eventsRes,unpackFn:unpack,// ... + n$ s* W" Q. t" ^ }})}}}重新加载页面,可以看到这时候页面虽然还卡顿,但是卡顿时间明显缩短到5秒内了。

c4 Q- S- ^9 M! G) x0 v% e5 `

观察火焰图可知,replayRRweb 调用栈下,递归响应式的调用栈已经消失不见了:

8 M6 q5 K6 K- j- U" H, e

3.2 使用时间分片解决回放文件加载耗时问题但是对于用户来说,这样仍然是不可接受的,我们继续看一下哪里耗时严重:

9 ~' z, s, x' U* G( q' ~7 p

可以看到问题还是出在 replayRRweb 这个函数里面,到底是哪一步呢:

; ^% o6 |4 d) }, b- \+ `6 j3 ]

那么 unpack 耗时的问题怎么解决呢?由于 rrweb 录制回放 需要进行 dom 操作,必须在主线程运行,不能使用 worker 线程(获取不到 dom API)对于主线程中的长任务,很容易想到的就是通过 时间分片,将长任务分割成一个个小任务,通过事件循环进行任务调度,在主线程空闲且当前帧有空闲时间的时候,执行任务,否则就渲染下一帧。

/ e- c) V4 E* f0 Q

方案确定了,下面就是选择哪个 API 和怎么分割任务的问题这里有同学可能会提出疑问,为什么 unpack 过程不能放到 worker 线程执行,worker线程中对数据解压之后返回给主线程加载并回放,这样不就可以实现非阻塞了吗?

; B2 d" |: a$ H3 H8 O f5 t* e3 Y) N

如果仔细想一想,当 worker 线程中进行 unpack,主线程必须等待,直到数据解压完成才能进行回放,这跟直接在主线程中 unpack没有本质区别worker 线程只有在有若干并行任务需要执行的时候,才具有性能优势。

( [! O( w z# R" m. W

提到时间分片,很多同学可能都会想到 requestIdleCallback 这个 APIrequestIdleCallback 可以在浏览器渲染一帧的空闲时间执行任务,从而不阻塞页面渲染、UI 交互事件等。

; [( ~* M0 a0 e* [

目的是为了解决当任务需要长时间占用主进程,导致更高优先级任务(如动画或事件任务),无法及时响应,而带来的页面丢帧(卡死)情况因此,requestIdleCallback 的定位是处理不重要且不紧急的任务。

' ]! F) y3 i3 Y% o+ n% U

requestIdleCallback 不是每一帧结束都会执行,只有在一帧的 16.6ms中渲染任务结束且还有剩余时间,才会执行这种情况下,下一帧需要在 requestIdleCallback 执行结束才能继续渲染,所以。

6 [6 \9 u9 ?1 f" G9 a/ c) Z6 q

requestIdleCallback 每个 Tick 执行不要超过30ms,如果长时间不将控制权交还给浏览器,会影响下一帧的渲染,导致页面出现卡顿和事件响应不及时requestIdleCallback 参数说明:。

. s( V3 J3 V8 M( s7 s6 u6 ^8 h

// 接受回调任务( ?7 n' v) L' q* B# g) O/ u0 ] typeRequestIdleCallback=(cbdeadlineeadline)=>void,options?:Options)=>number// 回调函数接受的参数: F Y0 p9 Z+ C$ V' W3 J) u

, ?# R6 U1 w$ Q0 h% [

typeDeadline={timeRemaining)=>number// 当前剩余的可用时间即该帧剩余时间 . B1 K1 j. F- Y( j( s6 a didTimeout:boolean// 是否超时- W2 d+ H! o" d2 p3 i+ R4 R! L }我们可以用 requestIdleCallback 写个简单的 demo:。

" t# v$ l! z2 p, F C: ]

// 一万个任务,这里使用 ES2021 数值分隔符 8 l6 l( u# B2 L" {' | constunit=10_000;// 单个任务需要处理如下 " Z" ?! G& p# b( h* z# L3 W constonOneUnit=()=>{for(leti=0;i<=500_000;

4 M- h7 C+ i6 u

i++){}}// 每个任务预留执行时间1 d+ z# b8 M4 `# d 1msconstFREE_TIME=1;// 执行到第几个任务 let _u = 0;# a; }* g( j* k/ U& ] functioncb(deadline){// 当任务还没有被处理完 & 一帧还有的空闲时间 > 1ms 8 I, }2 W2 g, P* o/ l- D

- d8 W1 `/ _% m" u6 E$ G+ \3 X

while(_uFREE_TIME){onOneUnit();_u++;}// 任务干完8 V7 m( a. g$ w/ x2 e: o% J+ E if(_u>=unit)return;// 任务没完成, 继续等空闲执行 ' U/ d0 [3 ]1 r+ Z+ j0 U

" q" ^* ?* x+ L' g- u8 O

window.requestIdleCallback(cb)}window.requestIdleCallback(cb)这样看来 requestIdleCallback 似乎很完美,能否直接用在实际业务场景中呢?答案是不行。

$ o( r3 |6 N0 a0 V

我们查阅 MDN 文档就可以发现,requestIdleCallback 还只是一个实验性 API,浏览器兼容性一般:

0 ^' [3 l2 U2 C0 a

查阅 caniuse 也得到类似的结论,所有 IE 浏览器不支持,safari 默认情况下不启用:

: K6 B1 S; {) z G/ [& f

而且还有一个问题,requestIdleCallback 触发频率不稳定,受很多因素影响经过实际测试,FPS 只有 20ms 左右,正常情况下渲染一帧时长控制在16.67ms 为了解决上述问题,在 React Fiber 架构中,内部自行实现了一套 requestIdleCallback 机制:。

?, Z e+ D# A6 J( r# {$ _, M

使用 requestAnimationFrame 获取渲染某一帧的开始时间,进而计算出当前帧到期时间点;使用 performance.now() 实现微秒级高精度时间戳,用于计算当前帧剩余时间;使用 MessageChannel 零延迟宏任务实现任务调度,如使用 setTimeout() 则有一个最小的时间阈值,一般是 4ms;

& t2 U, ^0 P% q9 P( t4 @

按照上述思路,我们可以简单实现一个 requestIdleCallback 如下:// 当前帧到期时间点 4 J9 j+ k' W3 ^ x7 W5 t letdeadlineTime;// 回调任务3 x. c$ i" T. X letcallback;// 使用宏任务进行任务调度) ]2 j8 t& ]9 f/ `& q. ~

3 T; {9 B& ^! t7 t( Z

constchannel=newMessageChannel();constport1=channel.port1;constport2=channel.port2;// 接收并执行宏任务 6 w1 \: ~% b4 E7 X7 g8 r; e+ ]5 v port2.

8 }1 s6 u+ {7 l% ` I" }

onmessage=()=>{// 判断当前帧是否还有空闲,即返回的是剩下的时间; n% P% a# Q+ S. `. V9 w) H0 K consttimeRemaining=()=>deadlineTime-performance.now();const_timeRemain

. m1 m! j+ D$ r# Y/ V* B

=timeRemaining();// 有空闲时间 且 有回调任务# L& |# z. P9 i: r$ x if(_timeRemain>0&&callback){constdeadline={timeRemaining,didTimeout

6 I+ W$ B* C* Z0 m' ^

:_timeRemain<0,};// 执行回调 ! b7 V3 m# f- }; r callback(deadline);}};window.requestIdleCallback=function(cb){requestAnimationFrame

/ R. r2 S+ X8 B$ ]

((rafTime)=>{// 结束时间点 = 开始时间点 + 一帧用时16.667ms 2 ]( ?. W1 p) ]3 G3 A deadlineTime=rafTime+16.667;// 保存任务 ) T! w' Y! G" C$ d( I% d callback=cb;// 发送个宏任务 : A/ P# a+ \8 A; o3 b+ |

- D" C/ e4 A2 h' z+ x; x

port1.postMessage(null);});};在项目中,考虑到 api fallback 方案、以及支持取消任务功能(上面的代码比较简单,仅仅只有添加任务功能,无法取消任务),最终选用 React 官方源码实现。

& K& |7 Q% M" N

那么 API 的问题解决了,剩下就是怎么分割任务的问题查阅 rrweb 文档得知,rrWebplayer 实例上提供一个 addEvent 方法,用于动态添加回放数据,可用于实时直播等场景按照这个思路,我们可以将录制回放数据进行分片,分多次调用 addEvent 添加。

?$ f3 M; G" e; s

import{requestHostCallback,cancelHostCallback,}from"@/utils/SchedulerHostConfig";exportdefault{// ...- v6 @7 |2 h5 ` v

- _" s; v) T, c

methods:{replayRRweb(eventsRes=[]){constPACKAGE_SIZE=100;// 分片大小 6 R# Y% \6 Y) P* J constLEN=eventsRes.length;// 录制回放数据总条数0 z' h$ a7 E% } L. [8 J# q

/ I% H! ]( w, z$ s" Z

constSLICE_NUM=Math.ceil(LEN/PACKAGE_SIZE);// 分片数量5 j& @$ p: j1 F: D1 a' g! z/ M rrWebplayer=newrrwebPlayer({target:document.getElementById

' j( y& F. [$ ]9 x5 K) B# i

("replayer"),props:{// 预加载分片4 W2 E! B$ t; e5 u) u0 z$ W events:eventsRes.slice(0,PACKAGE_SIZE),unpackFn:unpack,},});// 如有任务先取消之前的任务 : d4 k. X- g9 j

3 y1 B. w2 Y7 M+ n3 O; P& _" T

cancelHostCallback();constcb=()=>{// 执行到第几个任务& l" ^; \. h8 W, A! n let_u=1;return()=>{// 每一次执行的任务 # L) `0 a$ x$ c$ R! q3 R2 L6 {7 v6 X/ c // 注意数组的 forEach 没办法从中间某个位置开始遍历 & u$ i$ c* k0 |$ `( r

$ P; v' G1 P9 D- p4 {; \9 H4 d

for(letj=_u*PACKAGE_SIZE;j=LEN)break;rrWebplayer.addEvent(eventsRes[j]);

1 A5 F4 Q0 f6 e/ g/ F* h

}_u++;// 返回任务是否完成 . I C' a+ b0 }3 F( G return_u{// 加载完毕回调 ( ~4 a% ^, ]0 _; A6 W });},},};注意最后加载完毕回调,源码中不提供这个功能,是本人自行修改源码加上的。

( L! v$ v0 l" n8 ]) }0 t+ N

按照上面的方案,我们重新加载学员回放页面看看,现在已经基本察觉不到卡顿了我们找一个 20M 大文件加载,观察下火焰图可知,录制文件加载任务已经被分割为一条条很细的小任务,每个任务执行的时间在 10-20ms 左右,已经不会明显阻塞主线程了:。

. V/ Q- j$ b1 }

优化后,页面仍有卡顿,这是因为我们拆分任务的粒度是 100 条,这种情况下加载录制回放仍有压力,我们观察 fps 只有十几,会有卡顿感我们继续将粒度调整到 10 条,这时候页面加载明显流畅了,基本上 fps 能达到 50 以上,但录制回放加载的总时间略微变长了。

$ @ ?8 e+ {8 s0 k4 E! p) _

使用时间分片方式可以避免页面卡死,但是录制回放的加载平均还需要几秒钟时间,部分大文件可能需要十秒左右,我们在这种耗时任务处理的时候加一个 loading 效果,以防用户在录制文件加载完成之前就开始播放有同学可能会问,既然都加 loading 了,为什么还要时间分片呢?假如不进行时间分片,由于 JS 脚本一直占用主线程,阻塞 UI 线程,这个 loading 动画是不会展示的,只有通过时间分片的方式,把主线程让出来,才能让一些优先级更高的任务(例如 UI 渲染、页面交互事件)执行,这样 loading 动画就有机会展示了。

; Y' \& R7 X' o# s: a" g0 L

进一步优化使用时间分片并不是没有缺点,正如上面提到的,录制回放加载的总时间略微变长了但是好在 10-20M 录制文件只出现在测试场景中,老师实际上课录制的文件都在 10M 以下,经过测试录制回放可以在 2s 左右就加载完毕,学员不会等待很久。

% F, g# X6 j8 | _

假如后续录制文件很大,需要怎么优化呢?之前提到的 unpack 过程,我们没有放到 worker 线程执行,这是因为考虑到放在 worker 线程,主线程还得等待 worker 线程执行完毕,跟放在主线程执行没有区别。

: n% z% g+ i2 g6 p

但是受到时间分片启发,我们可以将 unpack 的任务也进行分片处理,然后根据 navigator.hardwareConcurrency 这个 API,开启多线程(线程数等于用户 CPU 逻辑内核数),以并行的方式执行 unpack ,由于利用多核 CPU 性能,应该能够显著提升录制文件加载速率。

+ t, C7 W, L) @( m+ w, X# l6 _

总结这篇文章中,我们通过 performance 面板的火焰图分析了调用栈和执行耗时,进而排查出两个引起性能问题的因素:Vue 复杂对象递归响应式,和录制回放文件加载对于 Vue 复杂对象递归响应式引起的耗时问题,本文提出的解决方案是,将该对象转为非响应式数据。

I. s [( d; _9 x% G1 n4 p

对于录制回放文件加载引起的耗时问题,本文提出的方案是使用时间分片由于 requestIdleCallback API 的兼容性及触发频率不稳定问题,本文参考了 React 17 源码分析了如何实现 requestIdleCallback 调度,并最终采用 React 源码实现了时间分片。

2 m; a% `0 y! p* x! S; u

经过实际测试,优化前页面卡顿 20s 左右,优化后已经察觉不到卡顿,fps 能达到 50 以上但是使用时间分片之后,录制文件加载时间略微变长了后续的优化方向是将 unpack 过程进行分片,开启多线程,以并行方式执行 unpack,充分利用多核 CPU 性能。

& O) R) D8 L% Q6 F. c& z

参考· vue-9-perf-secrets· React Fiber很难?六个问题助你理解· requestIdleCallback - MDN· requestIdleCallback - caniuse

/ S W( o U" N

· 实现React requestIdleCallback调度能力详情可点击这里查看

9 l2 u( l. z: o: j1 n 2 Q# P0 z0 q: U) a; C. [. R 1 |6 F: w. x) G& ? # k8 ?7 }3 a0 B; [+ H5 h8 Q# K2 U7 N

暂时无法加载帖子列表

回复

使用道具 举报

2268030878 发表于 2026-07-08 18:34:05 | 显示全部楼层
完全赞同,我也是这么认为的,英雄所见略同~
回复

使用道具 举报

leonseo444 发表于 2026-07-10 06:19:50 | 显示全部楼层
分析得很透彻,很多细节都说到点子上了~
回复

使用道具 举报

15914097593 发表于 2026-07-13 03:07:32 | 显示全部楼层
内容很干货,没有多余的废话,值得反复看
回复

使用道具 举报

图样森破avi1 发表于 2026-07-25 05:38:24 | 显示全部楼层
刚好遇到类似问题,看完这个帖子心里有底了
回复

使用道具 举报

残阳seo 发表于 2026-08-28 18:13:55 | 显示全部楼层
这个思路很新颖,打开了新世界的大门,谢谢分享
回复

使用道具 举报

    您需要登录后才可以回帖 登录 | 加入怎通

    本版积分规则

    QQ|手机版|小黑屋|网站地图|真牛社区 ( 苏ICP备2023040716号-2 )

    GMT+8, 2026-9-14 04:06 , Processed in 0.038800 second(s), 25 queries , Gzip On.

    免责声明:本站信息来自互联网,本站不对其内容真实性负责,如有侵权等情况请联系420897364#qq.com(把#换成@)删除。

    Powered by Discuz! X3.5

    快速回复 返回顶部 返回列表