likes
comments
collection
share

React Performance之 useMemo useCallback

作者站长头像
站长
· 阅读数 17

说到性能相关的东西主要是涉及到了component的re-render机制。性能优化方向之一就是减少不必要的重复re-render。

版本:react 18.2.x

在开始之前需要说一个前提:

在React开发模式下使用StrictMode,其下的所有组件都会重复渲染一次,即默认一个组件渲染两次。仅仅在开发模式(所以举例代码在开发模式下,会出两次一样的console),生产并不会这样。官方对于此解释:

In Strict Mode, React will call some of your functions twice instead of once:

This development-only behavior helps you keep components pure. React uses the result of one of the calls, and ignores the result of the other call. As long as your component and calculation functions are pure, this shouldn’t affect your logic. However, if they are accidentally impure, this helps you notice and fix the mistake.

develop模式下,渲染两次主要是为了确认,同一个component渲染两次结果是相同的(同时也确认你的组件是一个纯函数),提前发现程序的bug,详情参考StrictMode

Hooks

useMemo

useMemo这个Hook,主要是用于缓存两次re-render之间的计算结果。减少不必要的重复计算,这里引入了编程中的一个缓存概念 Memozation(记忆化),利用空间换时间,把之间的结果保存在内存中进行重复利用,提高运算效率。

语法

useMemo(calculate, dep)

PS: 这里我理解的这个东西和Vue中的watch API很像,就是监听依赖,依赖改变进行重新计算。

用法

  1. 跳过昂贵的重复计算

一直以来,我有一个疑问什么样的计算才算是比较浪费性能的重复计算呢?标准又是什么呢?总不能所有计算的地方(everywhere),我都使用 useMemo这个Hook进行包裹优化,这肯定是不对的,代码上还没有写,想想都觉得是一坨。

在react的官方文档里有这样一段话:

If the overall logged time adds up to a significant amount (say, 1ms or more), it might make sense to memoize that calculation.

如果总的执行时长加起来数量较大(比如 1ms或者更长),使用 memoize(记忆化)的计算是有意义的。

这是一个简单的标准使用 console.time 来测试一个计算的耗时, >= 1ms时使用memoize是有意义,否则可能会起到相反的效果,毕竟useMemo这个hook本身的计算也一耗时的过程。

注:另外使用 useMemo,并不会提高 component的第一次渲染耗时,useMemo只是在component re-render时才会提现出它缓存的优化效果。

看如下的代码:

import { useMemo } from 'react';
import React = require('react');
import { filterTodos, todoType } from './utils';

export function TodoList({
  todos,
  tab,
  theme,
}: {
  todos: todoType[];
  tab: string;
  theme: string;
}) {
  const visibleTodos = filterTodos(todos, tab);

  return (
    <div className={theme}>
      <ul>
        {visibleTodos.map((v) => (
          <li key={v.id}>{v.completed ? <s>{v.text}</s> : v.text}</li>
        ))}
      </ul>
    </div>
  );
}
export type todoType = { id: string; completed?: boolean; text: string };

export function filterTodos(todos: todoType[], tab: string) {
  console.log('filter todos -> ', tab);
  console.time('filterTodos');
  const s = todos.filter((t) => {
    if (tab === 'all') {
      return true;
    } else if (tab === 'active') {
      return !t.completed;
    } else if (tab === 'completed') {
      return t.completed;
    }
  });
  console.timeEnd('filterTodos');
  return s;
}

export function createTodos() {
  const todos = [];
  for (let i = 0; i < 50; i++) {
    todos.push({
      id: i,
      text: 'Todo' + (i + 1),
      completed: Math.random() > 0.5,
    });
  }
  return todos;
}
export default function App() {
  const [tab, setTab] = React.useState('all');
  const [isDark, setIsDark] = React.useState(false);

  return (
    <div>
      <button onClick={() => setTab('all')}>All</button>
      <button onClick={() => setTab('active')}>Active</button>
      <button onClick={() => setTab('completed')}>Completed</button>
      <br />
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={(e) => setIsDark(e.target.checked)}
        />
        Dark mode
      </label>
      <hr />
      <TodoList todos={todos} tab={tab} theme={isDark ? 'dark' : 'light'} />
    </div>
  );
}

