React 组件性能优化:如何避免不必要的 re-render

5,048 阅读5分钟

什么是组件 re-render

React 中组件的渲染主要有两种原因:

  1. 组件的初始渲染
  2. 组件(或者该组件的某一个祖先组件)的状态(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 的 useSelectoruse-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 应用的过程中遇到性能问题时,希望本文能给读者提供一个可供参考的优化思路。