愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

【译】明智地使用 React.memo()

原文:Use React.memo() wisely

用户都喜欢快速响应的用户界面(UI),少于100毫秒的UI响应延迟会让用户感觉比较快,而100~300毫秒的延迟就已经能够被察觉了。为了提高UI的性能,React提供了 React.memo() 这么一个高阶组件。当使用 React.memo() 来包裹一个组件时,React会记忆渲染过的输出从而跳过不必要的渲染过程。本文描述了什么时候要用 React.memo() 来提高性能的场景,还有非常重要的是警告那些没有必要使用它的使用场景。

1. React.memo()

当需要更新 DOM 的时候,React首先会渲染组件,然后比较先前的渲染结果,如果结果不一样的话,React才会去更新DOM。当前渲染结果和先前渲染结果的对比过程是很快的,但是针对一些场景仍然可以加速这个渲染过程。

当用 React.memo() 包裹一个组件的时候,React就会渲染组件并且记忆渲染结果。在下一次渲染的时候,如果新的 props 是一样的,React会复用已记忆的结果来跳过这一次渲染。

我们可以实践下看看记忆的结果,如下的函数式组件 Movie 被包裹在了 React.memo() 里:

export function Movie({ title, releaseDate }) {
  return (
    <div>
      <div>Movie title: {title}</div>
      <div>Release date: {releaseDate}</div>
    </div>
  );
}

export const MemoizedMovie = React.memo(Movie);

React.memo(Movie)返回了一个新的记忆组件MemoizedMovie,它会和原先的 Movie 组件输出一样的内容,但不同的一点是:MemoizedMovie 渲染的输出是被记忆住的,只要 titlereleaseDate 这些 props 在下次渲染时候不变,记忆住的内容就会被复用。

// 首次渲染. React调用MemoizedMovie函数。
<MemoizedMovie 
  title="Heat" 
  releaseDate="December 15, 1995" 
/>

// 下一轮渲染里,React没有调用MemoizedMovie函数,
// 组织了渲染过程
<MemoizedMovie
  title="Heat" 
  releaseDate="December 15, 1995" 
/>

可以打开这个Demo,然后展开 console,就会看到 React 只渲染了一次 <MemoizedMovie>,而 <Movie> 则重复渲染了很多次。

你获得了一个性能提升技巧:通过复用记忆住的内容,React会跳过组件渲染的过程,并且不会做虚拟DOM的差异检查。而同样的功能,对于类式组件则是通过 PureComponent 实现的。

1.1 自定义props相等性检查

默认情况下,React.memo() 会对 propsprops 中的对象做一个浅对比。而你可以使用第二个参数来指明相等性检查函数:

React.memo(Component, [areEqual(prevProps, nextProps)]);

如果 prevPropsnextProps 是一样的时候,那么 areEqual(prevProps, nextProps) 函数必须返回 true

如下例子,我们手动地计算了传入 Movie 组件的 props 是否相等:

function moviePropsAreEqual(prevMovie, nextMovie) {
  return prevMovie.title === nextMovie.title
    && prev.releaseDate === nextMovie.releaseDate;
}
const MemoizedMovie2 = React.memo(Movie, moviePropsAreEqual);

当先前的 props 和下次的 props 是一样的时候,moviePropsAreEqual() 函数会返回 true


2. 何时使用React.memo()

2.1 组件频繁地使用同样的props渲染

将一个组件用 React.memo() 包裹的最佳案例是当你预期一个函数式组件会频繁渲染且总是使用同样的props的时候。

而一个常见的用同样的props渲染一个组件的场景是被父组件强迫渲染。

让我们再次使用上面定义的 Movie 组件,一个新的父组件 MovieViewsRealtime 显示了一部电影的观看量,这个观看量是实时更新的:

function MovieViewsRealtime({ title, releaseDate, views }) {
  return (
    <div>
      <Movie title={title} releaseDate={releaseDate} />
      Movie views: {views}
    </div>
  );
}

应用在后台每秒规律地向服务器轮询,然后更新 MovieViewRealtime 组件的 views 属性。

// 初始渲染
<MovieViewsRealtime 
  views={0} 
  title="Forrest Gump" 
  releaseDate="June 23, 1994" 
/>

// 1秒后,观看量是10
<MovieViewsRealtime 
  views={10} 
  title="Forrest Gump" 
  releaseDate="June 23, 1994" 
/>

// 2秒后,观看量是25
<MovieViewsRealtime 
  views={25} 
  title="Forrest Gump" 
  releaseDate="June 23, 1994" 
/>

// 等等

每次 views 属性都会更新到一个新的数值,从而触发 MovieViewsRealtime 渲染,进而触发 Movie 渲染(尽管 titlereleaseDate 一直是一样的)

而这正是对 Movie 组件应用记忆功能的场景。通过使用记忆组件 MemoizedMovie 来改进一下 MovieViewsRealtime

