overreactedby Dan Abramov

在你使用 memo() 之前

February 23, 2021

关于 React 性能优化的文章已经有很多了。通常来说,如果某个状态更新很慢,你需要:

  1. 确认你正在运行生产环境构建。(开发环境构建本身就会有意变慢,有时甚至会慢一个数量级。)
  2. 确认你没有把状态放得比实际需要的树层级更高。(比如,把输入框的状态放在全局 store 里,可能并不是最佳做法。)
  3. 使用 React DevTools 的 Profiler 工具查看哪些组件被重新渲染了,然后用 memo() 包裹最耗时的子树。(必要时再加上 useMemo()。)

最后这一步其实挺烦人的,尤其是对于中间层的组件来说。理想情况下,编译器应该能帮你自动完成。未来也许会实现。

在这篇文章里,我想分享两种不同的技巧。 它们出奇地基础,所以大家往往没意识到它们能提升渲染性能。

这些技巧是对你已有知识的补充! 它们并不能取代 memouseMemo,但通常值得你先尝试一下。

一个(人为制造的)慢组件

下面这个组件有严重的渲染性能问题:

import { useState } from 'react';
 
export default function App() {
  let [color, setColor] = useState('red');
  return (
    <div>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
      <ExpensiveTree />
    </div>
  );
}
 
function ExpensiveTree() {
  let now = performance.now();
  while (performance.now() - now < 100) {
    // 人为延迟 —— 空转 100ms
  }
  return <p>我是一个非常慢的组件树。</p>;
}

在线体验

问题在于,每当 App 里的 color 变化时,我们都会重新渲染 <ExpensiveTree />,而我们故意让它变得非常慢。

我可以直接在它外面加个 memo(),就算解决了,但这方面的文章已经很多了,这里就不赘述。我想展示两种不同的解决方案。

方案一:下移状态

如果你仔细观察渲染代码,会发现其实只有部分返回的树真正关心当前的 color

export default function App() {
  let [color, setColor] = useState('red');
  return (
    <div>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
      <ExpensiveTree />
    </div>
  );
}

所以我们可以把这部分提取到一个 Form 组件中,并把状态下移到它里面:

export default function App() {
  return (
    <>
      <Form />
      <ExpensiveTree />
    </>
  );
}
 
function Form() {
  let [color, setColor] = useState('red');
  return (
    <>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
    </>
  );
}

在线体验

现在如果 color 发生变化,只有 Form 会重新渲染。问题解决。

方案二:上提内容

上面的方法在状态被用在慢组件树之上的地方时就不适用了。比如,我们把 color 用在了父级 <div> 上:

export default function App() {
  let [color, setColor] = useState('red');
  return (
    <div style={{ color }}>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p>Hello, world!</p>
      <ExpensiveTree />
    </div>
  );
}

在线体验

现在看起来我们没法简单地把不依赖 color 的部分“提取”到另一个组件里,因为那样会连父 <div> 一起提取,而 <ExpensiveTree /> 也会被包含进来。这次好像避不开 memo 了,对吧?

其实还有办法。

你可以在这个沙盒里试试看能不能想到解决方案。

答案其实非常简单:

export default function App() {
  return (
    <ColorPicker>
      <p>Hello, world!</p>
      <ExpensiveTree />
    </ColorPicker>
  );
}
 
function ColorPicker({ children }) {
  let [color, setColor] = useState("red");
  return (
    <div style={{ color }}>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      {children}
    </div>
  );
}

在线体验

我们把 App 组件拆成了两部分。依赖 color 的部分,以及 color 状态本身,都移到了 ColorPicker 组件里。

不关心 color 的部分留在 App 组件中,并作为 JSX 内容(也就是 children 属性)传递给 ColorPicker

color 变化时,ColorPicker 会重新渲染。但它收到的 children 属性和上次是一样的,所以 React 不会遍历那部分子树。

因此,<ExpensiveTree /> 不会重新渲染。

有什么启示?

在你使用 memouseMemo 这类优化之前,不妨先看看能不能把会变化的部分和不会变化的部分拆开。

有趣的是,这些方法本质上和性能其实没什么关系。用 children 属性来拆分组件,通常会让你的应用数据流更清晰,也减少了需要一层一层传递的 props 数量。性能提升只是额外的好处,而不是最终目的。

有意思的是,这种模式还为未来解锁了更多性能优化空间。

比如,当 Server Components 稳定并可用后,我们的 ColorPicker 组件就可以从服务器端接收它的 children。无论是整个 <ExpensiveTree /> 组件还是它的部分内容,都可以在服务器上运行,即使顶层的 React 状态更新,也能“跳过”这些部分,不在客户端重新渲染。

这是 memo 做不到的!但再次强调,这两种方法是互补的。不要忽视下移状态(和上提内容!)

如果还不够,再用 Profiler 工具,适当加点 memo。

你是不是以前看过类似内容?

很可能是的。

这不是新观点。这是 React 组合模型的自然结果。它足够简单,反而容易被低估,其实值得更多关注。

Pay what you like

Edit on GitHub