用useCallback优化React性能?这篇文章会让你改变看法

lxf2023-05-13 00:52:40

前言

本文将分为两部分,旨在全面介绍React中的useCallback hook。首先,我将结合官方文档来探讨是否应该在代码的每个地方都使用useCallback。其次,我将结合个人开发经验,介绍使用useCallback的一些业务场景。

你应该在所有地方添加 useCallback 吗?

api的详细介绍见官方文档:useCallback。

正如我们所了解的,useCallback的作用是用来优化React函数组件中的子组件渲染问题。但是,是否应该在所有地方都使用useCallback呢?官方文档给出了建议,我将在下面详细解读(官方文档将使用蓝框中引用标记)。

如果您的app类似于这个网站(react官网),并且大多数交互是粗粒度的(例如替换页面或整个模块),则记忆化(useCallback)通常无关紧要。另一方面,如果您的app更像绘图编辑器,大多数交互都是细粒度的(例如移动形状),那么记忆化(useCallback)可能非常有用。 使用 useCallback 缓存函数仅在少数情况下才具有价值:

  1. 如果您将函数作为prop传递给memo包装的组件,并且希望在值未更改时跳过重新渲染,那么使用记忆化可以帮助避免不必要的重新渲染。记忆化可以让组件在依赖项更改时重新渲染,从而提高性能

这段话向我们说明了useCallback最主要的一个作用,那就是优化子组件的渲染问题。但是,同时也对子组件提出了一个要求,即必须使用memo进行包裹。那么为什么必须要使用memo包裹呢?我们可以通过下面的例子来了解。

import { useState } from 'react';

interface AProps {
  a: string;
}

function A(props: AProps) {
  console.log('a');
  return <div>{props.a}</div>;
}

function B() {
  console.log('b');
  return <div>b</div>;
}

export default function TestRender() {
  const [id, setId] = useState(0);
  return (
    <div onClick={() => setId(id + 1)}>
      <A a="a" />
      <B />
    </div>
  );
}

每次点击时,我们会发现控制台都会打印a、b,这意味着A、B组件尽管传入的props中的属性值没有改变,但组件依然每次都重新渲染了。这是因为,React本身并没有对组件re-render做过多优化,而是赋予了我们优化re-render的能力。要解决上述问题,我们只需要给组件包上memo即可,如下所示:

import { useState, memo } from 'react';

interface AProps {
  a: string;
}

const A = memo(function A(props: AProps) {
  console.log('a');
  return <div>{props.a}</div>;
});

const B = memo(function B() {
  console.log('b');
  return <div>b</div>;
});

export default function TestRender() {
  const [id, setId] = useState(0);
  return (
    <div onClick={() => setId(id + 1)}>
      <A a="a" />
      <B />
    </div>
  );
}

从上面的示例中,我们可以看到,要完整优化子组件的渲染问题,除了使用useCallback,还要求子组件必须包裹memo才能生效。

我们再来看看react当中使用比较多的组件库antd,这里我随机挑了两个比较常用的组件table(源码见:github.com/ant-design/…),button(源码见:github.com/ant-design/…),它们均没有使用memo包裹,组件内的方法更没有使用useCallback做任何优化。所以当我们下次使用react组件时,传入组件的方法不用考虑使用useCallback包裹,因为这根本起不到任何性能优化的效果,反而影响性能。

那为什么antd中都没有使用useCallback来做子组件re-render的优化呢?这个问题我在文章结尾会给出,但是我相信你在读完全文应该也能自己得出答案。

其实,仅凭这一点,就注定了useCallback不会成为常用API。因为我们往往无法保证子组件是否已经使用memo进行了优化,而且当子组件的props中含有复杂的对象时,由于memo对props的比较是浅比较,我们需要使用memo的第二个参数来自定义比较逻辑。那么,如果不使用memo,还有其他的方案吗?当然有,那就是使用useMemo。对于上面的代码,我们可以做出如下修改来避免re-render:

