在你使用 memo() 之前
February 23, 2021
关于 React 性能优化的文章已经有很多了。通常来说,如果某个状态更新很慢,你需要:
- 确认你正在运行生产环境构建。(开发环境构建本身就会有意变慢,有时甚至会慢一个数量级。)
- 确认你没有把状态放得比实际需要的树层级更高。(比如,把输入框的状态放在全局 store 里,可能并不是最佳做法。)
- 使用 React DevTools 的 Profiler 工具查看哪些组件被重新渲染了,然后用
memo()
包裹最耗时的子树。(必要时再加上useMemo()
。)
最后这一步其实挺烦人的,尤其是对于中间层的组件来说。理想情况下,编译器应该能帮你自动完成。未来也许会实现。
在这篇文章里,我想分享两种不同的技巧。 它们出奇地基础,所以大家往往没意识到它们能提升渲染性能。
这些技巧是对你已有知识的补充! 它们并不能取代 memo
或 useMemo
,但通常值得你先尝试一下。
一个(人为制造的)慢组件
下面这个组件有严重的渲染性能问题:
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 />
不会重新渲染。
有什么启示?
在你使用 memo
或 useMemo
这类优化之前,不妨先看看能不能把会变化的部分和不会变化的部分拆开。
有趣的是,这些方法本质上和性能其实没什么关系。用 children
属性来拆分组件,通常会让你的应用数据流更清晰,也减少了需要一层一层传递的 props 数量。性能提升只是额外的好处,而不是最终目的。
有意思的是,这种模式还为未来解锁了更多性能优化空间。
比如,当 Server Components 稳定并可用后,我们的 ColorPicker
组件就可以从服务器端接收它的 children
。无论是整个 <ExpensiveTree />
组件还是它的部分内容,都可以在服务器上运行,即使顶层的 React 状态更新,也能“跳过”这些部分,不在客户端重新渲染。
这是 memo
做不到的!但再次强调,这两种方法是互补的。不要忽视下移状态(和上提内容!)
如果还不够,再用 Profiler 工具,适当加点 memo。
你是不是以前看过类似内容?
这不是新观点。这是 React 组合模型的自然结果。它足够简单,反而容易被低估,其实值得更多关注。
Pay what you like