React 组件性能优化:如何避免不必要的 re-render
什么是组件 re-render
React 中组件的渲染主要有两种原因:
- 组件的初始渲染
- 组件(或者该组件的某一个祖先组件)的状态(state)发生了更新
其中第二种原因导致的组件渲染即是我们所说的 re-render,一般发生在用户交互操作或者异步请求数据之后。
按照组件本次 re-render 是否是必须的,我们可以将 re-render 分为必须 re-render 和非必须 re-render:
- 必须 re-render:由于组件依赖的状态发生了变化,必须重新渲染以保证 UI 正确
- 非必须 re-render:其他组件重新渲染引起的连带渲染,即使不重新渲染也不会影响 UI 的一致
非必须的 re-render 本身不是一个问题。React render 的过程是非常快的,依赖的状态没有发生变化的话,在 React commit 阶段也不会有额外的 DOM 操作。但是,如果 re-render 发生的太频繁,或者 re-render 过程中有耗时的计算逻辑,或者在非常复杂的应用中,re-render 涉及了大量的组件,这时就会有严重的性能问题。
哪些操作会触发组件 re-render
组件状态更新
当组件的状态发生更新时,会触发该组件的 re-render。这包括类组件的状态、函数组件中的 hooks 状态和自定义 hooks 依赖的状态。 状态更新通常发生在 effect 或者回调函数中。
function Component() {
const [state, setState] = useState(1);
useEffect(() => {
// ...
setState(2)
}, [...])
// ...
}
父组件 re-render
如下代码所示,Parent 组件的 re-render 会导致 Child 组件的 re-render。
function Parent() {
return <Child />;
}
Context 变更
如下代码所示,在 Component1 里触发 Context 的变更会导致使用 Context 的 Component1 和 Component2 都发生 re-render。
const Context = createContext<[number, Dispatch<SetStateAction<number>>]>(null!);
function Component1() {
const [state, setState] = useContext(Context);
return <button onClick={() => setState(v => (v + 1))}>click</button>
};
function Component2() {
const [state, setState] = useContext(Context);
return <div>123</div>
};
function Provider({ children }: PropsWithChildren) {
return <Context.Provider value={useState(1)}>
{children}
</Context.Provider>
}
function App() {
return <Provider>
<Component1 />
<Component2 />
</Provider>
}
一个误解:props 变更触发了组件 re-render
props 变更与否不是原因,而是父组件的 re-render 触发了子组件的 re-render,不管父组件传给子组件的 props 有没有变化。仅当子组件是 PureComponent 或者用 React.memo 包裹时,才会根据 props 是否变化来决定子组件是否 re-render。
如何优化 re-render 导致的性能问题
首先,re-render 在大部分情况下是不会有性能问题的。作为开发者,我们可能会高估 re-render 的成本,如果你不知道是否需要优化,那就是不需要。这儿我们仅仅讨论有哪些可以优化的方法,方便在需要的时候能够参考。
缩小 re-render 范围
如下优化前的案例所示,点击按钮显示 Modal 时,Component 的 re-render 会导致 SlowComponent 的 re-render。我们可以将 Modal 显示的逻辑抽到一个单独的组件中,如下优化后代码所示,这样点击按钮显示 Modal 时就只有 ButtonWithModal 会 re-render。
优化前:
function Component() {
const [visible, setVisible] = useState(false);
return (
<div>
<button onClick={() => setVisible(true)}>open</button>
{visible ? <Modal /> : null}
<SlowComponent />
</div>
);
}
优化后:
function ButtonWithModal() {
const [visible, setVisible] = useState(false);
return (
<>
<button onClick={() => setVisible(true)}>open</button>
{visible ? <Modal /> : null}
</>
);
}
function Component() {
return (
<div>
<ButtonWithModal />
<SlowComponent />
</div>
);
}
components as props
如下优化前的案例所示,点击时 Component 的 re-render 会触发三个 SlowComponent 的 re-render。我们可以将 components 作为 props 往下传,改造成下面优化后的代码,这样点击时只会触发 ComponentWithClick 的 re-render。
优化前:
function Component() {
const [val, setVal] = useState('');
return (
<div onClick={() => setVal('...')}>
<SlowComponent1 />
<div>{val}</div>
<SlowComponent2 />
<SlowComponent3 />
</div>
);
}
优化后:
function ComponentWithClick({ top, bottom, children }) {
const [val, setVal] = useState('');
return (
<div onClick={() => setVal('...')}>
{top}
<div>{val}</div>
{children}
{bottom}
</div>
);
}
function Component() {
return (
<ComponentWithClick top={<SlowComponent1 />} bottom={<SlowComponent3 />}>
<SlowComponent2 />
</ComponentWithClick>
);
}
合理使用 memo、useMemo 、useCallback 和 PureComponent
如下案例所示,用 memo 或 useMemo 优化后 Component 的 re-render 不会再触发 SlowComponent 的 re-render。
优化前:
function Component() {
return <SlowComponent />;
}
优化后:
const SlowComponentMemo = memo(SlowComponent);
function Component() {
return <SlowComponentMemo />;
}
// 或者
function Component() {
const slowComponentNode = useMemo(() => {
return <SlowComponent />;
}, []);
return slowComponentNode;
}
当有 props 是引用类型时,需要使用 useMemo 结合 memo 来优化。
优化前:
function Component() {
return <SlowComponent value={{ a: 1 }} />;
}
优化后:
const SlowComponentMemo = memo(SlowComponent);
function Component() {
const value = useMemo(() => ({ a: 1 }), []);
return <SlowComponentMemo value={value} />;
}
如何优化 Context 导致的 re-render
我们把 Context 单独拿出来讨论,是因为 Context 经常被用来做全局的状态管理。在一个复杂的应用中,如果使用不合理的话,对 Context 中一个只使用在某个小组件内的字段的更改,都可能导致整个应用的重新渲染。
结合 useMemo 使用
如下案例,如果有父组件会触发 Component 重新渲染的话,可以用 useMemo 来保证传给 Context.Provider 的值在 state 没变时也不会变。
function Component({ children }) {
const [state, setState] = useState(1);
const value = useMemo(
() => ({
state,
setState,
}),
[state],
);
// value={{state, setState}} 这种写法,每次重新渲染,都会传入一个新的 object
return <Context.Provider value={value}>{children}</Context.Provider>;
}
将数据拆得更细
将数据拆得更细,这样在更新某一个细小的数据时,只有使用了这个数据的组件会 re-render。更新数据的 setState 也可以单独拆成一个,这样在只使用了 setState 的组件里,不会因为 Context 状态更新而 re-render。
function Component({ children }) {
const [state, setState] = useState({a: 1, b: 2});
return <Context1.Provider value={state.a}>
<Context2.Provider value={state.b}>
<Context3.Provider value={setState}>
{children}
</Context3.Provider>
</Context2.Provider>
</Context2.Provider>
}
使用 Context selector
selector 的使用方式常见的有两种,一种是高阶组件,其内部配合 memo、PureCompoent 或 shouldComponentUpdate 来控制组件是否 re-render,比如 react-redux 的 connect(mapStateToProps?, mapDispatchToProps?); 一种是 useSelector hook,比如 react-redux 的 useSelector 和 use-context-selector,hook 的实现方式一般传给 Context.Provider 的 value 是固定的 store,hook 内部实现了对 store 的监听,然后根据 selector 的结果决定是否需要重新渲染组件。
一个极简的高阶组件 selector 大概如下代码所示:
function withContextSelector(Component, selector) {
const ComponentMemo = React.memo(Component);
return (props) => {
const data = useContext(Context);
const contextProps = selector(data);
return <ComponentMemo {...props} {...contextProps} />;
};
}
use-context-selector 使用方式大概如下所示。 如果考虑选择使用 use-context-selector 的话,可以直接使用 react-tracked,其内部使用了 use-context-selector,并且用 Proxy 自动追踪组件实际使用的 state,以优化组件的 re-render。
import { createContext, useContextSelector } from 'use-context-selector';
const PersonContext = createContext({ a: '', b: '' });
function Component() {
const a = useContextSelector(PersonContext, state => state.a);
return ...
}
总结
本文首先整理了什么是 React 组件的 re-render,然后分析了触发组件 re-render 的常见操作,最后总结了优化 re-render 导致的性能问题的常见方法。在开发 React 应用的过程中遇到性能问题时,希望本文能给读者提供一个可供参考的优化思路。
转载自:https://juejin.cn/post/7199890888939421753