import { useState, useMemo } from 'react';

interface AProps {
  a: string;
}

function A(props: AProps) {
  console.log('a');
  return <div>{props.a}</div>;
}

function B() {
  console.log('b');
  return <div>b</div>;
}

export default function TestRender() {
  const [id, setId] = useState(0);
  return useMemo(
    () => (
      <div onClick={() => setId(id + 1)}>
        <A a="a" />
        <B />
      </div>
    ),
    []
  );
}

通过使用useMemo来缓存整个组件的返回值,我们可以在不影响子组件的情况下,同样达到优化的目的。

提到这里,细心的读者可能已经发现了,为什么不直接使用useMemo来进行优化,而要使用useCallback和memo的组合呢?实际上,在大多数情况下,我们可以直接使用useMemo来进行优化。

  1. 如果您传递的函数被用作某个 Hook 的依赖,例如另一个使用useCallback包裹的函数依赖于它,或者您从useEffect中依赖于这个函数,那么使用useCallback可以确保Hook对函数的依赖不会在每次重新渲染时发生变化,从而避免不必要的Hook计算和渲染。

结合我个人开发经验,虽然确实会出现这样的情况,但这种情况并不是很常见。而且,大多数情况下,我们都有更好的解决方案。就像React官网所举的例子一样,如下所示:

// 不使用useCallback,我们就会频繁调用useEffect中的连接函数
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();
    // ...

// 使用useCallback解决上面代码的问题
function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const createOptions = useCallback(() => {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }, [roomId]); // ✅ Only changes when roomId changes

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