运行代码,当你点击 Dark mode时,你会发现。虽然todostab这两个props没有改变。每次re-render时都会触发filterTodos这个函数的执行,造成不必要的计算浪费。

React Performance之 useMemo useCallback

但是如果使用了useMemo进行处理: const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);

在react内部使用Object.is进行判断dep是否有改变,决定是否进行计算。

React Performance之 useMemo useCallback

代码链接:stackblitz.com/edit/stackb…

除了上述的标准之外官方还提到了些其它场景,也是同样使用useMemo

  • useMemo hook包裹中的函数计算是很慢的而且对就的依赖(dep)不会频繁的改变
  • useMemo缓存的结果作为一个prop传入使用memo包裹的组件,该场景下,只要useMemo对应的依赖不改变,对应组件就不会触发re-render。
  • useMemo缓存的结果作为其它Hook的对应依赖场景。比如作为useCallback useEffect的对应依赖

当然对应的也不推荐的使用场景:

  • 当一个组件形式上包裹别一个组件时,让它接受JSX作为一个子组件。这样,当包装组件更新自己的state时,React本会知识它的子组件不需要重新渲染。(TODO 代码)
  • 优先使用本地state,非必要不要提升它。例如:不要保持表单等瞬时状态,也不要保持在你组件树的顶层或者全局状态库这样的状态
  • 保持你的渲染逻辑是纯粹的,如果重新渲染一个组件会引起一些问题或者产生一些视觉的错误,则你的组件存在bug,修复错误而不使用useMemo缓存
  • 避免在不必要的Effect中更新状态,大多数在React中的性能问题都是由Effect的更新链引起的,导致组件一遍又一遍的渲染。
  • 从Effect中移除不必要的依赖,例如把一些在Effect中的对象或者函数移动到组件外面是通常更简单的,而不是使用记忆化。
  1. 跳过组件的re-render

React的世界里,有一个默认的规则。父组件渲染,在正常情况下所有的子组件会被递归进行重新渲染。相发避免无用的re-render,通常我们会在组件上使用memo这个API进行包裹(memo的默认逻辑是比较组件前后的props是否相同,不同则进行re-render)。

在上面的代码里我们把TodoList组件中的 list部分提取一个List组件,并使用memo进行处理。注意在TodoList中的visibleTodos并没有使用,useMemo进行包裹(const visibleTodos = filterTodos(todos, tab))。

代码如下:

import React = require('react');
import { todoType } from './utils';

const List = React.memo(function List({ items }: { items: todoType[] }) {
  console.log('render List');

  return (
    <ul>
      {items.map((v) => (
        <li key={v.id}>{v.completed ? <s>{v.text}</s> : v.text}</li>
      ))}
    </ul>
  );
});

export default List;

但是运行代码你会发现,虽然子组件添加了memo进行处理。点击Dark mode进行切换的时候,子组件List依然会re-render

原因主要是:父组件(TodoList)渲染时都会重新使用filterTodos函数进行计算,每次都是一个新的对象。子组件 (List)进行判断props是否相等时(默认使用Object.is),结果是永远不会相同,导致每次都会re-render。

解决的方法有两种:

a. 对于 visibleTodos使用useMemo进行包裹处理,只有在deps变量变化后,才重新计算生成新的对象。这样对于子组件来说,传递的props就能检测到是否相同,也达到了memo预期的效果。

b. 直接使用useMemo对列表进行缓存处理,子组件用不用React.memo包裹都可以

import { useMemo } from 'react';
import React = require('react');
import List from './List';
import { filterTodos, todoType } from './utils';

export function TodoList({
  todos,
  tab,
  theme,
}: {
  todos: todoType[];
  tab: string;
  theme: string;
}) {
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  const children = useMemo(() => {
    return <List items={visibleTodos} />;
  }, [visibleTodos]);

  return (
    <div className={theme}>     
      {children}
    </div>
  );
}

