React.memo React.useMemo对项目性能优化使用详解

React
316
0
0
2023-07-22
目录
  • React.memo
  • 示例
  • 介绍
  • 使用
  • FAQ
  • React.memo 二次优化
  • 小结
  • useMemo
  • 示例
  • 介绍
  • 使用
  • FAQ何时使用?
  • 示例
  • 示例
  • 小结
  • 扩展
  • useCallback

React.memo


这篇文章会详细介绍该何时、如何正确使用它,并且搭配 React.memo 来对我们的项目进行一个性能优化。

示例

我们先从一个简单的示例入手

以下是一个常规的父子组件关系,打开浏览器控制台并观察,每次点击父组件中的 + 号按钮,都会导致子组件渲染。

const ReactNoMemoDemo = () => {
  const [count, setCount] = React.useState();
  return (
    <div>
      <div>Parent Count: {count}</div>
      <button onClick={() => setCount(count => count +)}>+</button>
      <Child name="Son" />
    </div>
  );
};
const Child = props => {
  console.log('子组件渲染了');
  return <p>Child Name: {props.name}</p>;
};
render(<ReactNoMemoDemo />);

子组件的 name 参数明明没有被修改,为什么还是重新渲染?

这就是 React 的渲染机制,组件内部的 state 或者 props 一旦发生修改,整个组件树都会被重新渲染一次,即时子组件的参数没有被修改,甚至无状态组件。

如何处理这个问题?接下里就要说到 React.memo

介绍

React.memo 是 React 官方提供的一个高阶组件,用于缓存我们的需要优化的组件

如果你的组件在相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果。

让我们来改进一下上述的代码,只需要使用 React.memo 组件包裹起来即可,其他用法不变

使用

function ReactMemoDemo() {
  const [count, setCount] = React.useState();
  return (
    <div>
      <div>Parent Count: {count}</div>
      <button onClick={() => setCount(count => count +)}>+</button>
      <Child name="Son" />
    </div>
  );
}
const Child = React.memo(props => {
  console.log('子组件渲染了');
  return <p>Child Name: {props.name}</p>;
});
render(<ReactMemoDemo />);

再次观察控制台,应该会发现再点击父组件的按钮,子组件已经不会重新渲染了。

这就是 React.memo 为我们做的缓存优化,渲染 Child 组件之前,对比 props,发现 name 没有发生改变,因此返回了组件上一次的渲染的结果。

React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹,且其实现中拥有 useState,useReducer 或 useContext 的 Hook,当 state 或 context 发生变化时,它仍会重新渲染。

当然,如果我们子组件有内部状态并且发生了修改,依然会重新渲染(正常行为)。

FAQ

看到这里,不禁会产生疑问,既然如此,那我直接为每个组件都添加 React.memo 来进行缓存就好了,再深究一下,为什么 React 不直接默认为每个组件缓存呢?那这样既节省了开发者的代码,又为项目带来了许多性能的优化,这样不好吗?

使用太多的缓存,反而容易带来 负提升

前面有说到,组件使用缓存策略后,在被更新之前,会比较最新的 props 和上一次的 props 是否发生值修改,既然有比较,那就有计算,如果子组件的参数特别多且复杂繁重,那么这个比较的过程也会十分的消耗性能,甚至高于 虚拟 DOM 的生成,这时的缓存优化,反而产生的负面影响,这个就是关键问题。

当然,这种情况很少,大部分情况还是 组件树的 虚拟 DOM 计算比缓存计算更消耗性能。但是,既然有这种极端问题发生,就应该把选择权交给开发者,让我们自行决定是否需要对该组件进行渲染,这也是 React 不默认为组件设置缓存的原因。

也因此,在 React 社区中,开发者们也一致的认为,不必要的情况下,不需要使用 React.memo。

什么时候该用? 组件渲染过程特别消耗性能,以至于能感觉到到,比如:长列表、图表等

什么时候不该用?组件参数结构十分庞大复杂,比如未知层级的对象,或者列表(城市,用户名)等

React.memo 二次优化

React.memo 默认每次会对复杂的对象做对比,如果你使用了 React.memo 缓存的组件参数十分复杂,且只有参数属性内的某些/某个字段会修改,或者根本不可能发生变化的情况下,你可以再粒度化的控制对比逻辑,通过 React.memo 第二个参数