function MovieViewsRealtime({ title, releaseDate, views }) {
  return (
    <div>
      <MemoizedMovie title={title} releaseDate={releaseDate} />
      Movie views: {views}
    </div>
  )
}

只要 titlereleaseDate 一样,React就会跳过渲染 MemoizedMovie,这提高了 MovieViewsRealtime 的性能。

组件越是用同样的props频繁渲染,输出结果中带有的繁重性、昂贵计算性工作越多,组件就越是应当被包裹在 React.memo()

总而言之,可以使用 profiling 工具来测量一下应用了 React.memo() 所带来的好处。


3. 何时避免使用React.memo()

如果组件并没有“常常使用同样的属性重新渲染”,那么很可能你并不需要使用 React.memo()。记住如下的经验法则:当你不能够量化性能提升时,就不要使用记忆功能了。

“性能相关的不合理使用进行的修改甚至会拖累性能,应明智地使用 React.memo()

包裹 React.memo() 在类式组件上是不推荐的,尽管这么做是可行的。应当继承 PureComponent 类,或者在需要自定义类式组件的相等性检查时,定义一个 shouldComponentUpdate() 方法的实现。

3.1 无用的props对比

设想有一个组件是典型的用不同的props渲染的,在这种情况下,记忆功能并不能带来什么好处。即使你对如此一个易变的组件包裹了 React.memo(),React每次渲染的时候还是会做两件事情:

  1. 调用对比函数来决定先前props和下次props是否相等
  2. 因为props对比几乎总是返回 false,React每次都会执行对先前结果和当前渲染结果的差异对比

因为对比函数总是返回 false,所以对它的调用是无意义的。


4. React.memo()和回调函数

函数对象只等于自身,因此让我们看看比较一类函数时会怎么样:

function sumFactory() {
  return (a, b) => a + b;
}

const sum1 = sumFactory();
const sum2 = sumFactory();

console.log(sum1 === sum2); // => false
console.log(sum1 === sum1); // => true
console.log(sum2 === sum2); // => true

sumFactory 是一个工厂函数。它返回了一个求和两个数值的函数。

函数 sum1sum2 通过这个工厂创建了出来,它们的功能都是求和两个数值。然而,sum1sum2 是补贴的函数对象( sum1 === sum2false)。

每次父组件为它的子组件定义一个回调函数的时候,可能会创建新的函数实例。让我们研究下这是怎样打断记忆,然后该怎样修复。

如下的组件 Logout 接收了 onLogout 这个回调prop:

function Logout({ username, onLogout }) {
  return (
    <div onClick={onLogout}>
      Logout {username}
    </div>
  );
}

const MemoizedLogout = React.memo(Logout);

一个接收回调函数的组件在应用记忆功能的时候应该谨慎地处理。父组件可能会在每次渲染的时候提供不同的回调函数实例:

function MyApp({ store, cookies }) {
  return (
    <div className="main">
      <header>
        <MemoizedLogout
          username={store.username}
          onLogout={() => cookies.clear('session')}
        />
      </header>
      {store.content}
    </div>
  );
}

即使提供了同样的 username 值,MemoizedLogout 还是会每次都渲染,因为它接收了一个新的 onLogout 回调函数实例,记忆性被打破了。为了修复这个问题,onLogout prop必须接收同样的回调实例。让我们使用 useCallback() 来保留每次渲染之间的 callback 实例吧:

const MemoizedLogout = React.memo(Logout);

function MyApp({ store, cookies }) {
  const onLogout = useCallback(
    () => cookies.clear('session'),
    [cookies]
  );
  return (
    <div className="main">
      <header>
        <MemoizedLogout
          username={store.username}
          onLogout={onLogout}
        />
      </header>
      {store.content}
    </div>
  );
}

只要 cookies 是一样的,那么 useCallback(() => cookies.clear('session'), [cookies]) 就会总是返回同样的函数实例,因此 MemoizedLogout 的记忆性被修复了。


5. React.memo()是一种性能暗示

严格上来说,React使用记忆性作为一种性能暗示。尽管在大多数场景下,React会避免渲染记忆性的组件,但是你仍然不应该依靠这个来避免渲染


6. React.memo()和hooks

使用hooks的组件可以自由地被包裹在 React.memo() 里来实现记忆性。使用 useState() 时,React总是确保会在状态变更的时候重新渲染组件,即使组件被包裹在了 React.memo() 中。


7. 结论

React.memo() 是一个很好用的记忆函数式组件的工具。恰当地应用它,就能够防止当下一props和先前props相等时的无意义组件渲染。

对于使用回调函数作为props来记忆组件的时候应该保持警惕,确保为每次渲染提供同样的回调函数实例。

别忘了使用 profiling 来测量记忆性带来的性能提升。