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

浅谈前端性能突破React应用瓶颈

时间:2023-04-05 19:13:50 HTML5

性能一直是前端开发中非常重要的话题。随着前端能做的事情越来越多,浏览器的能力被无限放大和利用:从网页游戏到复杂的单页应用,从NodeJS服务到网页VR/AR、数据可视化,前端工程师都在总是突破极限。随之而来的性能问题,有的解决了,有的成了无法逾越的盾墙。那么,当我们谈论性能时,我们到底在谈论什么?基于React框架开发的应用有哪些性能特点?在这篇文章中,我们从浏览器和JavaScript引擎的角度分析前端性能,同时对React进行创新,充分利用浏览器的能力来突破局限。在文章开始之前,先给大家介绍一本书。从去年开始,我和颜海静这个知名科技大佬就开始了合着之旅。今年,我们共同打磨的一本书《React 状态管理与同构实战》终于正式出版了!本书以React技术栈为核心。在介绍React用法的基础上,从源码层面分析了Redux的思想,同时着重介绍了服务端渲染和同构应用的架构模式。书中包含众多项目实例,不仅为用户打开了React技术栈的大门,也提升了读者对前沿领域的整体认知。如果您对本书内容或后续内容感兴趣,请多多支持!文末有详细介绍,别走开!性能问题的阿喀琉斯之踵其实存在各种性能问题:网络传输过程中可能出现瓶颈,导致前端数据呈现延迟;它也可能受到混合应用程序中webview容器的限制。但是在分析性能问题的时候,往往绕不开一个概念——JavaScript单线程。浏览器解析和渲染DOMTree和CSSTree,解析和执行JavaScript,几乎所有的操作都在主线程中进行。因为JavaScript可以操作DOM并影响渲染,所以JavaScript引擎线程和UI线程是互斥的。换句话说,JavaScript代码在执行时会阻止页面的呈现。使用下图理解:图中几个关键角色:CallStack:调用栈,执行JavaScript代码的地方,对应Chrome和NodeJS中的V8引擎。当它执行完所有当前任务后,堆栈为空,等待接收EventLoop中的下一个Tick任务。BrowserAPIs:这是连接JavaScript代码和浏览器内部的桥梁,使JavaScript代码能够通过BrowserAPIs操作DOM,调用setTimeout,AJAX等事件队列:每次通过AJAX或setTimeout添加异步回调时,回调函数一般会被添加到事件队列中。作业队列:这是为具有更高优先级的承诺保留的通道,这意味着“稍后执行此代码,但在下一个事件循环滴答之前”。属于ES规范,注意区别对待,这里不再展开。NextTick:表示调用栈将在下一个tick执行的任务。它由事件队列、整个作业队列以及部分或全部渲染队列中的回调组成。注意,只有当Job队列为空时,当前tick才会进入下一个tick。这涉及到任务优先级。可能大家对microtask和macrotask比较熟悉,这里就不展开了。事件循环:它将“监视”(轮询)调用堆栈是否为空。当调用栈为空时,EventLoop会将下一个tick中的任务压入调用栈。在浏览器的主线程中,JavaScript代码在调用栈上执行时,可能会调用浏览器的API对DOM进行操作。也可以执行一些异步任务:如果这些异步任务通过回调处理,往往会被加入到Event队列中;如果是promises处理的,会先放到Job队列中。这些异步任务和渲染任务将在下一个序列中由调用堆栈处理和执行。了解了这些,大家就明白了:如果调用栈运行一个比较耗时的脚本,比如解析一张图片,调用栈就会像北京上下班高峰期的循环入口一样,被这种复杂的任务堵死。主线程的其他任务必须排队,从而阻塞UI响应。这时候对用户的点击、输入、页面动画等都没有反应,这样的性能瓶颈就像阿喀琉斯之踵一样,在一定程度上限制了JavaScript的性能。两方性能解药我们一般有两种方案来突破上面提到的瓶颈:将耗时、成本高、容易阻塞的长任务切片,拆分成子任务,异步执行。这样,这些子任务就会在不同的调用栈tick周期中被执行,然后主线程就可以在子任务之间进行UI更新操作了。想象一个常见的场景:如果我们需要渲染一个由100,000条数据组成的列表,那么我们可以将数据分段并使用setTimeoutAPI逐步处理,而不是一次渲染所有数据。构建渲染列表的工作只是被分成不同的子任务在浏览器中执行。在这些子任务之间,浏览器可以处理UI更新。另一种创新方法:使用HTML5WebworkerWebworker允许我们在不同的浏览器线程中执行JavaScript脚本。因此,一些耗时的计算过程可以在Webworker开启的线程中处理。下面会有详细的解释。React框架性能分析社区中关于React性能的内容往往侧重于业务层面,主要是利用框架的“最佳实践”。这里不谈“使用shoulComponentUpdate减少不必要的渲染”、“减少render函数中的inline-function”等已经“常谈”的话题。本文主要从React框架的实现层面分析其性能瓶颈和突破策略。NativeJavaScript一定是最高效的,这点没有争议。与其他框架相比,React将更多的时间花在了JavaScript执行层面,这是由一系列复杂的过程造成的:VirtualDOM构建->计算DOMdiff->renderpatch的生成。即在一定程度上:React著名的调度策略——stackreconcile是React的性能瓶颈。这不难理解,因为DOM更新只是JavaScript调用浏览器的API。对于所有框架和本机JavaScript,此过程在同一个黑盒中执行。这部分的性能消耗是相等的,也是不可避免的。再看看我们的React:stackreconcile过程会先深入遍历所有VirtualDOM节点,然后进行diff。整个VirtualDOM计算完成后,将任务pop出栈,释放主线程。因此,当浏览器主线程被React更新状态任务占用时,用户与浏览器的任何交互都得不到反馈。只有任务结束,浏览器才能响应。我们来看一个典型的场景,来自文章:React的新引擎——ReactFiber是什么?此示例将在页面上创建一个输入框、一个按钮和一个BlockList组件。BlockList组件会根据NUMBER_OF_BLOCK值渲染相应数量的数字显示框,数字显示框显示按钮被点击的次数。在本例中,我们可以将NUMBER_OF_BLOCK的值设置为100000,此时点击按钮,触发setState,页面开始更新。此时点击输入框,输入一些字符串,比如“hi,react”。可以看到:页面没有响应。等了7s,输入框里突然出现了之前输入的“hireact”。同时,BlockList组件也更新了。显然,这样的用户体验并不好。浏览器主线程在这7s中的表现如下图所示:黄色部分是JavaScript执行时间,也是React占用的主线程时间;紫色部分是浏览器重新计算DOMTree的时间;绿色部分是浏览器绘制页面的时间。这三个任务一共占用浏览器主线程7s,这段时间浏览器无法与用户进行交互。主要原因是黄色部分执行时间比较长,耗时6s,也就是React长时间占用主线程,导致主线程无法响应用户输入。这是一个典型的例子。React性能升级——ReactFiberReact核心团队早就预料到性能风险的存在,并不断探索解决方法。基于浏览器对requestIdleCallback和requestAnimationFrame这两个API的支持,React团队实现了一种新的调度策略——Fiberreconcile。更多关于Fiber的内容也推荐文章:React的新引擎——什么是ReactFiber?文中在应用ReactFiber的场景下,重复刚才的例子,不会再出现页面卡顿,交互自然流畅。浏览器主线程的表现如下图所示:可以看到在黄色的JavaScript执行过程中,也就是React占据浏览器主线程的时候,浏览器也在重新计算DOMTree,重新绘制。看一下,黄色和紫色交替出现,页面截图显示用户输入可以及时响应。简单来说,在React占据浏览器主线程的同时,浏览器也在与用户进行交互。这显然是一种“更好的性能”表示。以上就是React应用的第一个方法:“分段耗时任务”,实现了性能上的突破。接下来,我们再看看另一种“民间”的做法,Webworkers的应用。React结合WebworkerWebworker的概念本文不再赘述,可以访问MDN地址了解更多。我们聚焦思考点:如果React连接Webworker,入口在哪里,如何实现?众所周知,一个标准的React应用由两部分组成:React核心:负责大部分复杂的VirtualDOM计算;React-Dom:负责与浏览器的真实DOM交互,显示内容。答案很简单,我们尝试在webworkers中运行ReactVirtualDOM计算。也就是把React核心部分移到webworker线程中。确实有人提出了这个想法,参见React仓库中的Issue#3092,这也引起了DanAbramov的讨论。虽然这样的提议被否决了,但这并不妨碍我们用worker来试验React。话不多说,看代码,看demo:读者可以访问这里。本网站使用原生React和使用WebWorker访问的React实现了两个应用程序,并比较了它们的性能。关于代码部分,感兴趣的同学可以私信我。最终结论:只有当大量节点发生变化时,Webworker才会对渲染性能有一定的提升作用。当节点数量很少时,访问Webworker的性能可能是负面的。我认为这是由于工作线程和主线程之间的通信成本。从这点来看,React的Webworker版本在性能上还是有提升空间的。我简单总结如下:因为工作线程和主线程使用postMessage进行通信,性能开销比较大,我们可以使用批处理的思想来减少通信次数。每次DOM需要变化时调用postMessage通知主线程并不是特别明智。所以可以使用批处理的思路,将工作线程中计算出来的待更新的DOM内容收集起来,然后统一发送。这样batching的粒度就很有意思了。如果我们走极端,每次批处理都会收集很多更改并延迟将它们发送到主线程,那么浏览器的真实渲染进程将在一次批处理期间承受压力,适得其反。使用postMessage传递消息时,可传输对象用于数据加载。关于syntheticEvent的worker版本,原生React有一个事件系统,在顶层监听所有的浏览器事件,然后将它们转换成合成事件(syntheticEvent),在定义在DOM上的VirtualEventlisteners中传递给我们。对于我们的Webworker来说,由于worker线程不能直接操作DOM,也就不能监听浏览器事件。因此,所有的事件也是在主线程中处理,转化为虚拟事件再传递给工作线程进行发布,也就是说所有与创建虚拟事件相关的操作仍然在主线程中进行。可以直接考虑一个可能的改进方案。将原始事件传递给worker,worker会生成模拟事件并冒泡。React和workers的结合还有很多值得深挖的地方,比如:事件处理中preventDefault和stopPropogation的同步保证(worker线程和主线程的通信是异步的);usingmultipleworker(多于一个worker)进行探索等,如果读者有兴趣,我会专门写一篇文章介绍。Redux和Webworker既然React可以访问Webworker,那么Redux当然可以借鉴这个思路。将Redux中reducer复杂的纯计算过程放在worker线程中是不是一个好主意?我用“N皇后问题”来模拟大规模计算。除了这个极其耗时的算法,页面上还运行了几个模块来实现频繁更新DOM的渲染逻辑:一个实时每16毫秒,显示计数(每秒增加1个)的blinker模块;每500毫秒更新一次背景颜色的计数器模块;永远往复运动的滑块模块;每16毫秒翻转5度的微调器模块。渲染。一般情况下,当JavaScript主线程在进行N-queen计算时,这些渲染进程就会卡住。如果我们把N-Queens计算放到worker线程中,我们会发现demo表现出惊人的性能提升,完全丝滑无卡顿。如上图,左半边是普通版,不出意外页面卡死,右半边是连接worker后的应用。在实现层面,借助Redux库的增强器设计完成抽象封装。storeenhancer其实就是一个柯里化的高阶函数,类似于React中高阶组件的概念,也类似于我们比较熟悉的中间件。其实参考Redux源码,你会发现Redux源码中applyMiddleware方法的执行结果是一个storeenhancer。那么为什么不选择中间件,而是使用enhancer来实现呢?这个Reduxworkerdemo采用的publiclibrary设计思路很有意思,关于神奇的Redux的高层内容不再展开。有兴趣的读者可以在我新出版的书中找到相应的内容。是时候打广告了。..《React 状态管理与同构实战》本书由我和知名前端技术大牛严海静共同打磨,浓缩了我们在学习和实践React框架过程中的积累和经验。除了介绍React框架的使用,重点分析了同构应用的状态管理和服务端渲染。同时,吸取了社区大量的优秀思想,进行了归纳对比。本书灵感来源于百度副总裁沉斗,百度高级前端工程师董锐,知名JavaScript语言专家阮一峰,Node.js浪叔。前端工程师谷依玲和众多前端圈高手联名推荐。感兴趣的读者可以点击这里了解详情。您也可以扫描下方二维码进行购买。再次感谢您的支持与鼓励!恳请大家批评指正!最后,前端学习无止境,希望和每一位技术爱好者一起进步。你可以在知乎找到我!编码愉快!