function MyComponent(props) {
  /* 使用 props 渲染 */
}
function shouldMemo(prevProps, nextProps) {
  /*
  如果把 nextProps 传入 render 方法的返回结果与
  将 prevProps 传入 render 方法的返回结果一致则返回 true,
  否则返回 false
  */
}
export default React.memo(MyComponent, shouldMemo);

如果对 class 组件有了解过的朋友应该知道,class 组件有一个生命周期叫做 shouldComponentUpdate(),也是通过对比 props 来告诉组件是否需要更新,但是与这个逻辑刚好相反。

小结

对于 React.memo,无需刻意去使用它进行缓存组件,除非你能感觉到你需要。另外,不缓存的组件会多次的触发 render,因此,如果你在组件内有打印信息,可能会被多次的触发,也不用去担心,即使强制被 rerender,因为状态没有发生改变,因此每次 render 返回的值还是一样,所以也不会触发真实 dom 的更新,对页面实际没有任何影响。

useMemo

示例

同样,我们先看一个例子,calculatedCount 变量是一个假造的比较消耗性能的计算表达式,为了方便显示性能数据打印时间,我们使用了 IIFE 立即执行函数,每次计算 calculatedCount 都会输出它的计算消耗时间。

打开控制台,因为是 IIFE,所以首次会直接打印出时间。然后,再点击 + 号,会发现再次打印出了计算耗时。这是因为 React 组件重渲染的时候,不仅是 jsx,而且变量,函数这种也全部都会再次声明一次,因此导致了 calculatedCount 重新执行了初始化(计算),但是这个变量值并没有发生改变,如果每次渲染都要重新计算,那也是十分的消耗性能。

注意观察,在计算期间,页面会发生卡死,不能操作,这是 JS 引擎 的机制,在执行任务的时候,页面永远不会进行渲染,直到任务结束为止。这个过程对用户体验来说是致命的,虽然我们可以通过微任务去处理这个计算过程,从而避免页面的渲染阻塞,但是消耗性能这个问题仍然存在,我们需要通过其他方式去解决。

function UseMemoDemo() {
  const [count, setCount] = React.useState();
  const calculatedCount = (() => {
    let res =;
    const startTime = Date.now();
    for (let i =; i <= 100000000; i++) {
      res++;
    }
    console.log(`Calculated Count 计算耗时:${Date.now() - startTime} ms`);
    return res;
  })();
  return (
    <div>
      <div>Parent Count: {count}</div>
      <button onClick={() => setCount(count => count +)}>+</button>
      <div>Calculated Count: {calculatedCount}</div>
    </div>
  );
}

介绍

const memoizedValue = useMemo(() => {
  // 处理复杂计算,并 return 结果
}, []);

useMemo 返回一个缓存过的值,把 "创建" 函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算

第一个参数是函数,函数中需要返回计算值

第二个参数是依赖数组

  • 如果不传,则每次都会初始化,缓存失败
  • 如果传空数组,则永远都会返回第一次执行的结果
  • 如果传状态,则在依赖的状态变化时,才会从新计算,如果这个缓存状态依赖了其他状态的话,则需要提供进去。

这下就很好理解了,我们的 calculatedCount 没有任何外部依赖,因此只需要传递空数组作为第二个参数,开始改造

使用

function UseMemoDemo() {
  const [count, setCount] = React.useState();
  const calculatedCount = useMemo(() => {
    let res =;
    const startTime = Date.now();
    for (let i =; i <= 100000000; i++) {
      res++;
    }
    console.log(`Memo Calculated Count 计算耗时:${Date.now() - startTime} ms`);
    return res;
  }, []);
  return (
    <div>
      <div>Parent Count: {count}</div>
      <button onClick={() => setCount(count => count +)}>+</button>
      <div>Memorized Calculated Count: {calculatedCount}</div>
    </div>
  );
}

现在,"Memo Calculated Count 计算耗时"的输出信息永远只会打印一次,因为它被无限缓存了。

FAQ何时使用?

当你的表达式十分复杂需要经过大量计算的时候

示例

下面示例中,我们使用状态提升,将子组件的 click 事件函数放在了父组件中,点击父组件的 + 号,发现子组件被重新渲染

const FunctionPropDemo = () => {
  const [count, setCount] = React.useState();
  const handleChildClick = () => {
    //
  };
  return (
    <div>
      <div>Parent Count: {count}</div>
      <button onClick={() => setCount(count => count +)}>+</button>
      <Child onClick={handleChildClick} />
    </div>
  );
};
const Child = React.memo(props => {
  console.log('子组件渲染了');
  return (
    <div>
      <div>Child</div>
      <button onClick={props.onClick}>Click Me</button>
    </div>
  );
});
render(<FunctionPropDemo />);

