3

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 里面,我们可以看到这样的结果:

呀!我们看到 tagsbeforeafter 是一样的 -- mixin 告诉我们如果两个对象相等(不严格相等)是可以避免更新的。另一方面,计算出两个方法是否相等的过程也是很耗时的,因为 Function.bind 尽管带同样的参数,也会生成一个新函数。虽然这些都是有用的线索 -- 我们回头看下在 tagsdeleteItem 我们是怎么做的,似乎就是我们每传一个新的值,都创建了一个 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的列表。

每一步重构都能获得性能的提升:

  • 假设我们用 PureRenderMixin 创建一个TodoItems组件,它不用重新渲染每个item,就可省去部分优化工作,这时prevProps.items === this.props.items

  • 假设我们创建了一个 AddTaskForm 组件,文本输入后的状态就已经更新在那里了。当输入框文本变化时,Todos 组件就不用再重新渲染了。

这两步结合起来,每次按键只需要10毫秒!

实例研究 #2:

方案: 当用户的任务项太多( >3000)时,我们就渲染一个 warning,并且给这些 todo items 添加样式,这样其它每个item就都有一个背景颜色。

实践:

  • 我们用一个类似于 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节点都会重新计算它们的样式。

为了解决这个问题,我们可以这样做:

  • TaskWarning 返回一个空的 div

  • TaskWarning 组件移到一个 div 里面,这样它就不会影响到其后节点的CSS选择器。

  • 升级React :-)

但这是脱离本意的。这里主要是我们知道怎么通过 timeline profiler 去诊断这些性能问题。

总结

希望这章能够帮助大家了解 React 的性能问题是如何在开发者工具呈现出来的 -- 把 TimelineProfiles 和React性能工具结合起来用大有帮助。

有上千个items的 todo lists 随意着色似乎是别扭的,但当渲染大量的文件和样式表,或者构建一个电子手册,我们都会遇到非常相似的问题。而且,我们仍然在壮大我们的团队 -- 如果你有兴趣构建复杂的React apps,欢迎联系我们


grace_xhw
1.4k 声望132 粉丝

博观而约取,厚积而薄发