性能一直是前端开发中非常重要的话题。随着前端能做的事情越来越多,浏览器的能力被无限放大和利用:从网页游戏到复杂的单页应用,从NodeJS服务到网页VR/AR和数据可视化,前端工程师都在总是在挑战极限。随之而来的性能问题,有的解决了,有的成了无法逾越的盾墙。那么,当我们谈论性能时,我们到底在谈论什么?基于React框架开发的应用有哪些性能特点?性能问题的本质其实性能问题有很多种:网络传输过程中可能出现瓶颈,导致前端数据呈现延迟;也可能是在移动混合应用中,wbview容器带来了瓶颈和限制。但是在分析性能问题的时候,往往绕不开一个概念——JavaScript单线程。浏览器解析和渲染DOMTree和CSSTree,解析和执行JavaScript,几乎所有的操作都在主线程中进行。因为JavaScript可以操作DOM并影响渲染,所以JavaScript引擎线程和UI线程是互斥的。换句话说,JavaScript代码在执行时会阻止页面的呈现。使用下图理解:图中几个关键角色:CallStack:调用栈,执行JavaScript代码的地方,对应Chrome和NodeJS中的V8引擎。遵循LIFO(后进先出)原则。当前所有任务执行完毕后,栈为空,等待EventLoop中接收下一个Tick任务。BrowserAPIs:这是连接JavaScript代码和浏览器内部的桥梁,使JavaScript代码能够通过BrowserAPIs操作DOM,调用setTimeout,AJAX等事件队列:每次通过AJAX或setTimeout添加回调时,回调函数将被添加到事件队列中。作业队列:这是为具有更高优先级的承诺保留的队列,这意味着“稍后执行此代码,但在下一个事件循环滴答之前”。属于ES规范,注意区别对待,这里不再展开。NextTick:表示调用栈将在下一个tick执行的任务。它由事件队列、整个作业队列以及部分或全部渲染队列中的回调组成。注意,只有当Job队列为空时,当前tick才会进入下一个tick。这里涉及到task的优先级,大家可能比较熟悉microtask和macrotask。事件循环:它将“监视”(轮询)调用堆栈是否为空。当调用栈为空时,EventLoop会将下一个tick中的任务压入调用栈。在浏览器的主线程中,JavaScript代码在调用栈上执行时,可能会调用浏览器的API对DOM进行操作。也可以执行一些异步任务:如果这些异步任务通过回调处理,往往会被加入到Event队列中;如果是promises处理的,会先放到Job队列中。这些异步任务和渲染任务将在下一个序列中由调用堆栈处理和执行。了解了这些,大家就明白了:如果调用栈运行一个比较耗时的脚本,比如解析一张图片,调用栈就会像北京上下班高峰期的循环入口一样,被这种复杂的任务堵死。反过来,UI响应被阻塞,主线程的其他任务必须排队。这时候对用户的点击、输入、页面动画等都没有反应,这样的性能瓶颈就像阿喀琉斯之踵一样,在一定程度上限制了JavaScript的性能。江湖救援——双方性能解药突破上述瓶颈我们一般有两种解决方案:将耗时费力的长任务切片,拆分成子任务,异步执行。这样,这些子任务就会在不同的调用栈周期中执行,然后主线程就可以在子任务之间进行UI更新操作了。想象一个常见的场景:如果我们需要渲染一个由100,000条数据组成的非常长的列表,那么与其一次性渲染所有数据内容,不如将数据切分,使用setTimeoutAPI一步步处理并构建列表的工作被分成不同的子任务在浏览器中执行,而在这些子任务之间,浏览器可以处理UI更新。另一种创新方法:使用HTML5WebWorkerWebWorker允许我们在不同的浏览器线程中执行JavaScript脚本。因此,一些耗时的计算过程可以在WebWorker开启的线程中处理。下面会有详细的解释。React框架性能分析社区中关于React性能的内容往往侧重于业务层面,主要是利用框架的“最佳实践”。这里不谈“使用shoulComponentUpdate减少不必要的渲染”、“减少render函数中的inline-function”等“老生常谈”的话题,本文将从React的实现层面分析其性能瓶颈和突破策略框架。NativeJavaScript一定是最高效的,这点没有争议。与其他框架相比,React将更多的时间花在了JavaScript执行层面,这显然是由构建VirtualDOM、计算DOMdiff、生成renderpatch等一系列复杂过程造成的。也就是说,React著名的调度策略——stackreconcile是React的性能瓶颈。这个不难理解,因为UI渲染就是JavaScript调用浏览器的API。这个过程对所有框架和原生JavaScript都是一样的,都是在黑盒中执行的。这部分的性能消耗是骗不了的。再看看我们的React,stackreconcile过程会深度优先遍历所有的VirtualDOM节点并进行diff。整个VirtualDOM计算完成后,任务出栈,释放主线程。因此,当浏览器主线程被React更新状态任务占用时,用户与浏览器的任何交互都无法得到反馈。只有任务结束,浏览器才会突然响应。让我们看一个典型的场景,来自文章“React的新引擎——什么是ReactFiber?”(http://www.infoq.com/cn/artic...)这个例子会在页面上创建一个输入框,一个按钮,一个BlockList组件。BlockList组件会根据NUMBER_OF_BLOCK值渲染相应数量的数字显示框,数字显示框显示按钮被点击的次数。在这个例子中,我们可以将NUMBER_OF_BLOCK的值设置为100000,将其变成一个“复杂”页面。点击按钮,触发setState,页面开始更新。此时点击输入框,输入一些字符串,比如“hi,react”。如您所见,页面没有任何响应。等了7s,输入框里突然出现了之前输入的“hireact”。同时,BlockList组件也更新了。显然,这样的用户体验并不好。浏览器主线程在这7s中的表现如下图所示:黄色部分是JavaScript执行时间,也是React占用的主线程时间,紫色部分是浏览器重新计算DOM的时间树,绿色部分是浏览器绘制页面的时间。三个任务占用浏览器主线程7s,这段时间浏览器无法与用户进行交互。但是DOM变化后,浏览器重新计算DOMTree,重绘页面是必不可少的阶段(紫绿阶段)。主要原因是黄色部分执行时间较长,占用了6s,即React长时间占用主线程,导致主线程无法响应用户输入。这里的场景内容选自《React的新引擎——什么是ReactFiber?》一文。React性能——ReactFiberReact核心团队早就预见到性能风险的存在,并不断探索解决这些问题的方法。基于浏览器对requestIdleCallback和requestAnimationFrame这两个API的支持,React团队实现了一种新的调度策略——Fiberreconcile。在应用ReactFiber的场景中,重复前面的例子。浏览器主线程的表现如下图所示:可以看出,在黄色JavaScript的执行过程中,也就是React占据浏览器主线程的时候,浏览器也在重新计算DOMTree,重新绘制。截图显示,浏览器渲染的是用户输入的新内容。简单来说,在React占据浏览器主线程的同时,浏览器也在与用户进行交互。这显然是“更好的表现”的体现。以上就是React的“将耗时任务切段”的做法。接下来,让我们看看另一种“民间”方法,它反映了WebWorker应用程序。React结合WebWorkerWebWorker的概念本文不再赘述,大家可以访问MDN地址了解更多。我们聚焦思考点:如果React连接WebWorker,入口在哪里,如何实现?众所周知,一个标准的React应用由两部分组成:React核心:负责大部分复杂的VirtualDOM计算;React-Dom:负责与浏览器的真实DOM交互,显示内容。那么答案就很简单了,我们尝试在WebWorker中运行ReactVirtualDOM相关的计算,而不是传统的主线程。这就是将React核心放在WebWorker线程中。确实有人提出过这样的想法,请参考React仓库Issue#3092。这个提议被官方React礼貌地拒绝了:“另一方面,在工人中中继似乎很合理。”具体原因可以在本期中找到很多内容,也引来了DanAbramov自己的经历。当然,如果我是React库的开发者,我是不会接受这样的改动的。但这并不妨碍我们尝试React结合Worker。Talkischeap,showmethecode,anddemo:读者可以访问http://web-perf.github.io/rea...,该网站实现了两个使用原生React和WebWorker-connectedReact的应用程序,并比较它们表现。最终结论:不能绝对的说WebWorker可以大大提高渲染率。只有当大量的节点发生变化时,WebWorker才会对提升渲染性能产生一定的作用。事实上,当节点数量很少时,WebWorker的性能可能还不如React本身。这是由于工作线程和主线程之间的通信成本。因此,WebWorker版本的React还有改进的空间。我简单总结如下:?由于工作线程和主线程使用postMessage进行通信,开销很大,我们可以使用批处理的思想来减少通信次数。每次DOM需要变化时调用postMessage通知主线程并不是特别明智。所以可以使用批处理的思路,将工作线程中计算出来的待更新的DOM内容收集起来,然后统一发送。这样batching的粒度就很有意思了。如果我们走极端,每次批处理都会收集很多变化,那么它会在一次批处理期间对浏览器的真实渲染过程造成压力,适得其反。?当使用postMessage传输消息时,可传输对象用于worker和主线程之间的数据加载。我要传输的数据可能不是稳定的结构。因此,我需要制定一个通用的协议。使用可传递的对象来传递信息可以有效地提高效率。有关更多信息,请参阅社区文档。?关于syntheticEvent的Worker版本NativeReact有一个事件系统,它在顶层监听所有浏览器事件,将它们转换为合成事件,并将它们传递给我们在VirtualDOM上定义的事件监听器。对于我们的WebWorker来说,由于WebWorker不能直接操作DOM,也就是说不能监听浏览器事件。因此,所有的事件也是在主线程中处理,转化为虚拟事件传递给工作线程,这意味着所有与创建虚拟事件相关的操作仍然在主线程中进行。一种可能的改进方案是直接将原始事件传递给worker,由worker生成模拟事件并冒泡。React和workers的结合还有很多值得探讨的地方,比如事件处理中preventDefault和stopPropogation的同步;multipleworker(多个worker)的使用探索等,如果读者有兴趣,我会专门写一篇文章。Redux和WebWorker既然React可以接入WebWorker,那么状态管理工具Redux当然可以借鉴这个思路。将Redux中reducer复杂的纯计算过程放在worker线程中是不是一个好主意?我用“N皇后问题”来模拟大规模计算。除了这个极其耗时的算法之外,页面上还运行了几个模块来实现渲染逻辑:一个blinker模块,每16毫秒实时显示一次计数(每秒增加1个);每500毫秒更新一次背景颜色的计数器模块;永远往复运动的滑块模块;每16毫秒翻转5度的微调器模块。这些模块会定期和频繁地更新DOM样式以进行渲染。通常情况下,当JavaScript主线程在做N皇后计算时,这些渲染进程会卡住。如果我们把N-Queens计算放到worker线程中,我们会发现demo表现出惊人的性能提升,完全丝滑无卡顿。如下图,左边是普通版,无意中页面卡死,右边是worker参与后的应用:在实现层面,抽象封装(类似中间件)用借助Redux库的增强器设计。一个storeenhancer其实就是一个颗粒状的高阶函数,最终的返回值是一个可以创建更强大的store的函数(enhancedstorecreator),这和React中高阶组件的概念类似,也类似对于我们比较熟悉的中间件,其实参考Redux源码,会发现Redux源码中有applyMiddleware方法,applyMiddleware(...middlewares)的执行结果就是一个storeenhancer。
