项目背景在code_pc项目中,前端需要使用rrweb记录老师的教学内容,学生可以录制回放。为了减少录制文件的体积,目前的录制策略是先录制全量快照,再录制增量快照。在记录阶段,MutationObserver实际上是用来监听DOM元素的变化,然后将每个事件推送到数组中。对于持久存储,可以将记录数据压缩并序列化为JSON文件。教师将JSON文件放入课件包中,打包成压缩包上传至教务系统。同学们回放时,前端会先下载压缩包,通过JSZip解压,得到JSON文件,反序列化解压得到原始录音数据,然后传递给rrwebPlayer实现录音和播放。发现问题在项目开发阶段,测试录制的时间不是太长,所以录制的文件大小也不大(几百kb),播放比较流畅。但是随着项目进入测试阶段,在模拟长时间上课场景录制后,发现录制文件变得非常大,达到10-20M。QA同学反映,当同学们回放页面时,页面明显卡住了,卡顿时间在20s以上,这段时间,页面交互事件没有任何响应。页面性能是影响用户体验的主要因素,这么长时间卡住页面对于用户来说显然是不能接受的。问题排查经过群内沟通,了解到导致页面卡顿的原因主要有两个:前端解压zip包,录音播放文件加载。同事怀疑主要是zip包解压的问题,希望我尽量把解压过程放在工作线程中。那么是不是真如我同事所说的,前端解压zip包导致页面卡顿呢?3.1解决Vue的递归复杂对象导致的耗时问题对于页面卡顿问题,首先想到的肯定是线程阻塞导致的,需要在长任务出现的地方进行排查。所谓长任务是指执行时间超过50ms的任务。我们都知道Chrome浏览器的页面渲染和V8引擎是用一个线程的。如果JS脚本执行时间过长,会阻塞渲染线程,导致页面卡顿。对于JS执行的耗时分析,大家应该都知道使用性能面板。在性能面板中,通过查看火焰图来分析调用堆栈和执行时间。火焰图中每个块的宽度代表执行时间,堆叠块的高度代表调用堆栈的深度。按照这个思路,我们来看一下分析结果:可以看出replayRRweb明显是一个长任务,耗时将近18s,严重阻塞了主线程。replayRRweb耗时过长是因为两次内部调用,分别是左边的浅绿色部分和右边的深绿色部分。我们来看一下调用栈,看看哪里的耗时比较严重:熟悉Vue源码的同学可能已经看到,上面比较耗时的方法都是Vue内部的递归响应式方法(这些方法显示在右侧)来自vue.runtime.esm.js)。为什么这些方法会长期占据主线程呢?Vue性能优化有一个规则:不要把复杂的对象扔到数据中,否则Vue会深度遍历对象中的属性来添加getters和setters(即使这些数据不需要用于视图渲染),这样会导致性能问题。那么业务代码中是否存在这样的问题呢?我们发现了一段非常可疑的代码:=>{this.replayRRweb(JSON.parse(res));})})},方法:{replayRRweb(eventsRes){this.rrWebplayer=newrrwebPlayer({target:document.getElementById('replayer'),道具:{events:eventsRes,unpackFn:unpack,//...}})}}}在上面的代码中,创建了一个rrwebPlayer的实例并赋值给rrWebplayer的响应数据。在创建实例时,它还接受一个eventsRes数组,这个数组非常大,包含数万条数据。这种情况下,如果Vue递归响应rrWebplayer,肯定是非常耗时的。因此,我们需要将rrWebplayer改为Non-reactivedata(避免Vue递归响应)。转换为Non-reactivedata主要有3种方式:data选项中没有预先定义data,而是this.rrwebPlayer是在组件实例创建后动态定义的(没有事先收集依赖,会有没有递归响应);数据是预定义的,是在data选项中定义的,但是当后续修改状态时,对象会被Object.freeze处理(让Vue忽略对象的响应式处理);数据定义在组件实例外部,以模块私有变量的形式定义(这种方式需要注意内存泄漏问题,Vue不会在组件卸载时破坏状态);这里我们使用第三种方法将rrWebplayer更改为Non-reactivedata尝试:replayer'),props:{events:eventsRes,unpackFn:unpack,//...}})}}}重新加载页面,可以看出此时页面虽然还是卡住了,但是冻结时间已经显着缩短至不到5秒。观察火焰图,replayRRweb调用栈下,递归响应式调用栈消失了:3.2使用时间分片解决replay文件加载耗时问题但是对于用户来说,这还是不能接受的,我们继续看哪里是否耗时严重:可以看到问题还是出在replayRRweb函数上,具体是哪一步:那解包耗时问题怎么解决呢?由于rrweb的录制和播放需要dom操作,所以必须运行在主线程上,不能使用worker线程(获取不到domAPI)。对于主线程中的长任务,很容易想到通过时间分片将长任务分割成小任务,通过事件循环来调度任务。当主线程空闲并且当前帧有空闲时间时,执行任务,否则渲染下一帧。方案确定了,下面就是选择哪个API以及如何划分任务的问题了。这里可能有同学会问,为什么unpack过程不能在worker线程中执行,由worker线程解压数据返回给主线程加载播放,这样就可以实现非阻塞呢?仔细想想,在工作线程中解包时,主线程必须等到数据解压完成后才能进行播放,这与直接在主线程中解包没有本质区别。工作线程只有在需要执行多个并行任务时才有性能优势。说到时间分片,很多同学可能会想到requestIdleCallbackAPI。requestIdleCallback可以在浏览器渲染帧的空闲时间内执行任务,以免阻塞页面渲染、UI交互事件等,目的是解决任务需要占用时页面丢帧(卡死)的问题main进程长时间运行,导致优先级较高的任务(如动画或事件任务)无法及时响应。所以requestIdleCallback的定位是处理不重要、不紧急的任务。requestIdleCallback不会在每一帧结束时执行,只有在一帧16.6ms内渲染任务结束且还有时间时才会执行。在这种情况下,需要在requestIdleCallback执行完后执行下一帧才能继续渲染,所以每次Tick执行requestIdleCallback不要超过30ms。如果控件长时间不返回给浏览器,会影响下一帧的渲染,导致页面出现卡顿,事件响应不及时。requestIdleCallback参数说明://接受回调任务类型RequestIdleCallback=(cb:(deadline:Deadline)=>void,options?:Options)=>number//回调函数接受的参数类型Deadline={timeRemaining:()=>number//当前剩余可用时间。即帧的剩余时间。didTimeout:boolean//是否超时。}我们可以使用requestIdleCallback写一个简单的demo://10,000个任务,这里使用ES2021值分隔符constunit=10_000;//单个任务需要处理如下constonOneUnit=()=>{for(leti=0;i<=500_000;i++){}}//每个任务预留执行时间为1msconstFREE_TIME=1;//要执行多少个任务let_u=0;functioncb(deadline){//当任务还没有完成&一帧还有空闲时间>1mswhile(_uFREE_TIME){onOneUnit();_u++;}//任务完成if(_u>=unit)return;//任务没有完成,继续等待空闲执行window.requestIdleCallback(cb)}window.requestIdleCallback(cb)看来requestIdleCallback看起来很完美,能不能直接用于实际业务场景呢?答案是不。我们可以发现requestIdleCallback只是一个实验性的API,浏览器兼容性一般:查阅caniuse也可以得到类似的结论,所有IE浏览器都不支持,safari默认不开启:还有一个问题,requestIdleCallback的触发频率不稳定,受多种因素影响。经过实际测试,FPS仅为20ms左右,正常情况下一帧渲染时间控制在16.67ms。为了解决上述问题,在ReactFiber架构中,内部实现了一个requestIdleCallback机制:使用requestAnimationFrame获取渲染某帧的开始时间,然后计算当前帧的过期时间;使用performance.now()实现微秒级的高精度时间戳,用于计算当前帧的剩余时间;使用MessageChannel零延迟宏任务实现任务调度,比如使用setTimeout(),有一个最小时间阈值,一般为4ms;根据以上思路,我们可以简单的实现一个requestIdleCallback如下://当前帧过期时间点letdeadlineTime;//回调任务letcallback;//使用宏任务进行任务调度constchannel=newMessageChannel();constport1=channel.port1;constport2=channel.port2;//接收并执行宏任务port2.onmessage=()=>{//判断当前帧是否空闲,即返回剩余时间consttimeRemaining=()=>deadlineTime-性能.now();const_timeRemain=timeRemaining();//空闲时间和回调任务if(_timeRemain>0&&callback){constdeadline={timeRemaining,didTimeout:_timeRemain<0,};//执行回调callback(deadline);}};window.requestIdleCallback=function(cb){requestAnimationFrame((rafTime)=>{//结束时间=开始时间+16.667ms一帧deadlineTime=rafTime+16.667;//保存任务回调=cb;//发送宏任务port1.postMessage(null);});};项目中考虑到apifallback方案和取消任务的支持(上面的代码比较简单,只有添加任务的功能不能取消任务),最后选择了React官方源码来实现,然后API问题解决了,剩下的就是如何划分任务了。根据rrweb文档,在rrWebplayer实例上提供了一个addEvent方法,用于动态添加播放数据,可用于实时直播等场景。根据这个思路,我们可以将录音和播放数据进行分段,多次调用addEvent添加。import{requestHostCallback,cancelHostCallback,}from"@/utils/SchedulerHostConfig";exportdefault{//...方法:{replayRRweb(eventsRes=[]){constPACKAGE_SIZE=100;//段大小constLEN=eventsRes.长度;//录制和播放数据的总数constSLICE_NUM=Math.ceil(LEN/PACKAGE_SIZE);//切片个数rrWebplayer=newrrwebPlayer({target:document.getElementById("replayer"),props:{//预设加载切片events:eventsRes.slice(0,PACKAGE_SIZE),unpackFn:unpack,},});//如果有任务,先取消之前的任务cancelHostCallback();constcb=()=>{//执行到第几个任务let_u=1;return()=>{//每个任务都执行//注意数组的forEach不能从中间某个位置开始遍历for(letj=_u*PACKAGE_SIZE;j<(_u+1)*PACKAGE_SIZE;j++){如果(j>=LEN)中断;网络播放器。添加事件(事件资源[j]);}_u++;//返回任务是否完成return_u{//加载后回调});},},};注意回调是最后加载的,源码中没有提供这个功能,我自己修改源码添加的。按照上面的方案,我们重新加载学生回放页面看看,现在卡顿基本检测不到了。我们找一个20M的大文件加载,观察火焰图,可以看到录制文件加载任务被分成了非常小的任务,每个任务的执行时间在10-20ms左右,不会明显阻塞主线程。:优化后,页面仍然卡顿。这是因为我们拆分任务的粒度是100,在这种情况下,加载录音和回放还是有压力的。我们观察到fps只有十几,会有卡顿感。我们继续调整粒度为10,这时候页面加载明显更流畅了,基本上fps可以达到50以上,但是录制和回放的总加载时间略长。使用时间分片的方式可以避免页面卡顿,但加载录制和播放平均需要几秒,有些大文件可能需要十几秒左右。我们在处理这个耗时的任务时添加了一个加载效果,以防止用户在加载完成之前就开始播放录音。可能有同学会问,既然加了loading,为什么还要时间分片呢?如果不做时间分片,由于JS脚本一直在占用主线程,阻塞UI线程,所以不会显示这个加载动画。只有通过时间分片让主线程出来,才能执行一些更高优先级的任务(比如UI渲染,页面交互事件),这样加载动画才有机会展现。使用时间切片的进一步优化并非没有缺点,如上所述,录制和重放加载的总时间略长。不过还好,10-20M的录音文件只出现在测试场景中。老师实际的课堂录音文件都在10M以下。测试后录音回放2s左右即可加载,同学们不会久等。如果后续录制文件很大,如何优化?前面说的解包过程我们没有放在worker线程中执行,因为考虑到放在worker线程中,主线程要等待worker线程执行完,这和在主线程。不过受时间分片的启发,我们也可以对unpack任务进行分片,然后使用navigator.hardwareConcurrencyAPI开启多线程(线程数等于用户CPU的逻辑核心数)执行unpackin平行线。多核CPU性能,可显着提高记录加载速度。总结这篇文章,我们通过性能面板的火焰图分析了调用栈和执行时间,然后找出了两个导致性能问题的因素:Vue复杂对象递归响应,以及录制和播放文件加载。针对Vue复杂对象递归响应导致的耗时问题,本文提出的解决方案是将对象转为无响应数据。针对录放文件的加载带来的耗时问题,本文提出的解决方案是使用时间分片。由于requestIdleCallbackAPI的兼容性和触发频率的不稳定性,本文参考React17源码分析如何实现requestIdleCallback调度,最终利用React源码实现时间分片。经实际测试,优化前页面卡顿20s左右,优化后卡顿不再明显,fps可达50以上。但使用时间分片后,录制文件的加载时间稍长。后续的优化方向是对unpack流程进行分段,开启多线程,并行执行unpack,充分利用多核CPU的性能。参考·vue-9-perf-secrets·ReactFiber难不难?六个问题助你理解·requestIdleCallback-MDN·requestIdleCallback-caniuse·实现ReactrequestIdleCallback的调度能力详情请点这里查看