合理useMemo和useCallback

246 阅读6分钟

useMemouseCallback 的核心作用

这两个 Hooks 都是 React 提供的性能优化工具,它们的核心目标是减少不必要的计算和渲染

  1. useMemo:

    • 用途:记忆一个计算结果。它接收一个“创建”函数和一个依赖项数组。useMemo 会在初次渲染时执行该函数,并缓存其结果。在后续渲染中,只有当依赖项数组中的某个值发生变化时,它才会重新执行函数计算新值。否则,它将返回上一次缓存的值。
    • 语法const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
    • 主要解决的问题
      • 避免在每次渲染时都进行昂贵的计算。
      • 当一个对象或数组作为 props 传递给子组件时,如果这个对象/数组是在父组件渲染时即时创建的,那么即使它的内容没有变化,它的引用地址也会改变,导致依赖该 props 的 React.memo 子组件或 useEffect 不必要地重新渲染/执行。useMemo 可以确保在依赖不变的情况下返回相同的引用。
  2. useCallback:

    • 用途:记忆一个回调函数本身。它接收一个内联回调函数和一个依赖项数组。useCallback 会返回该回调函数的记忆版本,该回调函数仅在某个依赖项改变时才会更新。
    • 语法const memoizedCallback = useCallback(() => { doSomething(a, b); }, [a, b]);
    • 主要解决的问题
      • useMemo 类似,当一个函数作为 props 传递给子组件(尤其是用了 React.memo 的子组件)时,如果这个函数在父组件每次渲染时都重新创建,那么子组件会因为 props 变化而重新渲染。useCallback 可以保证在依赖不变时返回相同的函数引用。
      • 当一个函数作为依赖项传递给其他 Hooks (如 useEffect, useMemo, 甚至另一个 useCallback) 时,如果该函数没有被 useCallback 包裹,它会在每次渲染时都是一个新的引用,可能导致依赖它的 Hook 不必要地执行。

何时合理使用 useMemouseCallback

并非所有地方都需要用它们。滥用反而可能导致性能下降(因为 Hooks 本身也有开销,比如依赖比较)。

useMemo 的合理使用场景:

  1. 昂贵的计算

    • 当你的组件中有一个计算量非常大(例如,对大型数组进行复杂转换、过滤、排序,或者递归计算)的函数,并且其结果在多次渲染之间可能保持不变时。
    // 假设 filterAndSortLargeList 是一个非常耗时的操作
    function MyComponent({ list, filterTerm, sortKey }) {
      const processedList = useMemo(() => {
        console.log("Performing expensive list processing...");
        // 模拟耗时操作
        let result = [...list];
        if (filterTerm) {
          result = result.filter(item => item.name.includes(filterTerm));
        }
        if (sortKey) {
          result.sort((a, b) => (a[sortKey] > b[sortKey] ? 1 : -1));
        }
        // 可能还有更多复杂的转换
        for (let i = 0; i < 1000000; i++) { /* no-op for demo */ }
        return result;
      }, [list, filterTerm, sortKey]); // 依赖项:当它们变化时才重新计算
    
      return (
        <ul>
          {processedList.map(item => <li key={item.id}>{item.name}</li>)}
        </ul>
      );
    }
    
  2. 确保传递给子组件的对象/数组的引用稳定性

    • 如果你将一个在渲染过程中动态创建的对象或数组作为 prop 传递给一个用 React.memo 包裹的子组件,那么即使该对象/数组的内容没变,子组件也会因为 prop 的引用变化而重新渲染。
    import React, { useState, useMemo, memo } from 'react';
    
    const ChildComponent = memo(({ styleObject, dataArray }) => {
      console.log("ChildComponent re-rendered");
      return (
        <div style={styleObject}>
          Data length: {dataArray.length}
          {/* 假设这里有更复杂的渲染逻辑依赖 styleObject 和 dataArray */}
        </div>
      );
    });
    ChildComponent.displayName = "ChildComponent";
    
    
    function ParentComponent() {
      const [theme, setTheme] = useState('light');
      const [count, setCount] = useState(0); // 这个 state 的变化不应该影响 styleObject
    
      // 如果不使用 useMemo,styleObject 每次都会是一个新的对象引用
      // const styleObject = {
      //   backgroundColor: theme === 'light' ? '#fff' : '#333',
      //   color: theme === 'light' ? '#000' : '#fff',
      //   padding: 10
      // };
    
      // 使用 useMemo 确保 styleObject 的引用稳定性
      const styleObject = useMemo(() => {
        console.log("Recalculating styleObject");
        return {
          backgroundColor: theme === 'light' ? '#fff' : '#333',
          color: theme === 'light' ? '#000' : '#fff',
          padding: 10
        };
      }, [theme]); // 只有 theme 变化时才重新创建 styleObject
    
      // 假设 dataArray 是固定的,或者它的生成逻辑不应该因为 count 变化而重新执行
      const dataArray = useMemo(() => {
        console.log("Recalculating dataArray");
        return [{ id: 1, value: 'A' }, { id: 2, value: 'B' }];
      }, []); // 空依赖数组,只创建一次
    
      return (
        <div>
          <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
            Toggle Theme
          </button>
          <button onClick={() => setCount(c => c + 1)}>
            Increment Count (forces parent re-render): {count}
          </button>
          <ChildComponent styleObject={styleObject} dataArray={dataArray} />
        </div>
      );
    }
    
    // export default ParentComponent;
    

    在这个例子中,如果 ParentComponent 因为 count 状态变化而重新渲染,但 theme 没有变,styleObject 会因为 useMemo 而保持引用不变,从而 ChildComponent 不会因为 styleObject 这个 prop 的变化而重新渲染。

