使用重新选择计算派生状态时如何避免 React 重新渲染

Posted

技术标签:

【中文标题】使用重新选择计算派生状态时如何避免 React 重新渲染【英文标题】:How to avoid React re-renders when using reselect to compute derived state 【发布时间】:2016-06-30 18:12:54 【问题描述】:

我在我的应用程序中使用了 React + Redux + Reselect + Immutable.js 的终极组合。我喜欢重新选择的想法,因为它让我的状态(由减速器维护)尽可能简单。我使用选择器来计算我需要的实际状态,然后将其提供给 React 组件。

这里的问题是,reducers 中的一个小变化会导致选择器重新计算整个派生输出,结果整个 React UI 也被更新。 我的纯组件没有工作。很慢。

典型示例:我的数据的第一部分来自服务器,基本上是不可变的。第二部分由客户端维护,并使用 redux 操作进行变异。它们由单独的 reducer 维护。

我使用选择器将这两个部分合并为一个记录列表,然后将其传递给 React 组件。但很明显,当我更改其中一个对象中的单个内容时,会重新生成整个列表并创建 Records 的新实例。并且 UI 完全重新渲染。

显然,每次都运行选择器效率不高,但仍然相当快,我愿意做出权衡(因为它确实使代码更简单、更清晰)。问题是实际渲染速度很慢。

我需要做的是将新选择器输出与旧选择器深度合并,因为 Immutable.js 库足够聪明,不会在没有任何更改时创建新实例。但由于选择器是无法访问先前输出的简单函数,我想这是不可能的。

我认为我目前的方法是错误的,我想听听其他想法。

在这种情况下,可能要走的路是摆脱重新选择,并将逻辑移动到使用增量更新来维护所需状态的减速器层次结构中。

【问题讨论】:

您确定为列表中的每个项目正确分配了唯一的关键道具吗? React 应该优化渲染(参见facebook.github.io/react/docs/multiple-components.html)。 不,这里的问题是选择器会生成一组全新的记录(这意味着新实例),无论它们是否更改。这就是为什么它总是被重新渲染。 即便如此,您说性能消耗不会发生在选择器中,而是在渲染时。我认为 react diffing 算法在提供唯一密钥时应该避免不必要的重新渲染(参见facebook.github.io/react/docs/reconciliation.html)。不确定差异算法是否适用于参考更改。 我认为我们讨论的是不同的问题。您正在链接处理生成的 React 树和实际 DOM 树之间协调的 diff 算法。我正在处理的是当前状态(假设传递给组件的道具)和 React 树之间的协调。我试图实现 React 树不会首先改变,除非状态发生实际变化。 React 的 diff 算法很快但不够快,有时这对于获得可用性能是必要的。见facebook.github.io/react/docs/pure-render-mixin.html 【参考方案1】:

我解决了我的问题,但我想没有正确的答案,因为它确实取决于具体情况。就我而言,我决定采用这种方法:


原始选择器很好地处理的挑战之一是最终信息是从以任意顺序交付的许多片段编译而成的。如果我决定在我的 reducer 中逐步建立最终信息,我必须确保计算所有可能的场景(信息片段可能到达的所有可能顺序)并定义所有可能状态之间的转换。而通过重新选择,我可以简单地利用我目前拥有的东西并从中做出一些事情。

为了保留此功能,我决定将选择器逻辑移动到包装父级 reducer

好的,假设我有三个reducer,A、B和C,以及相应的选择器。每个处理一条信息。该片段可以从服务器加载,也可以来自客户端的用户。这将是我原来的选择器:

