React 性能优化那些事儿

先看两个例子

  1. 当 Parent 中的 value 发生更新时,A、B、C 中哪些组件会发生 re-render?为什么?
function Parent() {
  <Provider value={xxx}>
   <A />
  </Provider>
}

function A() {
  return <B />
}

const B = React.memo(() => {
  return <C />
})

function C() {
  const ctx = useContext(xxx)
  return <XXX />
}
  1. MemoizedComponentX 是使用 React.memo 包装的组件,当 Parent 发生 re-render 时,MemoizedComponent1、MemoizedComponent2、MemoizedComponent3、MemoizedComponent4 中哪些会发生 re-render?为什么?
function Parent(props) {
  return <>
        <MemoizedComponent1><OtherComponent /></MemoizedComponent1>
        <MemoizedComponent2><div /></MemoizedComponent2>
        <MemoizedComponent3>Hello</MemoizedComponent3>
        <MemoizedComponent4>{props.children}</MemoizedComponent4>
    </>;
}

function App() {
  return <Parent>
    <NormalComponent />
  </Parent>
}

如果你能很快找出上例中哪些组件会发生 re-render,并且知道其发生 re-render 的原因,那后面的内容就无需再看啦。😄

Initial Render 与 Re-render

可以说,re-render 是 React 在 UI=f(State) 理念下让应用「可交互」的核心驱动力。

那什么是非必要的 re-render 呢?

如上图中,组件 B 的 state:foo 更新了,D 消费了 foo,其 re-render 是必要的,而 C、E、F、G 则是非必要的。

什么情况下会发生 re-render ?

  1. state 变化了
class People extends React.Component {
  state = {
    name: 'zhangsan'
  }
  render() {
    return <h1>{this.state.name}</h1>
  }
}
  1. hooks 变化了
function NetworkStatus() {
  const status = useNetworkStatus();
  return <h1>{status}</h1>
}
  1. 父组件 re-render 了
function Parent() {
  const [name, setName] = useState(xx);
  return <Child />;
}

function Child() {
  return <h1>hello child</h1>
}
  1. props 变化了<仅当组件被 memo 时考虑>
function Parent() {
  const [name, setName] = useState(xx);
  return <Child name={name} />;
}
// Child 被 memo,不受 Parent re-render 影响
const Child = React.memo((props) => {
  return <h1>{props.name}</h1>
})
  1. context 变化了
const People = React.meme(() => {
  const name = useSelector(({ name }) => name);
  return <h1>{name}</h1>
})
  1. 祖先组件有声明 childContextTypes,子孙组件订阅了其状态变更【已废弃特性】

* Legacy Context

有哪些优化 Re-render 的手段?

  1. 分离不会同时更新的 Context

image.png
Split contexts that don’t change together

当有 a、b、c 三个状态,a 与 b 更新频率不一样,a 与 c 则属于毫无关联的子业务,则这仨状态可以切分到不同的 Context,避免下层消费 Context 时由于并不关注的状态更新而触发非必要的 re-render。

  1. 为 Provider 的 children 提供 memo,或使用 props.children,以避免 Context 更新时造成子树递归 re-render

image.png

  • 为 Provider 的 children 添加 memo:
function Parent() {
  <Provider value={xxx}>
   <A />
  </Provider>
}
// 这里中断了 Provider 更新触发的子树递归更新
const A = React.memo(() => {
  return <B />
})

// 由于 A 被 memo,B 不会 re-render
const B = () => {
  return <C />
}
// C 消费了 Context,re-render
function C() {
  const ctx = useContext(xxx)
  return <XXX />
}
  • 直接使用 props.children
function Parent() {
  <Provider value={xxx}>
   <A />
  </Provider>
}
// 这里中断了 Provider 更新触发的子树递归更新
const A = React.memo(() => {
  return <B />
})

// 由于 A 被 memo,B 不会 re-render
const B = () => {
  return <C />
}
// C 消费了 Context,re-render
function C() {
  const ctx = useContext(xxx)
  return <XXX />
}
  1. 避免使用 Context 传递频繁更新的值

image.png

如果通过 Context 传递的是更新频率很低甚至不更新的值,那上面两项优化都没必要做了。

react-reduxjotai 等状态管理工具中通过 Context 传递的都是静态对象。

数据更新

非 react life cycle 中,手动合并 update (react 18 之前)

Automatic batching for fewer renders in React 18

数据订阅

  1. 按需订阅数据
  • selector

仅 select 需要的状态,清理不再使用的状态:

function A() {
  // y 未被使用
  const { x, y } = useSelector(({ x, y }) => ({ x, y }), shallowEqual);

  return <>
    <B x={x} />
    ....
    <Other />
  </>
}
  • 直接使用原子状态,不需要「样板式 select」

jotai 为例

function A() {
  const [name, setName] = useAtom(nameAtom)
  return <h1>{name}</h1>
}
  • 更细粒度的数据订阅

基于 Proxy 搜集所需依赖,以 valtio 为例

