深入 useMemo 与 useCallback 的底层实现

1. 引言

在 React 的世界里,每一次组件的重新渲染都意味着其内部所有代码(包括函数定义和变量计算)都会被重新执行。在大多数情况下,这是无害的。但当遇到昂贵的计算或需要将稳定的函数引用传递给子组件时,这种重复执行就可能成为性能瓶颈。

useMemouseCallback 是 React 提供的两个核心优化工具,它们允许我们"记住"一个值或一个函数,只有在依赖项发生变化时才重新计算或创建。这种技术被称为记忆化 (Memoization)

理解它们的实现原理,能帮助我们更精确地运用这些工具,避免不必要的性能损耗,同时也能避免因误用而引入的 bug。

2. 核心数据结构:Fiber 上的 Hook 链表

要理解任何 Hook 的工作原理,首先必须知道 React 是如何"记住"一个组件的状态的。答案就在组件对应的 Fiber 节点上。

每个函数组件的 Fiber 节点内部都有一个名为 memoizedState 的属性。这个属性指向一个链表 ,链表中的每一个节点都是一个 Hook 对象 。当你每次调用 useState, useEffect, useMemo 等 Hook 时,React 就会按顺序读取或创建这个链表上的一个节点。

之前写过的useState 和 useEffect 实现,更完整的介绍了这一部分,这里不过多介绍。

一个简化的 Hook 对象结构如下:

typescript 复制代码
interface Hook {
  memoizedState: any; // 对于 useState,是 state;对于 useMemo,是记忆的值和依赖
  next: Hook | null; // 指向下一个 Hook
}

useMemouseCallback 正是利用了这个机制,将计算出的值(或函数)和它的依赖项数组存储在对应的 Hook 节点上,以便在下一次渲染时进行比较。

3. useMemo 的实现原理

useMemo 的作用是执行一个"创建"函数并将其返回值进行记忆化,只有在依赖项变化时才重新计算。

其签名为:const memoizedValue = useMemo(createFn, deps);

当一个组件渲染并调用 useMemo 时,React 内部会执行以下步骤(由 updateMemo 函数处理):

  1. 获取当前 Hook 节点 :React 从 Fiber 的 memoizedState 链表中获取当前 useMemo 对应的 Hook 节点。

  2. 比较依赖项 :React 取出上一次渲染时存储在该 Hook 节点上的 deps 数组,并与本次传入的 deps 数组进行浅比较

    • 这个比较过程由 areHookInputsEqual 函数完成,它会遍历两个数组,使用 Object.is 来比较每一个依赖项。
  3. 决策与返回值

    • 如果依赖项没有变化 (或者 deps 未提供):React 会直接返回上一次存储在 Hook 节点 memoizedState 属性中的值,完全跳过 createFn 的执行。
    • 如果依赖项发生变化 (或者这是第一次渲染):React 会执行 createFn 函数,得到新的值。然后,它会将这个新值新的 deps 数组 一同存储到当前 Hook 节点的 memoizedState 中,以备下次渲染使用。最后,返回这个新计算出的值。

源码简化逻辑

javascript 复制代码
// ReactFiberHooks.new.js -> updateMemo

function updateMemo<T>(create: () => T, deps: Array<mixed> | void | null): T {
  // 1. 获取当前组件正在处理的 Hook
  const hook = updateWorkInProgressHook();

  // 2. 获取上一次的依赖项
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps = prevState[1];
      // 3. 比较依赖项
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 依赖未变,直接返回旧值
        return prevState[0];
      }
    }
  }

  // 4. 依赖变化或首次渲染,重新计算
  const nextValue = create();
  // 5. 存储新值和新依赖
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

4. useCallbackuseMemo 的语法糖

useCallback 的作用是记忆化一个回调函数本身,而不是它的返回值。

其签名为:const memoizedCallback = useCallback(callbackFn, deps);

理解 useCallback 最简单的方式就是:它完全等同于一个特殊用法下的 useMemo

以下两行代码是完全等价的:

javascript 复制代码
// 使用 useCallback
useCallback(callback, deps);

// 等价的 useMemo 实现
useMemo(() => callback, deps);

实现原理useCallback 内部的实现逻辑和 useMemo 几乎一模一样。它也遵循相同的步骤:获取 Hook 节点、比较 deps 依赖项。

  • 如果依赖项不变,它返回上一次传入的 callbackFn 函数的引用
  • 如果依赖项变化,它返回本次传入的新的 callbackFn 函数,并将其存储起来以备后用。