于是我们想到用 memo 函数包裹子组件,给缓存起来

const FunctionPropDemo = () => {
  const [count, setCount] = React.useState();
  const handleChildClick = () => {
    //
  };
  return (
    <div>
      <div>Parent Count: {count}</div>
      <button onClick={() => setCount(count => count +)}>+</button>
      <Child onClick={handleChildClick} />
    </div>
  );
};
const Child = React.memo(props => {
  console.log('子组件渲染了');
  return (
    <div>
      <div>Child</div>
      <button onClick={props.onClick}>Click Me</button>
    </div>
  );
});
render(<FunctionPropDemo />);

但是意外来了,即使被 memo 包裹的组件,还是被重新渲染了,为什么!

我们来逐一分析

  • 首先,点击父组件的 + 号,count 发生变化,于是父组件开始重渲染
  • 内部的未经处理的变量和函数都被重新初始化,useState 不会再初始化了, useEffect 钩子函数重新执行,虚拟 dom 更新
  • 执行到 Child 组件的时候,Child 准备更新,但是因为它是 memo 缓存组件,于是开始浅比较 props 参数,到这里为止一切正常
  • Child 组件参数开始逐一比较变更,到了 onClick 函数,发现值为函数,提供的新值也为函数,但是因为刚刚在父组件内部重渲染时被重新初始化了(生成了新的地址),因为函数是引用类型值,导致引用地址发生改变!比较结果为不相等, React 仍会认为它已更改,因此重新发生了渲染。

既然函数重新渲染会被重新初始化生成新的引用地址,因此我们应该避免它重新初始化。这个时候,useMemo 的第二个使用场景就来了

const FunctionPropDemo = () => {
  const [count, setCount] = React.useState();
  const handleChildClick = useMemo(() => {
    return () => {
      //
    };
  }, []);
  return (
    <div>
      <div>Parent Count: {count}</div>
      <button onClick={() => setCount(count => count +)}>+</button>
      <Child onClick={handleChildClick} />
    </div>
  );
};
const Child = React.memo(props => {
  console.log('子组件渲染了');
  return (
    <div>
      <div>Child</div>
      <button onClick={props.onClick}>Click Me</button>
    </div>
  );
});
render(<FunctionPropDemo />);

这里我们将原本的 handleChildClick 函数通过 useMemo 包裹起来了,另外函数永远不会发生改变,因此传递第二参数为空数组,再次尝试点击 + 号,子组件不会被重新渲染了。

对于对象,数组,renderProps(参数为 react 组件) 等参数,都可以使用 useMemo 进行缓存

示例

既然 useMemo 可以缓存变量函数等,那组件其实也是一个函数,能不能被缓存呢?我们试一试

继续使用第一个案例,将 React.memo 移除,使用 useMemo 改造

const ReactNoMemoDemo = () => {
  const [count, setCount] = React.useState();
  const memorizedChild = useMemo(() => <Child name="Son" />, []);
  return (
    <div>
      <div>Parent Count: {count}</div>
      <button onClick={() => setCount(count => count +)}>+</button>
      {memorizedChild}
    </div>
  );
};
const Child = props => {
  console.log('子组件渲染了');
  return <p>Child Name: {props.name}</p>;
};
render(<ReactNoMemoDemo />);

尝试点击 + 号,是的,Child 被 useMemo 缓存成功了!

小结

同样的,不是必要的情况下,和 React.memo 一样,不需要特别的使用 useMemo

使用场景

  • 表达式有复杂计算且不会频发触发更新
  • 引用类型的组件参数,函数,对象,数组等(一般情况下对象和数组都会从 useState 初始化,useState 不会二次执行,主要是函数参数)
  • react 组件的缓存

扩展

useCallback

前面使用 useMemo 包裹了函数,会感觉代码结构非常的奇怪

const handleChildClick = useMemo(() => {
  return () => {
    //
  };
}, []);

函数中又 return 了一个函数,其实还有另一个推荐的 API, useCallback 来代替于对函数的缓存,两者功能是完全一样,只是使用方法的区别,useMemo 需要从第一个函数参数中 return 出要缓存的函数,useCallback 则直接将函数传入第一个参数即可

const handleChildClick = useCallback(() => {
  //
}, []);

代码风格上简介明了了许多

看完这篇文章,相信你对 React.memo 和 React.useMemo 已经有了一定的了解,并且知道何时/如何使用它们了