import { proxy, useSnapshot } from 'valtio'

const state = proxy({ name: '张三', age: 18 });

function A() {
  const snap = useSnapshot(state);
  // 在 name 为 '李四' 前,`age` 的变化不会触发组件 re-render
  return <>
        <p>{snap.name}</p>
        {snap.name === '李四' ? <p>{snap.age}</p> : null}
    </>
}
  1. 拆分更细粒度的组件:
function A() {
  // x 频繁更新,则不宜放在一个 render 成本很高的组件内直接消费
  const { x, y } = useSelector(({ x, y }) => ({ x, y }), shallowEqual);

  return <>
    <B x={x} />
    <C y={y} />
    ....
    <Other />
  </>
}
  1. 等等等等…都快召唤神龙[手动狗头]了怎么还没有 memo、useCallback、useMemo?

何时该用 xxx

脆弱的 memo 与 shouldComponentUpdate 还有那 PureComponent

PureComponentshouldComponentUpdate 的阉割简化版,而 memo 则是弥补 Function Component 下没有 shouldComponentUpdate 而增加的高阶函数。

通过这些手段试图“记忆”组件的方式很容易被破坏:

// 案例一
function App() {
  return <MemoComponent1 onChange={() => {}} />
}

// 案例二
function App() {
  return <MemoComponent2>
    <OtherComponent />
    </MemoComponent2>
}

当 App 发生 re-render 时,无论 MemoComponent1 还是 MemoComponent2 的“记忆”均会失效,为什么呢?

  • 案例一 :onChange 在 App 每次 re-render 时会重新创建为新的 Function,props.onChange 发生了更新
  • 案例二:OtherComponent 在 App 每次 re-render 时会重新创建(想象下 JSX 编译为 React.createElement 每次执行返回新对象),props.children 发生了更新

这时你可能会犀利地发问了:这几个 API 提供的优化能被如此轻易破坏,你当 React 团队是干什么吃的?

当时我大意了,没有闪,但依旧反手丢出了一坨代码:

// 案例一
function App() {
  const handleChange = useCallback(() => {
    // ...
  }, [])
  return <MemoComponent1 onChange={handleChange} />
}

// 案例二
function App() {
  <MemoComponent2>
    {useMemo(() => <OtherComponent />, [])}
    </MemoComponent2>
}

你很不屑地嘀咕道:“不讲武德”。并随之厉声直击我痛处:指望你的组件使用方一直提供稳定的 props 么?


这里其实引出了另一个问题,该不该依靠外部传入稳定的 props 以保证组件的 memo 生效?

我觉得是不应该的。

作为组件的使用方,不应该感知组件的实现,不感知组件实现也就无法知道组件使用了 memo,也就不会主动确保 props 的稳定了。

那为什么不约定成每次都使用 useMemo、useCallback 包装后再传递 props 呢?

  • 造成 useCallback / useMemo hell
  • 对于非 memo 的组件无任何益处
  • 使用 useCallback / useMemo 的开销在很多情况下大于“裸写”
function App() {
  const handleChange = useCallback(() => {
    // App re-render 时,这个函数在内存里会存在两份
  }, []);
  // 最大的误用:未被 memo 的 Component 依旧会 re-render(App re-render 了)
  return <Component onChange={handleChange} />
}

何时该 memo 组件

一句话:把复杂留给自己

如果组件内(上图 B、C)存在计算量大的组件,从而不希望该组件出现“非必要的 re-render”,应该在组件内部处理好何时该 re-render 的问题,这样对于 A 使用 B 抑或 B 使用 C 都无需再担心“非必要 re-render“带来性能问题了:

const InnertExpensiveComponent = React.memo(() => {
  // ...
});

export function MyComponent() {
    return <InnertExpensiveComponent />
}

何时该用 useMemo、useCallback

  • useMemo
// 案例一
function Foo() {
  const memoizedValue = useMemo(() => computeExpensiveValue(), [])
  return <XXX />
}

// 案例二
const Bar = ({ a }) => {
  const memoObj = useMemo(() => {
    return {
      a,
      //....
    }
  }, [a])

  useEffect(() => {
    // do something with memoObj
  }, [memoObj]);

};
  • useCallback
export function Foo() {
  const handleOnChange = useCallback(() =>{}, [])
  return <InnertExpensiveComponent onChange={handleOnChange} />
}

const InnertExpensiveComponent = React.memo(({ onChange }) => {
  // ...
});

结语

过早地介入优化“非必要的 re-render” ROI 可能并不高,优先解决慢的 render 过程收益更大

image.png
Fix the slow render before you fix the re-render

在大多数情况下,组件的非必要 re-render 并不会造成性能问题,React 与浏览器能非常迅速处理该过程。

但如果 re-render 过于频繁地发生在拥有复杂计算的组件上时,可能在 re-render 过程中造成应用变得不可交互甚至完全卡死,此时可使用 React Developer Tools 定位 render 成本很高的组件进行优化。

参考阅读