// 更优的方案,将函数放到useEffect中,依赖变为roomId
function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    function createOptions() { // ✅ No need for useCallback or function dependencies!
      return {
        serverUrl: 'https://localhost:1234',
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ Only changes when roomId changes
  // ...

在其他情况下,将函数包裹在useCallback中并没有任何好处。虽然这样做也没有什么显著的坏处,但一些团队选择尽可能地对函数进行记忆化处理,而不考虑个别情况。缺点是代码变得不易读懂。此外,并非所有的记忆化都是有效的:一个“总是新的”单个值就足以打破整个组件的记忆化处理。因此,在使用useCallback进行记忆化处理时需要小心,仅在真正需要时使用,否则可能会带来意想不到的问题。

在官方文档中,针对useCallback的主要使用场景已经讲得很清楚了。即使我们将所有函数都使用useCallback包装,也不会有什么坏处,确实有一些团队这样做。但是,我并不完全认同这个观点。我更赞同的是在真正存在性能问题,已经影响用户使用体验时再进行优化,这符合“不要过早优化”的理念。此外,使用useCallback不仅会降低代码的可读性,对于一些React的新手开发者,很容易出现闭包问题。尽管我们可以使用react-hooks/exhaustive-deps插件来避免这些问题,但是useCallback的依赖项很多时,也很难达到优化的目的。

需要注意的是,useCallback并不能防止创建函数。在使用useCallback时,你总是在创建函数(这是正常的!),但React会忽略它,并在函数依赖项未更改时返回一个缓存的函数。换句话说,useCallback会在函数依赖项未更改的情况下返回之前缓存的函数,从而提高组件的性能。

这一点也值得我们注意:在使用useCallback时,每次都会创建一个新的函数。useCallback的作用是通过对比依赖项是否改变,来决定是返回新创建的函数还是缓存的函数。下面这种写法可能更容易理解useCallback的工作原理

function Test() {
  const clickCallbackParam = () => {
			...
	}
  const handleClick = useCallback(clickCallbackParam, []);

  // 上述写法等价于:const handleClick = useCallback(() => { ... }, [])

  return <div onClick={handleClick}>test</div>;
}

实际上,您可以遵循以下几个原则来避免很多不必要的记忆化处理:

  1. 当一个组件在视觉上包装其他组件时,请让它接受JSX作为子组件。如果包装组件更新自己的状态,React就知道它的子组件不需要重新渲染。

例如,在以下场景中,当B组件作为A组件的子组件传入时,A组件的点击事件触发re-render时,不会触发B组件的re-render。

import React, { useState } from 'react';

function A(props: { children: React.ReactElement }) {
  const [id, setId] = useState(0);
  console.log('a');
  return (
    <div
      onClick={() => {
        setId(id + 1);
      }}
    >
      {props.children}
    </div>
  );
}

function B() {
  console.log('b');
  return <div>b</div>;
}

export default function TestChildren() {
  return (
    <A>
      <B />
    </A>
  );
}

// 等价于如下写法,这里把children换成了comp字段,当我们需要有多个子组件,且渲染到不同位置时,可采用如下写法
function A(props: { comp: JSX.Element }) {
  const [id, setId] = useState(0);
  console.log('a');

  return (
    <div
      onClick={() => {
        setId(id + 1);
      }}
    >
      {props.comp}
    </div>
  );
}

function B() {
  console.log('b');
  return <div>b</div>;
}

export default function TestChildren() {
  return <A comp={<B />} />;
}

在平时的业务开发中,我们也可能会看到以下写法,它们都会触发子组件的re-render。需要注意的是,这些写法并不好,我们尽量应该避免使用它们。在平时的业务开发中,我们也能看到如下的写法,它们都会触发子组件的re-render,注意:这都是不好的写法,尽量避免。

// 子组件作为渲染函数的方式
function A(props: { renderA: () => React.ReactElement }) {
  const [id, setId] = useState(0);
  console.log('a');
  return (
    <div
      onClick={() => {
        setId(id + 1);
      }}
    >
      {props.renderA()}
    </div>
  );
}

function B() {
  console.log('b');
  return <div>b</div>;
}

export default function TestChildren() {
  return <A renderA={() => <B />} />;
}

// 直接传入组件构造函数,由父组件实例
function A(props: { Comp: React.FunctionComponent }) {
  const [id, setId] = useState(0);
  console.log('a');
  const Comp = props.Comp;
  return (
    <div
      onClick={() => {
        setId(id + 1);
      }}
    >
      <Comp />
    </div>
  );
}

function B() {
  console.log('b');
  return <div>b</div>;
}

export default function TestChildren() {
  return <A Comp={B} />;
}
  1. 更推荐使用本地状态,并避免将状态提升到不必要的高层次。不要将瞬态状态(例如表单和表单项是否悬停)保存在全局状态库中或组件树的顶层。

这一点可以看文章overreacted.io/zh-hans/bef…有详细解释,本文不详述

  1. 保持渲染逻辑的纯净。如果重新渲染组件导致问题或产生一些明显的视觉效果,则说明组件中存在问题。应该先修复这些问题,而不是通过添加记忆化来绕过它们
  2. 尽量避免不必要的更新状态的Effects。React应用程序中大多数性能问题都源于Effects的更新链,因为它们会导致组件反复渲染。

这一点之后我会单独写一篇文章介绍useEffect的正确使用

5.尽量减少Effects的依赖项。例如,可以将某些对象或函数移动到Effect内部或组件外部,而不是使用记忆化,这通常更加简单易行。 如果特定交互仍然感到不流畅,请使用React Developer Tools分析器,查看哪些组件最需要记忆化,并在必要时添加记忆化。这些原则使您的组件更易于调试和理解,因此在任何情况下都应该遵循它们。从长远来看,我们正在研究自动进行记忆化,以一劳永逸地解决这个问题。

这里所说的自动记忆化是指React的自动优化机制,也称为"React Forget"。通过编译手段,React可以替代我们做上面提到的memo和useCallback,从而让我们即使不使用memo和useCallback,也可以优化组件的重新渲染。更详细的信息可以参考::React without memo(注:负责的黄玄大佬已从meta离职…)

业务场景

函数防抖节流

一个简单的防抖函数如下所示

function debounce(fn, delay) {
    var timer; // 维护一个 timer
    return function () {
        var _this = this; // 取debounce执行作用域的this
        var args = arguments;
        if (timer) {
            clearTimeout(timer);
        }
        timer = setTimeout(function () {
            fn.apply(_this, args); // 用apply指向调用debounce的对象,相当于_this.fn(args);
        }, delay);
    };
}

调用 debounce 函数时,它会返回一个新的函数。这个新函数在执行时,会通过闭包保存 debounce 函数中定义的局部变量 timer。每次调用这个新函数时,都会清除上一次的定时器,并重新设定新的定时器,从而保证每次函数被调用时,timer 变量的值都是最新的。这样就可以实现有效的防抖效果。

如果我们直接使用 debounce 函数而不是使用 useCallback 包裹的话,将不能起到函数防抖的作用,因为每次函数被调用时都会生成一个新的函数和一个新的 timer,这样就无法实现防抖效果。如下所示:

function Test() {
    const handleMouseMove = debounce(() => {
        ...
    }, 100)

    return <div onMouseMove={handleMouseMove}>test</div>
}

而是应该使用useCallback包裹才行,这样每次返回的都是初始时创建的函数,从而达到函数防抖的作用

function Test() {
    const handleMouseMove = useCallback(debounce(() => {
        ...
    }, 100),[])

    return <div onMouseMove={handleMouseMove}>test</div>
}

通用hook的封装

这里指的是,当我们封装一些通用性的hook供其他人使用时,最好使用 useCallback 包裹或其他方式生成记忆化函数来对外暴露。这样,其他人在使用你的hook进行性能优化时也可以有优化的空间。

以 ahooks 中的 useLockFn 为例,最终我们向用户暴露的函数是通过 useCallback 包裹的记忆化函数。

import { useRef, useCallback } from 'react';

function useLockFn<P extends any[] = any[], V extends any = any>(fn: (...args: P) => Promise<V>) {
  const lockRef = useRef(false);

  return useCallback(
    async (...args: P) => {
      if (lockRef.current) return;
      lockRef.current = true;
      try {
        const ret = await fn(...args);
        lockRef.current = false;
        return ret;
      } catch (e) {
        lockRef.current = false;
        throw e;
      }
    },
    [fn],
  );
}

export default useLockFn;

其它

除了上面提到的两个场景,我们还会遇到以下两个主要场景:

  1. 非常耗性能组件的re-render优化,或者单位时间内频繁触发子组件re-render并造成页面卡顿
  2. 有相互依赖的hook(即我们上面文章中react提到的第二个场景)

场景1的话,通常情况下我们使用useMemo替代useCallback来做优化会更加简单有效。

针对场景2,我的建议是使用useEffectEvent(原useEvent,详细见:react.dev/learn/separ…,react稳定版暂未发布,现在可使用ahooks的useMemoizedFn做替代,详细见ahooks.js.org/zh-CN/hooks…)。因为你会发现,当你的组件里面有很多hook,并且它们之间又存在很多依赖关系时,将会大大增加我们代码的阅读和维护成本。

总结

在总结之前,先给出上文问题的答案:为什么antd组件基本都没有使用memo+useCallback做优化?

我个人的一个理解是:antd中大部分组件都不是特别耗能的组件,组件多一次render,也就是多了一次Dom diff的时间,我们要相信js和浏览器的性能,通常情况下这段时间都不会很长。并且在React Concurrent Mode的加持下,Dom diff的优先级是低于用户行为的,一般也不会造成页面明显的卡顿。即使在使用antd组件时遇到了性能问题,一般情况下我们也可以通过外部使用useMemo来解决。

最后,结合我个人的开发经验,给出一个我对useCallback这个hook的使用总结:

一般业务场景下,我们都可以不使用useCallback,仅当页面出现卡顿时再考虑使用。

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!