非主流童话

性能日历之 React diff算法

原文链接: calendar.perfplanet.com

React 是由Facebook出品的一个JavaScript库,用以开发用户界面,基于高性能设计。在这篇文章中,我将说明React的diff算法和渲染是如何工作的。明白了这些,你也可以据此把你的app优化得更好。

Diff算法

在阐述实现细节之前,我们先来看看react是如何工作的。

var MyComponent = React.createClass({ 
    render: function() { 
        if (this.props.first) { 
            return <div className="first"><span>A Span</span></div>; 
        } else {
            return <div className="second"><p>A Paragraph</p></div>; 
        }
    }
});

任何时点,你要描述的只是你的UI界面的样子,渲染的结果不是实际的DOM节点。明白这一点很重要。这些渲染的结果是轻量的JavaScript对象,我们称它们为虚拟DOM。

React要做的事情就是利用这些呈现出来的虚拟DOM来尝试找出从上一个渲染态跃迁到下一个渲染态的最小差异。举个例子,我们想要挂载节点<MyComponent first={true} />,随后以<MyComponent first={false} />取代此节点,最后再移除此节点,则DOM操作指令大致如下:

第一步

  • 创建一个节点: <div class="first"><span>A Span</span></div>

第二步

  • 替换属性: 将className="first" 替换为:className="second"

  • 替换节点: 讲<span>A Span</span> 替换为 <p>A Paragraph</p>

第三步

  • 移除节点: <p>A Paragraph</p>

逐级比较

两棵任意树形结构数据中找到最小差异是一个时间复杂度为O(n^3)的问题。这肯定不是我们所要的解决方案。React使用了简明强大的启发式算法,时间复杂度近乎O(n)。

React只是对两棵树进行同级比较,这大大降低了复杂度。而在web应用中,很少会出现一个组件节点迁移到其他层级上的变化,一般都只是在子节点中横向移动。因此,这个决策不会有很大的问题。

列表

假设有一个组件。第一个状态下,它迭代渲染5次,形成一个组件列表。下一个状态,在这个列表的中间,插入一个新的组件。如果只知道这些信息,我们将很难找出这两个列表的映射关系。

默认地,React会将状态变化前后的组件列表中的每一个组件做一一关联,我们把状态变化之前的列表作为表1,之后的作为表2。表1的第一个组件关联到表2的第一个组件,依此类推。你可以提供key属性来帮助React找出对应关系。实践中,通常很容易在子节点中找出具有唯一key的那个子组件。

组件

React程序通常由很多用户自定义组件组成。这些组件最后被转化成一棵很多div组成的树。当React匹配相同组件的时候这些额外的信息将被diff算法用来查找具有相同类的组件。

例如,如果一个<Header>被一个 <ExampleBlock>取代,React将直接移除header并创建ExampleBlock块。我们不需要花费宝贵的时间来尝试匹配两个不太可能有任何相似之处的组件。

事件委派

对DOM节点增加事件监听很消耗时间和内存的方法。取而代之的,React实现了一种叫做“事件”委派的流行技术,并且走得更远,重新实现了一个W3C兼容的事件系统。这一位置,Internet Explorer 8的事件处理bug成为了过去时,所有的事件名在各个浏览器之间更加一致。

我来解释下这是如何实现的。文档的根元素绑定一个简单的事件监听器。当事件触发时,浏览器提供给我们事件发生的目标DOM。为了通过DOM层级间传播事件,React不会在虚拟DOM中按层迭代。

每一个React组件都有一个唯一的ID,可以表示它的层级。我们可以使用简单字符串操作来获取其所有的父级组件。我们不难发现,把事件监听器存储在一个哈希表里比把它们附加在虚拟DOM上的表现更好。下面是一个通过虚拟DOM委派事件的例子:

// dispatchEvent('click', 'a.b.c', event) 
clickCaptureListeners['a'](event); 
clickCaptureListeners['a.b'](event); 
clickCaptureListeners['a.b.c'](event); 
clickBubbleListeners['a.b.c'](event); 
clickBubbleListeners['a.b'](event);
clickBubbleListeners['a'](event);

浏览器为每一个事件和每一个监听器都创建了一个时间对象。这固然很好,你籍此可以保存事件对象的引用,甚至可以修改它。然而,这意味着要做大量的内存分配。React很聪明地在启动时候会分配一个对象池给这些对象,当需要一个事件的时候,将从这个对象池来复用。此举大大减少了垃圾收集的操作。

渲染

批量处理

无论何时,当你在一个组件中调用setState方法,React就将它标记为“脏”组件。当事件循环最后,React会找到所有“脏”组件,并重新渲染他们。

这个批量的意味着在一个事件循环过程中,只有一次DOM更新的时机。这个属性是构建一个高性能程序的关键,而且用通常的JavaScript编写很难达到这个效果。而在React程序中,这个特性是默认包含的。

子树渲染

当你调用setState的时候,组件重新建立子组件的虚拟DOM。如果调用发生在根元素,则挣个程序的将被重新渲染。所有的组件————即使并没有变化————它们的render方法都会被调用。这听起来可能很吓人、很低效,但实际上,我们没有接触到实际的DOM。

首先,我们讨论下用户界面的呈现。由于屏幕空间是有限的,你经常只需要一次按顺序显示成百上千的元素。对于整个界面的业务逻辑而言,JavaScript目前足够快,是可控的。

另一个关键点是,当你写React代码时候,你通常不会一有什么东西变化就在根节点调用setState。你一般只会在收到变化事件的一个或其之上的几个组件上调用setState。直接到达根节点是非常罕见的,变化应该被局限在用户产生交互的地方。

有选择性的子树渲染

最后,如果你在组件里实现了下述方法,你就可能阻止了一些子树的重新渲染:

boolean shouldComponentUpdate(object nextProps, object nextState)

根据这个组件在变化前后的props/state值,你可以告诉React这个组件没有发生变化,不需要重新渲染。当这个方法被恰当的视线,你可以获得巨大的性能提升。

为了能够使用到这个特性,你需要能够比较JavaScript对象。这里面会引起许多问题,诸如 比较是深还是浅。如果是深度的比较,我们需要使用不可变的数据类型或者进行深度拷贝。

而且要记住,如果实现了这个方法,将被每次调用,所以你应该保证计算时间要小于渲染组件的时间,即使这个重新渲染并不是严格需要的。

结论

这些React调优方法不是最新的。我们很早就知道了DOM操作代价高昂,即使有了事件委派机制,你依然应该批量化你的读写操作。

在实践中,人们依然谈论这些,这是因为在一般的JavaScript代码中这是很难实现的。而这正是React卓尔不群的地方:所有优化,触手可得。新手老鸟,皆可把玩。可以提速,亦难自伤。

React的性能损耗模型也是简单易懂的:每次setState将重新渲染整个子树。如果你想提高性能,那么就在尽量低的层级调用setState,同时,在大的子树上使用shouldComponentUpdate来阻止不必要的重新渲染。