一个简单技巧优化 React 重新渲染

lxf2023-03-13 09:20:01

题图来自 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 (付费文档