useCallback 的合理使用场景:

  1. 将回调函数传递给 React.memo 子组件

    • useMemo 类似,如果传递给 memo 子组件的回调函数在父组件每次渲染时都重新创建,那么子组件仍然会重新渲染。
    import React, { useState, useCallback, memo } from 'react';
    
    const MemoizedButton = memo(({ onClick, children }) => {
      console.log(`Button "${children}" re-rendered`);
      return <button onClick={onClick}>{children}</button>;
    });
    MemoizedButton.displayName = "MemoizedButton";
    
    function CounterApp() {
      const [countA, setCountA] = useState(0);
      const [countB, setCountB] = useState(0); // 这个 state 用于触发父组件渲染
    
      // 如果不使用 useCallback:
      // const handleIncrementA = () => setCountA(countA + 1);
      // 每次 CounterApp 渲染,handleIncrementA 都是一个新的函数实例
    
      // 使用 useCallback:
      const handleIncrementA = useCallback(() => {
        setCountA(prevCountA => prevCountA + 1); // 使用函数式更新,避免把 countA 加入依赖
        console.log("handleIncrementA called_ (memoized version if countB changed)");
      }, []); // 依赖项为空,函数实例永不改变 (除非组件卸载)
    
      const handleIncrementB = useCallback(() => {
        setCountB(prevCountB => prevCountB + 1);
        console.log("handleIncrementB called (memoized version)");
      }, []);
    
      // 一个依赖 countA 的回调
      const logCountA = useCallback(() => {
        console.log("Current Count A:", countA);
      }, [countA]); // 当 countA 变化时,这个回调会是新的实例
    
      return (
        <div>
          <p>Count A: {countA}</p>
          <p>Count B: {countB}</p>
          <MemoizedButton onClick={handleIncrementA}>Increment A</MemoizedButton>
          <MemoizedButton onClick={handleIncrementB}>Increment B (triggers parent re-render)</MemoizedButton>
          <MemoizedButton onClick={logCountA}>Log Count A</MemoizedButton>
        </div>
      );
    }
    
    // export default CounterApp;
    

    当点击 "Increment B" 时,CounterApp 重新渲染。由于 handleIncrementAuseCallback 包裹且依赖项为空,它仍然是同一个函数引用,所以 MemoizedButton "Increment A" 不会因为 onClick prop 变化而重新渲染。而 logCountA 因为依赖 countA,如果 countA 变化,logCountA 会是新的函数,对应的按钮可能会重新渲染。

  2. 作为其他 Hooks (如 useEffect) 的依赖项

    • 如果一个函数在 useEffect 的依赖数组中,并且这个函数没有被 useCallback 记忆,那么每次组件渲染时,这个函数都是一个新的引用,会导致 useEffect 在每次渲染后都执行。
    import React, { useState, useEffect, useCallback } from 'react';
    
    function DataFetcher({ userId }) {
      const [data, setData] = useState(null);
      const [otherState, setOtherState] = useState(0); // 用于触发组件重新渲染
    
      // 错误的做法 (如果 fetchData 作为依赖):
      // const fetchData = () => {
      //   console.log(`Fetching data for userId (unmemoized): ${userId}`);
      //   fetch(`https://jsonplaceholder.typicode.com/todos/${userId}`)
      //     .then(res => res.json())
      //     .then(json => setData(json));
      // };
      // useEffect(() => {
      //   fetchData();
      // }, [userId, fetchData]); // fetchData 每次都是新的,导致无限循环或不必要的请求
    
      // 正确的做法 (使用 useCallback):
      const fetchData = useCallback(() => {
        console.log(`Fetching data for userId (memoized): ${userId}`);
        // 模拟API请求
        const mockFetch = new Promise((resolve) => {
          setTimeout(() => {
            resolve({ id: userId, title: `Todo for user ${userId}` });
          }, 500);
        });
        mockFetch.then(json => setData(json));
      }, [userId]); // 只有 userId 变化时,fetchData 才会是新的函数实例
    
      useEffect(() => {
        console.log("useEffect triggered due to fetchData or userId change");
        fetchData();
        // 清理函数 (可选)
        return () => {
          console.log("Cleanup effect for userId:", userId);
          // 可以在这里中止 fetch 请求等
        };
      }, [fetchData]); // 现在 fetchData 是稳定的,effect 只在 fetchData (即 userId) 变化时运行
    
      return (
        <div>
          <button onClick={() => setOtherState(s => s + 1)}>
            Force Rerender (Other State: {otherState})
          </button>
          {data ? <pre>{JSON.stringify(data, null, 2)}</pre> : <p>Loading...</p>}
        </div>
      );
    }
    
    // export default function App() {
    //   const [currentUserId, setCurrentUserId] = useState(1);
    //   return (
    //     <>
    //       <button onClick={() => setCurrentUserId(id => id + 1)}>Next User</button>
    //       <DataFetcher userId={currentUserId} />
    //     </>
    //   );
    // }
    

    在这个例子中,如果 setOtherState 导致 DataFetcher 重新渲染,但 userId 没变,那么 fetchData 函数因为 useCallback 仍然是同一个引用,所以 useEffect 不会重新执行。如果 userId 改变,fetchData 会变成新的函数实例,useEffect 正常执行。