const makeFinalState(a, b, c) => (new List(a)).map(item => 
  new MyRecord( ...item, ...(b[item.id] || ), ...(c[item.id] || ) );

export const finalSelector = createSelector(
  [selectorA, selectorB, selectorC],
  (a, b, c) => makeFinalState(a, b, c,));

(这不是实际代码,但我希望它有意义。请注意,无论各个 reducer 的内容可用的顺序如何,选择器最终都会生成正确的输出。)

我希望我的问题现在很清楚。如果这些 reducer 的内容发生变化,则会从头开始重新计算选择器,生成所有记录的全新实例,最终导致 React 组件的完全重新渲染。

我目前的解决方案看起来很简单:

export default function finalReducer(state = new Map(), action) 
  state = state
    .update('a', a => aReducer(a, action))
    .update('b', b => bReducer(b, action))
    .update('c', c => cReducer(c, action));

  switch (action.type) 
    case HEAVY_ACTION_AFFECTING_A:
    case HEAVY_ACTION_AFFECTING_B:
    case HEAVY_ACTION_AFFECTING_C:
      return state.update('final', final => (final || new List()).mergeDeep(
        makeFinalState(state.get('a'), state.get('b'), state.get('c')));

    case LIGHT_ACTION_AFFECTING_C:
      const update = makeSmallIncrementalUpdate(state, action.payload);
      return state.update('final', final => (final || new List()).mergeDeep(update))
  


export const finalSelector = state => state.final;

核心思想是这样的:

如果发生大事(即我从服务器获得大量数据),我会重建整个派生状态。 如果发生一些小事情(即用户选择一个项目),我只是在原始减速器和包装父减速器中进行快速增量更改(存在一定的重复性,但必须同时实现一致性和良好性能)。

与选择器版本的主要区别在于我总是将新状态与旧状态合并。 Immutable.js 库足够聪明,不会用新的 Record 实例替换旧的 Record 实例如果它们的内容完全一样。因此保留了原始实例,因此不会重新渲染相应的纯组件。

显然,深度合并是一项代价高昂的操作,因此不适用于非常大的数据集。但事实是,与 React 重新渲染和 DOM 操作相比,这种操作仍然很快。因此,这种方法可以很好地兼顾性能和代码可读性/简洁性。

最后说明:如果不是单独处理那些轻量级的动作,这种方法本质上等同于在纯组件的shouldComponentUpdate 方法中将shallowEqual 替换为deepEqual

【讨论】:

【参考方案2】:

看起来你描述的场景与我写re-reselect的场景非常接近。

re-reselect 是一个小的 reselect 包装器,它使用记忆工厂动态初始化选择器。

(免责声明:我是re-reselect的作者)。

【讨论】:

【参考方案3】:

这种情况通常可以通过重构如何 UI 连接到状态来解决。假设您有一个显示项目列表的组件:您可以将其连接到一个简单的 id 列表,并通过 id 将每个单独的项目连接到其记录,而不是将其连接到已经构建的项目列表。这样,当一条记录发生变化时,id列表本身不会发生变化,只会重新渲染对应的连接组件。

如果在您的情况下,如果记录是从州的不同部分组装而成的,则产生单个记录的选择器本身可能会连接到该特定记录的州的相关部分。

现在,关于 immutable.js 与 reselect 的使用:如果您的状态的原始部分已经是 immutable.js 对象,这种组合效果最好。通过这种方式,您可以利用它们使用持久数据结构的事实,并且 reselect 的默认记忆功能效果最好。你总是可以重写这个记忆函数,但是感觉选择器应该访问它以前的返回值,如果经常表明它负责应该保持在状态中的数据/或者它一次收集太多数据,并且也许更细粒度的选择器会有所帮助。

【讨论】:

以上是关于使用重新选择计算派生状态时如何避免 React 重新渲染的主要内容,如果未能解决你的问题,请参考以下文章

如何在 React JS 中渲染之前等待状态?

(p)React 避免在提交时不必要的重新渲染

(通用 React + redux + react-router)如何避免在初始浏览器加载时重新获取路由数据?

每次我需要进行 React 更改时如何避免重新启动服务器?

React性能优化之减少组件渲染次数

重定向到 React 中的另一条路线时如何传递状态/道具?