一个简单技巧优化 React 重新渲染
题图来自 unsplash@charlespostiaux
其实复杂组件的一个很重要的性能指标,就是组件是否频繁的重新渲染,所以我们在引用较大较复杂组件的时候,都会有意识的去使用 memo 或者 usememo 去处理,当组件属性没有变化时,不重新渲染组件。举个最常见的例子,在引入图表组件的时候,我们通常会使用 memo 处理。
import React, { memo } from 'react';
const Demo = memo(({ data }) => (<Chart data={data} />));
这么做看起来没什么问题,似乎 memo 是个万能解,如果你现在就是这么想的,那请你停下来思考一个问题:如果 memo 这么好用,为什么 react 官方不把它当作默认行为?
其实组件重绘并不是唯一的一个性能指标,还有另一个性能指标就是 react 比对属性是否变更,“对于 props 很多且没有很多子组件的组件来说,相比重绘,检查属性是否变更带来的消耗可能更大。因此,如果对每个组件都进行 React.memo,可能会产生反效果。--- form: 云谦”
结论
在使用 React.memo() 之前,还可以考虑两个方法,让重绘保持在一个很小的范围之内。
1、把状态往下移,把可变的部分拆到平行组件里 2、把内容往上提,把可变的部分拆到父级组件里
怎么快速判断组件发生重绘
这里也有一个非常简单的小技巧,但是知道的人却不多。其实我们可以在组件中加上 {Math.random()}
。这样每一次重绘,我们都能得到一个全新的随机数,一眼就能看出组件差异。
展开说说
一个常见的用例
import React from 'react';
const Logger = (props) => {
console.log(`${props.label} rendered`);
return <div>{Math.random()}</div>;
};
export default () => {
const [count, setCount] = React.useState(0);
const increment = () => setCount((c) => c + 1);
return (
<div>
<button onClick={increment}>The count is {count}</button>
<Logger label="counter" />
</div>
);
};
上面是一个很简单也是很常见的项目中的写法,但是当我们点击按钮的时候,你会发现 Logger 也发生了重绘,但是当我们查看 Logger 的 props 时,我们很容易发现 Logger 的属性并没有发生变化。
状态往下移
我们将上面的用例做一点修改,将 count 相关的变化,移动到一个平行的组件中
import React from 'react';
const Logger = (props) => {
console.log(`${props.label} rendered`);
return <div>{Math.random()}</div>;
};
const Count = (props) => {
const [count, setCount] = React.useState(0);
const increment = () => setCount((c) => c + 1);
return <button onClick={increment}>The count is {count}</button>;
};
export default () => {
return (
<div>
<Count />
<Logger label="counter" />
</div>
);
};
不难看出,上面的用例和原始用例在渲染后时一样的页面,但是此时当你点击按钮导致计数加 1 的时候,并不会导致 Logger 组件重绘,从页面上可以看到 Logger 的随机数没有发生变化。
内容往上提
还是上面的用例,我们将 Logger 作为 Count 的属性,传递到 Count 中,最终渲染的页面还是一样的,但是点击按钮同样不会导致 Logger 重绘。(注意随机数没有变化)
import React from 'react';
const Logger = (props) => {
console.log(`${props.label} rendered`);
return <div>{Math.random()}</div>;
};
const Count = (props) => {
const [count, setCount] = React.useState(0);
const increment = () => setCount((c) => c + 1);
return (
<div>
<button onClick={increment}>The count is {count}</button>
{props.logger}
</div>
);
};
export default () => <Count logger={<Logger label="counter" />} />;
Context 变化导致的组件重绘
我们还是看一个最简单的 react Context 的用法,甚至是 react 官网的用例。
import React, { createContext, StrictMode, useContext } from 'react';
const MyContext = createContext<any>(null);
const Logger = (props) => {
return <div>{Math.random()}</div>;
};
const Count = (props) => {
const { count, setCount } = useContext(MyContext);
const increment = () => setCount((c) => c + 1);
return (
<div>
<button onClick={increment}>The count is {count}</button>
</div>
);
};
const Body = (props) => (
<div>
<div>Counter</div>
<Count />
<Count />
<div>Logger</div>
<Logger />
</div>
);
export default () => {
const [count, setCount] = React.useState(0);
return (
<StrictMode>
<MyContext.Provider value={{ count, setCount }}>
<Body />
</MyContext.Provider>
</StrictMode>
);
};
当我们点击按钮时,会导致 count 变化,因此所有关联了 count 的组件都会发生重绘,这是正确的行为,但是如果你留心观察的话,你就会发现,当 count 变化的时候,Logger 也发生了重绘。
可是我们的 Logger 并没有关联 Context,它发生重绘,就是个 bug 了。而且非常影响整个页面的性能。因为一般我们的 MyContext.Provider
会在很顶层的位置使用它,甚至大部分情况,我们会在整个组件的最顶层用到它,这意味着每次属性变化,将会导致所有的组件发生重绘。
const Logger = (props) => {
return <div>{Math.random()}</div>;
};
其实要解决这个问题,我们只需要简单的将 Provider 封装成一个简单的组件,
const Provider = (props) => {
const [count, setCount] = React.useState(0);
return (
<MyContext.Provider value={{ count, setCount }}>
{props.children}
</MyContext.Provider>
);
};
最终代码如下:
import React, { createContext, StrictMode, useContext } from 'react';
const MyContext = createContext<any>(null);
const Logger = (props) => {
return <div>{Math.random()}</div>;
};
const Count = (props) => {
const { count, setCount } = useContext(MyContext);
const increment = () => setCount((c) => c + 1);
return (
<div>
<button onClick={increment}>The count is {count}</button>
</div>
);
};
const Body = (props) => (
<div>
<div>Counter</div>
<Count />
<Count />
<div>Logger</div>
<Logger />
</div>
);
const Provider = (props) => {
const [count, setCount] = React.useState(0);
return (
<MyContext.Provider value={{ count, setCount }}>
{props.children}
</MyContext.Provider>
);
};
export default () => {
return (
<StrictMode>
<Provider>
<Body />
</Provider>
</StrictMode>
);
};
此时你再点击按钮,将会发现 Logger 的随机数不再变化,觉得上面两段代码没有差异的,可以回去看前面的内容。
进一步优化 Context
上面我们可以看到,当我们关联了 Context 的时候,它的值变化导致的组件绘制,这种行为我们认为是正确的,但是其实组件重绘应该只发生在我们关心的数据变化,比如 Context 的 value 为 "{label,count}"。
Count 关联 count 数据,Logger 关联 label 数据时,当 count 变化的时候,Logger 也不应该发生重绘制。
要达到这个效果我们可以用 use-context-selector
代替 React.createContext
pnpm i use-context-selector
- import { createContext, useContext } from 'react';
+ import { createContext, useContext } from 'use-context-selector';
扩展
会导致组件重绘的四个原因:状态变化、父组件 re-render、Context 变化和 Hooks 变化。
误解《Props 变化会导致 re-render?》
其实不会,props 往上可以追溯到 state 变更,是 state 变更导致父组件 re-render 从而引发子组件 re-render,而不是由 props 变更引起。
参考
overreacted.io/before-you-… (英文)
kentcdodds.com/blog/optimi… (英文)
t.zsxq.com/07nI6E66A (付费文档)
转载自:https://juejin.cn/post/7179540836865015868