经验法则与注意事项:

  1. 不要过早优化,不要过度优化:只有当性能分析(如 React DevTools Profiler)表明某个组件或计算确实是瓶颈时,才考虑使用它们。
  2. 依赖项数组至关重要
    • 正确性:必须包含函数/计算所依赖的所有外部变量。如果遗漏,可能会导致闭包捕获到过时的值,产生 bug。ESLint 的 eslint-plugin-react-hooks 插件的 exhaustive-deps 规则可以帮助检查。
    • 稳定性:依赖项数组本身的内容如果频繁变化,那么 useMemo/useCallback 就失去了意义。有时需要进一步思考如何稳定依赖项(比如将对象依赖拆分为原始值依赖,或者使用 useReducer 管理复杂状态逻辑以获得稳定的 dispatch 函数)。
  3. useCallback(fn, deps) 等价于 useMemo(() => fn, deps)useCallback 只是 useMemo 用来记忆函数时的一个语法糖。
  4. 简单函数/值的开销:对于非常简单、计算开销极低的函数或值,使用 useMemo / useCallback 的成本(比较依赖项、存储等)可能超过它带来的收益。
  5. React.memo 是前提:如果子组件没有用 React.memo (或 PureComponent),那么即使你用 useMemo/useCallback 优化了传递给它的 props,子组件仍然可能因为父组件的重新渲染而重新渲染(除非子组件内部有其他优化逻辑)。

React 编译器 ("Forget") 与未来

React 团队正在开发一个名为 "Forget" 的实验性编译器。这个编译器的目标是自动地对 React 组件和 Hooks 进行记忆化优化

