当前位置: 首页 > Web前端 > HTML

得物技术时间分片的实践与应用

时间:2023-03-28 00:26:22 HTML

0x1:前言每一个拥有【前辈】头衔的前端工程师,对于项目的整体性能优化,想必都有自己独到的见解。这是向前端业务架构转型必须具备的能力之一。本文将介绍一种性能优化的手段:TimeSlicing。按照W3C性能团队的介绍,超过50ms的任务就是长任务。序号时间间隔描述10到16毫秒用户特别擅长跟踪运动,他们不喜欢动画不流畅。只要每秒渲染60个新帧,他们就会认为动画是流畅的。这是每帧16毫秒,包括浏览器将新帧绘制到屏幕所花费的时间,让应用程序大约需要10毫秒来生成一个帧。20到100毫秒在此时间窗口内响应用户操作,用户感觉结果是立竿见影的。再长一点,行动和反应之间的联系就被打破了。3100到1000毫秒在此窗口内,事情感觉是任务自然和连续进展的一部分。对于Web上的大多数用户来说,加载页面或更改视图代表一项任务。41000毫秒或更多超过1000毫秒(1秒),用户会失去对他们正在执行的任务的关注。510000毫秒或更多超过10000毫秒(10秒),用户会沮丧并可能放弃任务。他们以后可能会回来,也可能不会回来。表格内容摘自UsingtheRAILmodeltoevaluateperformance根据上表描述可知,当延迟超过100ms时,用户会感知到轻微的延迟。所以为了解决这个问题,每个任务不要超过50ms。为了避免延迟超过100ms用户会感知到轻微延迟的情况,我们可以使用两种方案,一种是WebWorker,另一种是TimeSlicing。0x2:webworker测试演示代码在这里众所周知。JavaScript语言采用单线程模型,即所有任务只能在一个线程上完成,一次只能做一件事。前面的任务还没有完成,后面的任务只能等待。对于我们的业务而言,一旦我们执行过多的长任务,执行过程很容易被阻塞,导致页面卡顿。虽然我们可以把任务放到任务队列中异步执行,但是这并没有改变JS的本质。所以为了改变这种情况,whatwg推出了WebWorkers。关于webworker,不用深究,想了解的同学可以查看MDN——webworkerWebWorker提供了一种简单的方式让web内容在后台线程中运行脚本。线程可以在不干扰用户界面的情况下执行任务。可以使用XMLHttpRequest执行I/O(尽管responseXML和通道属性始终为空)。创建后,worker可以将消息发送到创建它的JavaScript代码,方法是将消息发布到该代码指定的事件处理程序(反之亦然)。Worker线程一旦创建成功,就会一直运行,不会被主线程上的活动(比如用户点击按钮或提交表单)打断。这样有利于随时响应主线程的通信。但是,这也会导致Worker消耗更多的资源,不应该过度使用,一旦用完就应该关闭。WebWorker有以下注意事项。同源限制:分配给Worker线程运行的脚本文件必须与主线程的脚本文件同源。DOM限制:Worker线程所在的全局对象与主线程不同。不能读取主线程所在网页的DOM对象,不能使用document、window、parent等对象。但是,工作线程可以访问导航器对象和位置对象。通信连接:Worker线程和主线程不在同一个上下文中,不能直接通信,必须通过消息来完成。脚本限制:工作线程不能执行alert()方法和confirm()方法,但可以通过XMLHttpRequest对象发送AJAX请求。文件限制:Worker线程不能读取本地文件,即不能打开本地文件系统(file://),加载的脚本必须来自网络。我们可以看到使用WebWorker后的优化效果:worker.jsself.onmessage=function(){conststart=performance.now()while(performance.now()-start<1000){}postMessage('done!')}myWorker.jsconstmyWorker=newWorker('./worker.js')setTimeout(_=>{myWorker.postMessage({})myWorker.onmessage=function(ev){console.log(ev.data)}},5000)测试demo代码在这里,感兴趣的朋友可以下载学习。0x3:什么是时间分片?时间分片的核心思想是:当一组任务在一个通道中执行时,如果当前任务不能在50毫秒内执行完,为了不阻塞主线程,这个任务应该放弃对通道的控制主线程权限,以便浏览器可以处理其他任务。放弃控制就是停止当前任务的执行,让浏览器执行其他任务,然后回来继续执行未完成的任务。所以,时间分片的目的不是阻塞主线程,实现目的的技术手段是将一个长任务拆分成许多不超过50ms的小任务,分散在宏任务队列中执行。上图中可以看到主线程中有一个longtask,它会阻塞主线程。使用时间切片将其切割成许多小任务后,如下图所示。可以看到当前主线程有很多密密麻麻的小任务,我们将其放大如下图。可以看出,每个小任务之间都有一个间隙,这意味着在经过很短的时间后,该任务会放弃对主线程的控制,让浏览器去执行其他任务。使用时间分片的缺点是任务的总运行时间变长,因为主线程在它处理完每个小任务后就处于空闲状态,在下一个小任务开始处理之前有一个小的延迟。但是为了避免卡死浏览器,这种权衡是必要的。0x4:如何练习时间分片时间分片充分利用了“异步”。早期可以使用定时器来实现,我们称之为“手动切片”,例如:btn.onclick=function(){someThing();//执行50毫秒setTimeout(function(){otherThing();//执行50毫秒});};当点击上面的代码时,原本应该执行100毫秒的任务现在被拆分为两个50毫秒的任务。在实际应用中,我们可以做一些封装,封装效果类似如下:btn.onclick=timeSlicing([someThing,otherThing],function(){console.log('done~');});当然timeSlicing函数的API设计不是本文的重点。这里要说明的是,前期可以使用定时器来实现人工“时间分片”;也可以,但是如果需要切分成非常小粒度的逻辑,使用generatorfunction特性会更方便。ES6引入了迭代器的概念,提供了生成器Generator函数来生成迭代器对象。虽然Generator函数最正统的用法是生成迭代器对象,但我们不妨利用它的特性来做一些其他的事情。Generator函数提供了yield关键字,它允许函数暂停执行。然后使用迭代器对象的next方法让函数继续执行。利用这个特性,我们可以设计一个更方便的时间片,例如:btn.onclick=timeSlicing(function*(){someThing();//执行50毫秒yield;otherThing();//执行50毫秒});可以看到,我们只需要使用关键字yield,就可以将本来应该执行100毫秒的任务拆分成两个50毫秒的任务。我们甚至可以将yield关键字放入循环中:btn.onclick=timeSlicing(function*(){while(true){someThing();//执行50毫秒yield;}});上面的代码我们写了一个死循环,但是还是不会阻塞主线程,浏览器也不会卡。接下来我们正式使用Generator开始封装一个时间片执行器。利用generator的特性,在requestIdleCallback中执行每一次yield,直到全部执行完毕,可以轻松实现时间分片的效果。//首先我们封装一个时间片执行函数timeSlicing(gen){if(typeofgen!=="function")thrownewError("TypeError:theparamexpectageneratorfunction");变量g=gen();如果(!g||typeofg.next!==“函数”)返回;返回函数next(){varstart=performance.now();varres=null;做{res=g.next();}while(res.done!==true&&performance.now()-start<25);如果(res.done)返回;window.requestIdleCallback(下一步);};}//然后把长任务变成生成器函数,交给时间片执行器控制执行constadd=function(i){letitem=document.createElement("li");item.innerText=${i++}项目;listDom.appendChild(项目);}function*gen(){让i=0;while(i<100000){yieldadd(i);i++}}//使用时间片插入10W条数据functionbigInsert(){timeSlice(gen)()}0x5:timeSlicing实现斐波那契数列每次学习新的编程语言,都会要求你重新自己实现斐波那契数列算法。当时常用的方法是递归和递归。那时,我只对结果感兴趣。只要结果出来了,其他的事情似乎都无所谓了。了解了generator生成器的方法后,可以尝试使用generator方法对长任务执行进行切片。首先介绍一下斐波那契数列0,1,1,2,3,5,8,……每一项的值都是前面两项相加得到的。递归方法:首先,再次实现前面的递归方法。constfibonacci=(n)=>{if(n===0||n===1)返回n;returnfibonacci(n-1)+fibonacci(n-2);}//调用console.log(fibonacci(40)中递归的思想很简单,就是不断调用自己的方法,直到n为1或者0,然后开始逐层返回数据,用递归计算大数的时候,性能会特别低,原因有两个:在递归过程中,每次创建一个新的函数,解释器都会新建一个函数栈帧压在当前函数的栈帧上,这样就形成了调用栈,因此,当递归层数过多时,可能会导致调用栈占用过多内存或溢出。分析可以发现递归导致了很多重复计算Generator生成器:Generator是ES2015的新特性,得益于这个特性,我们可以使用generator的方式来制作斐波那契数列生成器function*fibonacci(n,current=0,next=1){if(n===0){returncurrent;}yieldcurrent;yield*fibonacci(n-1,next,current+next);}//调用const[...data]=fibonacci(num)console.log(data);测试Demo代码在这里0x6:Summarytimeslice不是一个高级的API,而是一种基于浏览器渲染特性的优化方案。是一种优化思路,将过大、容易阻塞渲染的逻辑,切割成小任务执行,留给浏览器渲染的时间,达到肉眼可见的流畅度。本质上并没有优化js性能的计算,因此,应该从算法的角度去优化一些算法的逻辑。文/Davis关注得物科技,做最时尚的科技人!