对于使用useMemo处理JSX节点的这种方法,官方对它的评价如下:

Manually wrapping JSX nodes into useMemo is not convenient. For example, you can’t do this conditionally. This is usually why you would wrap components with memo instead of wrapping JSX nodes.

意思就是:如果使用useMemo缓存JSX这种方式,会牺牲一定节点灵活性,不能使用它做为条件。(自己还没有感觉到)

  1. 为其它Hook提供一个记忆化的依赖

这种情况在开发很常见,就是使用useMemo计算出来的一个变量,作为其它Hook的相关依赖。

export function TodoList({
  todos,
  tab,
  theme,
}: {
  todos: todoType[];
  tab: string;
  theme: string;
}) {

  //  🚩 不使用 useMemo进行处理,每次re-render时都是一个新的对象
  const visibleTodos = filterTodos(todos, tab);

  // ✅ 使用 useMemo之后,依赖不改变返回的对象都是相同的,对应children则不会触发re-render
  // const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);

  //  🚩 对于children来说,dep每次都是新的,就会每次都re-render
  const children = useMemo(() => {
    return <List items={visibleTodos} />;
  }, [visibleTodos]);

  return (
    <div className={theme}>
      {/* <List items={visibleTodos} /> */}
      {children}
    </div>
  );
}
  1. 记忆化一个函数

这个场景用法,感觉更适合使用 useCallback,在这里就不在展开描述。

useCallback

useCallback主要是用于在两次re-render之间缓存你的函数,减少组件re-render的计算量。

语法

const cachedFn = useCallback(fn,dependencies)

useMemouseCallback在本质很像,都是为了记忆化一个值,只不过useMemo返回的是函数运算的值,而useCallback返回的是函数

用法

  1. 跳过组件的重新渲染

这里的用法和useMemo很类似,是利用useCallback来缓存一个方法,达到父组件渲染时,子组件根据传入的 function有没有改变,决定是否重新渲染。代码如下:

import * as React from 'react';

// 子组件使用memo包裹,根据props是改变,决定是否重新渲染
const ShippingForm = React.memo(
  ({ onSubmit }: { onSubmit: (e: any) => void }) => {
    const [count, setCount] = React.useState(1);

    console.log('Rendering <ShippingForm/>');

    const handleSubmit = (e: any) => {
      e.preventDefault();
      onSubmit?.(e);
    };

    return (
      <div>
        <h2>购物表单</h2>
        <form onSubmit={handleSubmit}>
          <label>
            购买数量:
            <button type="button" onClick={() => setCount(count - 1)}>

            </button>
            &nbsp;
            {count}
            &nbsp;
            <button type="button" onClick={() => setCount(count + 1)}>
              +
            </button>
          </label>
          <button type="submit">提交</button>
        </form>
      </div>
    );
  }
);

export default ShippingForm;
import * as React from 'react';
import ShippingForm from './ShippingForm';