原理概述:

  1. 静态分析:编译器会在构建时分析你的 React 代码。
  2. 理解依赖关系和可变性:它能理解哪些值在渲染之间是稳定的,哪些是可能变化的,以及函数和计算依赖于哪些值。
  3. 自动插入记忆化:基于分析结果,编译器会自动地、安全地插入类似于 useMemouseCallback 的优化逻辑,或者采用更底层的优化手段,而无需开发者手动编写这些 Hooks。

如果 "Forget" 编译器成熟并广泛应用:

  1. 减少手动优化负担:开发者将不再需要花费大量精力思考何时何地使用 useMemouseCallback,以及如何正确管理它们的依赖项数组。这将大大简化代码,减少出错的可能性。
  2. 默认高性能:React 应用的性能会得到更普遍的提升,因为优化将是自动的,而不是依赖开发者手动应用。
  3. 更直观的代码:开发者可以编写更接近“纯粹” JavaScript 逻辑的组件代码,可读性可能会更高。

什么时候会不再需要它们?

  • 这不是一个确切的版本号。React "Forget" 编译器目前仍在实验阶段。它需要经过充分的测试和迭代,才能成为 React 的标准部分,并且能够覆盖绝大多数需要手动记忆化的场景。
  • 逐渐过渡:即使编译器推出,也可能有一个过渡期。可能最初它能处理大部分常见情况,但对于某些非常复杂的边缘场景,开发者仍然可能需要手动干预。
  • 理念转变:如果编译器足够智能和可靠,那么 useMemouseCallback 可能会变成开发者不常直接使用的“底层工具”,或者仅在编译器无法自动优化的极少数情况下才需要。

** (React 19 及之前) 的状况:**

React 19 及之前,"Forget" 编译器尚未正式发布并集成到 React 的核心中。因此,useMemouseCallback 仍然是 React 性能优化中非常重要且需要手动使用的工具。理解它们的原理和适用场景对于编写高性能的 React 应用至关重要。

总结代码要点与结构 (以 memenuhi "详细代码讲解" 的精神):

下面的代码片段不是一个单一的巨型文件,而是对上述概念的精炼和组织,并辅以注释。

// ========================================================================
// 场景 1: useMemo 优化昂贵计算
// (已在上方 ParentComponent 中通过 processedList 示例给出)
// ========================================================================

// ========================================================================
// 场景 2: useMemo 优化传递给 React.memo 子组件的对象/数组 props
// (已在上方 ParentComponent 和 ChildComponent 示例中通过 styleObject 和 dataArray 给出)
// ========================================================================

// ========================================================================
// 场景 3: useCallback 优化传递给 React.memo 子组件的回调 props
// (已在上方 CounterApp 和 MemoizedButton 示例中通过 handleIncrementA 给出)
// ========================================================================

// ========================================================================
// 场景 4: useCallback 优化作为 useEffect 依赖项的函数
// (已在上方 DataFetcher 示例中通过 fetchData 给出)
// ========================================================================

