24 February 2016 on React
本文是 React 性能工程系列文章的 第二篇
(共两篇). 在第一篇 译文,我们讲述了如何使用React性能工具和一些普遍存在的性能瓶颈,以及一些调试相关的技巧。如果你还没阅读上一篇文章,建议读一读!
本文我们将深入研究调试的工作流 -- 有了这些ideas之后,我们又要怎么实践呢?我们找了一些实际开发中遇到的例子,使用 Chrome 开发工具来诊断、修复这些性能问题。(如果你有好的建议或补充,欢迎让我们知悉!)
我们通过下面的示例代码来看下 -- 你将看到一个用React实现的简单版 todo list
。点击下面 JS fiddle
中的 "RESULT" 查看交互效果、完成性能复制。我们将一步步更新 JS fiddle
来查看性能调试。
实例研究 #1: TodoList
从这个 TodeList
开始吧。快速地输入没有经过优化的代码,你会发现它运行缓慢。
我们打开 Chrome 开发者工具 Timeline profiler
,它会展示浏览器的详细执行情况,包括执行用户事件、运行JS和渲染页面。在Input框输入一个字符,然后中止 timeline profiler
。由于我们只是输入一个简单字符,所以这种迟缓
并不明显,但它却是生成性能分析所需最小信息量的最快方式。
我们注意到 Event (textInput)
的长条,在脚本处理上总计耗时121.10毫秒。从 timeline profiler
可以看出,导致性能缓慢的是脚本问题,不是样式或计算引起的。
因此我们来看下脚本处理,切换到 Profiles
面板。Timeline
展示浏览器的概览并且支持JS Profile
,而Profiles
则提供多种可视化工具,允许我们深入研究JS-land。以下是另一个 Profile
记录,表明性能的缓慢不是来源于我们的应用代码:
看下这个Profile,Total
这列根据占用时间递减排列,可以看出绝大部分时间是花在React的batchedUpdates
的调用上,这点相当明确地暗示了是在React-land这一层。相反, Self
一栏评估了花费在函数本身的时间(排除耗费在子函数的时间),这样可以看出是否有一些特别耗时的函数。从这两个方面看来,用户层函数并没有明显的性能瓶颈。因此,我们换用React的性能工具来试下。
为了给这个缓慢的action生成一个测量概况,我们在控制台调用 React.addons.Perf.start()
, 输入一个字符来执行这个action,随后调用 React.addons.Perf.stop()
完成这个流程。这样我们就可以看到React.addons.Perf.printWasted()
花费了一些不必要的时间:
第一列表明 TodoItem
是由 Todos
渲染出来的;然而,Perf.printWasted()
的打印结果表明:如果避免重新渲染,可以节省100毫秒。这个似乎是主要的优化项之一。
为了诊断为何 TodeItem
会浪费这么多时间,我们创建了一个自定义 mixin
, 并把它命名为 WhyDidYouUpdateMixin
。把它 hook
到组件中,哪部分代码更新及其更新的原因都打印出来。以下就是我们的代码,你可以根据自己所需,随意适配。
一旦我们把这个 mixin
放到 TodoItem
里面,我们可以看到这样的结果:
呀!我们看到 tags
的 before
和 after
是一样的 -- mixin
告诉我们如果两个对象相等(不严格相等)是可以避免更新的。另一方面,计算出两个方法是否相等的过程也是很耗时的,因为 Function.bind
尽管带同样的参数,也会生成一个新函数。虽然这些都是有用的线索 -- 我们回头看下在 tags
和 deleteItem
我们是怎么做的,似乎就是我们每传一个新的值,都创建了一个 TodoItem
。
如果我们通过一个未绑定的函数来传递给TodoItem,并用一个常量来储存tags,就可以避免这个问题了:
现在 WhyDidYouUpdateMixin
显示前一个props和新的props是浅相等的。我们可以使用 PureRenderMixin
,如果前后两个props(和state)浅相等,则不用更新。
当我们再次运行 profiler
,发现现在只是用了35毫秒(比之前快了4倍):
这样比起之前已经好很多了,但仍不够理想。Input 框的输入不应该这么耗时。因此,我们继续优化这个问题。刚刚仅仅是减少了常量,我们仍然需要对每个 item
做浅对比。
在这点上,你或许觉得一个 todo list
上面有1000个 item
已经很特殊了,30毫秒对于你的应用来说是可以接受的。但是,如果你要支持上千个子item,这样就不符合理想中的60fps(每帧16毫秒)。
下一步比较合理的做法是把一个组件拆分成多个子组件 (这也可以说是有效的第一步)。我们注意到 Todos
组件实际上包括两个互不相交的子组件:一个AddTaskForm
子组件包含了输入框和按钮,另一个 TodoItem
子组件包含items的列表。
每一步重构都能获得性能的提升:
@H_502_164@假设我们用 PureRenderMixin
创建一个TodoItems组件,它不用重新渲染每个item,就可省去部分优化工作,这时prevProps.items === this.props.items
。
假设我们创建了一个 AddTaskForm
组件,文本输入后的状态就已经更新在那里了。当输入框文本变化时,Todos
组件就不用再重新渲染了。
这两步结合起来,每次按键只需要10毫秒!
实例研究 #2:
方案: 当用户的任务项太多( >3000)时,我们就渲染一个 warning
,并且给这些 todo items
添加样式,这样其它每个item就都有一个背景颜色。
实践:
@H_502_164@我们用一个类似于 todo list
的例子,伴随着 TodoItems
的执行 -- 在这个例子中,我们把input框中的内容储存在组件状态的top-level
。
我们创建一个 TaskWarning
组件,根据任务项的数量来渲染提示信息。要在组件内部封装这些逻辑,如果不用渲染,我们就让它返回null。
我们给div:nth-child(even)
添加灰色背景。
观察报告: 在Input框快速输入,页面变得有点迟缓(不超过3000个任务)。如果我们第一次给 todo list
再添加一项( > 3000 个任务),在按下按钮的那一瞬间,这种迟缓反而销声匿迹了。太令人惊讶了,添加更多的任务反而能够修复页面迟缓的问题!
调试: timeline profile
展示了一些非常有趣的报告:
基于某种原因,输入一个简单的字符会造成大量样式被重新计算,这个会耗时30毫秒(这也是为什么当我们输入的速度大于 30毫秒/字符
时,可以观察到闪退的原因)。
查看 First invalidated
这一行,它表明 Danger.dangerouslyReplaceNodeWithMarkup
造成布局失效,需要重新计算样式。以下是 react-with-addons.js: 2301
:
`oldChild.parentNode.replaceChild(newChild,oldChild);`
基于某些原因,React用一个全新的DOM节点来替换原来的DOM节点。重新调用那些DOM操作是很耗性能的!使用 Perf.printDOM()
,可以查看到React是怎样进行DOM操作的:
update attributes
表明在 input
框输入 abc
时,TaskWarning
还是不可见的。然而,replace
指出React正准备接触DOM来调用 TaskWarning
组件,尽管它看似有完全一致的虚拟DOM。
正如这里所表明的,React (<= v0.13) 使用一个 noscript
标签来渲染 no component
, 但却不恰当地把这两个标签的功能处理得不一致:末尾的 noscript
标签是不需要用另一个noscript
标签来代替的。 此外,之前我们给每个div添加了灰色背景。基于CSS,3000个item节点里面每个独立个体的渲染都取决于它的兄弟节点。每次 noscript
标签被替换,其后的DOM节点都会重新计算它们的样式。
为了解决这个问题,我们可以这样做:
@H_502_164@让 TaskWarning
返回一个空的 div
把 TaskWarning
组件移到一个 div
里面,这样它就不会影响到其后节点的CSS选择器。
升级React :-)
但这是脱离本意的。这里主要是我们知道怎么通过 timeline profiler
去诊断这些性能问题。
总结
希望这章能够帮助大家了解 React 的性能问题是如何在开发者工具呈现出来的 -- 把 Timeline
、Profiles
和React性能工具结合起来用大有帮助。
有上千个items的 todo lists
随意着色似乎是别扭的,但当渲染大量的文件和样式表,或者构建一个电子手册,我们都会遇到非常相似的问题。而且,我们仍然在壮大我们的团队 -- 如果你有兴趣构建复杂的React apps,欢迎联系我们。