export default function ProductPage({
  id,
  theme,
}: {
  theme: string;
  id: string;
}) {
  // ✅ 使用 useCallback 进行缓存,在相关依赖没有改变时,返回一样的函数
  const handleSubmit = React.useCallback(
    (orderDetail: any) => {
      const p = { ...orderDetail, id };
      post('buy', p);
    },
    [id]
  );

  return (
    <div className={theme}>
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

function post(url: string, data: any) {
  console.log('POST /' + url);
  console.log(data);
}
export default function UseCallbackDemo() {
  const [isDark, setIsDark] = React.useState(false);
  return (
    <div>
      <h1>UseCallbackDemo</h1>

      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={(e) => setIsDark(e.target.checked)}
        />
        暗黑模式
      </label>
      <hr />
      <ProductPage theme={isDark ? 'dark' : 'light'} id="32323" />
    </div>
  );
}

在上面的例子中,ShipingForm为子组件,并且使用memo进行处理。父组件ProductPage中的handleSubmit方法使用useCallback进行了记忆化缓存,只要变量id没有改变。返回的函数永远是同一个,子组件ShippingForm,就可以在re-render时跳过不必要的渲染。

相关代码链接:stackblitz.com/edit/stackb…

既然useCallback这么有用,那么我是不是需要在所有hook的方法里都可以使用useCallback进行优化呢?

答案当然是否定的,有以下几种场景是推荐使用的:

    • 当你通过props传递一个方法给子组件,且子组件使用了memo进行包裹。两者配合可以实现子组件跳过不必要的re-render。
    • 当一个方法做为其它Hook的依赖时,比如:一个使用useCallback包裹的方法,在useEffect中进行调用,此时如果该方法不使用useCallback,则会频繁的触发useEffect。

在这里突然想到,平时开发有如下的代码,是一个好的习惯还是错误的写法呢?为此在Twitter上的大佬还讨论过,答案在后面揭晓。

<Item onClick={() => { xxxxx }}>显示内容</Item>

继续,除了推荐使用场景之后,也有不推荐使用的几个场景:

  • 当一个组件形式上包裹别一个组件时,让它接受JSX作为一个子组件。这样,当包装组件更新自己的state时,React本会知识它的子组件不需要重新渲染。(TODO 代码)
  • 优先使用本地state,非必要不要提升它。例如:不要保持表单等瞬时状态,也不要保持在你组件树的顶层或者全局状态库这样的状态
  • 保持你的渲染逻辑是纯粹的,如果重新渲染一个组件会引起一些问题或者产生一些视觉的错误,则你的组件存在bug,修复错误而不使用useMemo缓存
  • 避免在不必要的Effect中更新状态,大多数在React中的性能问题都是由Effect的更新链引起的,导致组件一遍又一遍的渲染。
  • 从Effect中移除不必要的依赖,例如把一些在Effect中的对象或者函数移动到组件外面是通常更简单的,而不是使用记忆化。
  1. 从记忆化的callback中更新state

考虑如下代码有什么问题吗?

function TodoList() {
  const [todos, setTodos] = useState([]);

  const handleAddTodo = useCallback((text) => {
    const newTodo = { id: nextId++, text };
    setTodos([...todos, newTodo]);
  }, [todos]);

当你需要上一个state的状态,来进行合并形成一个新的状态时,这里的useCallback是没有意义的,该函数的本质就是修改todos,自己的更新又依赖todos这个变量。是一个相互矛盾的。如果一定要用useCallback,请从dep中移除对应的state依赖。更合理的方法应该使用setXX的回调进行更新。代码如下:

React Performance之 useMemo useCallback

注:如果你开启了eslint,程序应该是会报错的。请禁用相关的eslint规则:

React Performance之 useMemo useCallback

  const [todos, setTodos] = useState<any[]>([]);

  const handleAddTodo = useCallback((text:any) => {
    const newTodo = { id: 1, text };
    setTodos([...todos, newTodo]);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  1. 防止Effect被频繁触发
function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  function createOptions() {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }

useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]);
}

上面的代码会导致useEffect被频繁的触发,处理方法如下:

    • 使用useCallback把createOptions进行包裹
    • 更好的办法是把对应的function移入useEffect
  useEffect(() => {
    function createOptions() { 
      return {
        serverUrl: 'https://localhost:1234',
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); 
  1. 优化自定义Hook

这个层面没有什么可以讲的,就是代码优化技术,减少不必要的计算:

function useRouter() {
  const { dispatch } = useContext(RouterStateContext);

  const navigate = useCallback((url) => {
    dispatch({ type: 'navigate', url });
  }, [dispatch]);

  const goBack = useCallback(() => {
    dispatch({ type: 'back' });
  }, [dispatch]);

  return {
    navigate,
    goBack,
  };
}

总结

本文主要介绍了在React组件的使用useMemouseCallback场景及如何正确的使用,两者想要达到减少子组件渲染的功能,一般需要配合memo进行实现。其它用法都差不多,但两者有本质上的区别:useMemo返回是一个缓存函数执行的结果、useCallback返回的是一个缓存的函数

转载自:https://juejin.cn/post/7243337546825924665
评论
请登录