// ========================================================================
// 场景 5: 过度使用或不当使用的警示 (概念性)
// ========================================================================
function UnnecessaryMemoization({ simpleValue, onSimpleClick }) {
  // 假设 simpleValue 只是一个数字或字符串,它的计算非常简单
  // 假设 onSimpleClick 是一个简单的 setter,或者其逻辑不依赖复杂闭包

  // !! 不必要的 useMemo !!
  // 如果 getDisplayValue 本身计算开销极小
  const displayValue = useMemo(() => {
    console.log("Calculating displayValue (potentially unneeded memo)");
    // 假设这里只是: return `Value: ${simpleValue}`;
    return `Value: ${simpleValue}`; // 计算非常快
  }, [simpleValue]);

  // !! 可能不必要的 useCallback !!
  // 如果 MyListItem 不是 memoized,或者 onSimpleClick 的创建开销极小
  // 并且它不作为其他 hooks (如 useEffect) 的依赖
  const handleClick = useCallback(() => {
    console.log("handleClick called (potentially unneeded memo)");
    onSimpleClick(simpleValue + 1);
  }, [onSimpleClick, simpleValue]); // 这里的依赖项也需要小心

  // 如果 MyListItem 是这样的:
  // const MyListItem = ({ text, onClick }) => <li onClick={onClick}>{text}</li>;
  // 那么 handleClick 的 memoization 就意义不大了,除非 MyListItem 被 React.memo 包裹

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


// ========================================================================
// 对依赖项的说明
// ========================================================================
function DependencyExample({ userSettings }) { // userSettings 是一个对象 { theme: 'dark', fontSize: 12 }
  const [internalCount, setInternalCount] = useState(0);

  // 假设我们有一个基于 userSettings.theme 的回调
  // 错误: 依赖整个 userSettings 对象。如果 userSettings 的任何属性改变 (即使 theme 没变),
  // 或者如果 userSettings 是父组件每次渲染都重新创建的对象,这个 callback 都会变。
  // const handleThemeAction = useCallback(() => {
  //   console.log("Theme action for:", userSettings.theme);
  // }, [userSettings]);

  // 更好: 只依赖实际用到的原始值
  const handleThemeAction = useCallback(() => {
    console.log("Theme action for:", userSettings.theme);
    // 假设这里还用到了 internalCount
    console.log("Internal count during theme action:", internalCount);
  }, [userSettings.theme, internalCount]); // 依赖更精确

  // 如果 handleThemeAction 不需要访问最新的 internalCount,可以将 internalCount 从依赖中移除
  // 如果需要访问但不希望它成为依赖 (例如,只是读取一下而不触发函数再生):
  // 1. 使用 ref: const countRef = useRef(internalCount); useEffect(() => { countRef.current = internalCount });
  //    然后在回调中读取 countRef.current
  // 2. 如果是 setState,使用函数式更新: setCount(c => c + 1)

  useEffect(() => {
    handleThemeAction();
  }, [handleThemeAction]);

  return (
    <button onClick={() => setInternalCount(c => c + 1)}>
      Increment internal count: {internalCount}
    </button>
  );
}

// ========================================================================
// 总结与未来展望 (文字部分已说明)
// - useMemo: memoizes values
// - useCallback: memoizes functions
// - Key use cases: expensive computations, referential stability for props to memoized children,
//   stable dependencies for other hooks.
// - "Forget" Compiler: Aims to automate these memoizations.
// - Current status: Still essential tools for manual optimization.
// ========================================================================

// 这里的代码行数主要通过注释和多个示例场景的组合来充实,
// 核心是把概念和用法通过不同的代码片段讲清楚。
// 对于这两个 Hooks 的讲解,代码的质量和清晰度比单纯的行数更重要。

// 模拟一个父组件来使用上面的组件
function AppShowcase() {
  const [appTheme, setAppTheme] = useState('light');
  const [mainList, setMainList] = useState([{id: 1, name: "Apple"}, {id: 2, name: "Banana"}]);
  const [filter, setFilter] = useState('');

  const userSpecificSettings = useMemo(() => ({
    theme: appTheme,
    fontSize: appTheme === 'light' ? 14 : 16,
    // ...其他属性
  }), [appTheme]);

  const handleSimpleClick = useCallback((value) => {
    console.log("AppShowcase: Simple click received with value", value);
  }, []);

  return (
    <div>
      <h1>React Memoization Showcase</h1>

      <button onClick={() => setAppTheme(t => t === 'light' ? 'dark' : 'light')}>
        Toggle App Theme
      </button>
      <input
        type="text"
        placeholder="Filter list"
        value={filter}
        onChange={e => setFilter(e.target.value)}
      />
      <button onClick={() => setMainList([...mainList, {id: Date.now(), name: "New Fruit"}])}>
        Add Fruit
      </button>

      <h2>MyComponent (Expensive List Processing)</h2>
      <MyComponent list={mainList} filterTerm={filter} sortKey="name" />

      <h2>Parent/Child with Memoized Props</h2>
      <ParentComponent />

      <h2>CounterApp with Memoized Callbacks</h2>
      <CounterApp />

      <h2>DataFetcher with Memoized Effect Callback</h2>
      <DataFetcher userId={1} /> {/* 可以再加一个按钮改变 userId 来观察 */}

      <h2>UnnecessaryMemoization Example</h2>
      <UnnecessaryMemoization simpleValue={42} onSimpleClick={handleSimpleClick} />

      <h2>DependencyExample</h2>
      <DependencyExample userSettings={userSpecificSettings} />
    </div>
  );
}

// export default AppShowcase;

参考react19如何自动useMemo和useCallback juejin.cn/post/749791…