它记忆的是函数本身,确保了只要依赖不变,传递给子组件的函数引用就是稳定、不变的。这对于依赖函数引用进行优化的子组件(例如被 React.memo 包裹的组件)至关重要,可以防止因为父组件渲染时创建了新的函数实例而导致的不必要的子组件重渲染。

5. 何时使用?避免过度优化

虽然 useMemouseCallback 是强大的工具,但它们并非没有成本。每一次使用都意味着:

  • 内存开销:需要额外存储记忆化的值和依赖数组。
  • 计算开销:每次渲染都需要对依赖数组进行比较。

因此,滥用它们反而可能导致性能下降。以下是正确的使用场景:

  1. useMemo 的使用场景

当组件中有计算成本高昂的操作时(例如,对大型数组进行循环、过滤、排序)。

当一个值的计算需要耗费明显的时间,并且不希望它在每次渲染时都重新计算。

  1. useCallback 的使用场景

将回调函数传递给一个React.memo 优化的子组件时。这是最主要的用途。

js 复制代码
const MemoizedButton = React.memo(Button);

function Parent() {
  // 如果不用 useCallback,handleClick 在每次 Parent 重新渲染时都是新函数
  const handleClick = () => {
    console.log("Clicked!");
  };

  // 因为 handleClick 引用变了,导致 MemoizedButton 即使其他 props 没变也会重新渲染
  return <MemoizedButton onClick={handleClick} />;
}
// 在这种情况下,React.memo 通过浅比较 props 来决定是否跳过渲染。
// 如果 onClick prop 每次都是一个新的函数引用,React.memo 的优化就会完全失效。
// 使用 useCallback 可以确保 handleClick 的引用保持稳定,从而让 React.memo 发挥作用。

当一个函数被用作 useEffect 的依赖项时,为了防止 useEffect 在每次渲染时都重新执行,需要用 useCallback 来稳定该函数的引用。

js 复制代码
function MyComponent({someProp}) {
  const fetchData = () => {
    // ... uses someProp ...
  };

  useEffect(() => {
    fetchData();
  }, [fetchData]); // 问题在这里!
}
// 在这个例子中,因为 fetchData 在每次渲染时都是新函数, 
// useEffect 的依赖项 [fetchData] 每次都会变化,导致 useEffect 在每次渲染后都会执行,
// 这可能不是你想要的。如果用 useCallback 包裹 fetchData , 
// useEffect 就只会在 fetchData 真正需要更新时(即它的依赖 someProp 变化时)才重新执行。

反模式

  • 简单的计算(如 a + b)不需要用 useMemo 来记忆,因为 useMemo 本身的开销(内存和计算)要比 a + b 这种简单计算的开销大得多。
  • 如果一个函数没有传递给优化的子组件,或者没有作为其他 Hook 的依赖,那么通常不需要用 useCallback 来包裹它,因为在组件函数体内部定义的函数(如 function handleClick() {} 或 const handleClick = () => {}),在每次组件重新渲染时,都会被重新创建一个新的函数对象,这个创建过程非常快,其成本可以忽略不计。

6. 总结

useMemouseCallback 是 React 性能优化工具箱中的两把"手术刀"。它们都基于相同的底层机制------在 Fiber 节点的 Hook 链表上存储数据,并通过比较依赖项来决定是返回缓存的结果还是重新计算。

  • useMemo 记忆化一个
  • useCallbackuseMemo 的一个特例,用于记忆化一个函数
相关推荐
AAA简单玩转程序设计2 小时前
救命!Java 进阶居然还在考这些“小儿科”?
java·前端
MediaTea2 小时前
思考与练习(第十章 文件与数据格式化)
java·linux·服务器·前端·javascript
JarvanMo2 小时前
别用英语和你的大语言模型说话
前端
江公望2 小时前
Vue3的 nextTick API 5分钟讲清楚
前端·javascript·vue.js
weixin_446260852 小时前
深入了解 MDN Web Docs:打造更好的互联网
前端
Codebee2 小时前
# 🔥A2UI封神!元数据驱动的AI交互新范式,技术人必看
前端·架构
JarvanMo2 小时前
展望 2030 年:移动开发者的未来将如何?
前端
我的xiaodoujiao2 小时前
使用 Python 语言 从 0 到 1 搭建完整 Web UI自动化测试学习系列 34--基础知识 9--文件上传功能
前端·python·测试工具·ui·pytest
辛-夷2 小时前
pinia与Vuex高频面试题
前端·vue.js