useMemo

缓存计算结果的利器 - 避免昂贵计算的重复执行

核心概述

在 React 组件中,每次状态或 props 变化都会触发组件重新渲染, 导致组件内的所有代码重新执行。如果其中包含昂贵的计算(如大数组过滤、复杂排序、数学运算), 即使输入数据没变,也会浪费计算资源,导致界面卡顿。

useMemo 通过缓存计算结果来解决这个问题。 它接收一个计算函数和依赖数组,只在依赖变化时才重新执行计算,其他渲染直接返回缓存的结果。 这让你既能保持代码的响应式,又能避免不必要的性能开销。

核心机制:React 会在首次渲染时执行计算并缓存结果, 后续渲染时检查依赖数组中的值是否变化。如果依赖未变,直接返回缓存值,跳过计算; 如果依赖变了,重新计算并更新缓存。这种"按需计算"的策略是 React 性能优化的核心。

适用场景:当组件中有昂贵的计算、需要避免子组件不必要的渲染、 或需要确保对象引用稳定时使用 useMemo。但不要过度优化——只有当性能测试表明某个计算确实是瓶颈时才使用。

💡 心智模型

将 useMemo 想象成一个"智能备忘录":

  • 首次计算:写下问题和答案,存入备忘录
  • 后续查询:先检查问题中的数字(依赖)是否变化
  • 命中缓存:如果数字没变,直接读取备忘录中的答案
  • 重新计算:如果数字变了,重新计算并更新备忘录

关键:useMemo 的价值在于"跳过计算",而非"跳过渲染"。组件仍然会重新渲染, 但昂贵的计算被跳过了。

技术规格

类型签名

function useMemo<T>(
  calculateValue: () => T,
  dependencies: DependencyList
): T

// 泛型 T 是缓存值的类型
// calculateValue 必须是纯函数,返回缓存值
// dependencies 是依赖数组,只有数组中的值变化时才重新计算

参数说明

参数类型说明
calculateValue() => T计算缓存值的纯函数。必须返回一个值,不能接收参数。 函数只在依赖变化时执行。
dependenciesDependencyList依赖数组。React 使用 Object.is 比较数组中每个值与上次渲染的值。 只要有一个值变化,就会重新执行 calculateValue。

返回值

返回值类型说明
缓存值T首次渲染返回 calculateValue() 的执行结果, 后续渲染返回缓存的值(除非依赖变化)。

运行机制

依赖比较:React 使用 Object.is 比较依赖数组中的每个值。 对于基本类型,比较值是否相等;对于对象/数组,比较引用是否相同。 这意味着如果依赖是对象,必须确保对象引用稳定(配合 useMemo 或 useState)。

缓存失效:当任何一个依赖变化时,React 会在当前渲染中执行 calculateValue, 并将结果缓存用于下次渲染。需要注意的是,计算是同步执行的,如果计算太慢会阻塞渲染。

引用稳定性:useMemo 返回的值在依赖不变时保持同一个引用, 这对于传递给子组件作为 props 很重要。稳定的引用可以避免子组件触发不必要的渲染。

实战演练

1. 基础用法:过滤大数组

最常见的场景是过滤或排序大量数据,避免每次渲染都重新计算:

import { useMemo } from 'react';

interface Product {
  id: string;
  name: string;
  category: string;
  price: number;
}

interface ProductListProps {
  products: Product[];
  categoryFilter: string;
  maxPrice?: number;
}

export function ProductList({ products, categoryFilter, maxPrice }: ProductListProps) {
  // ✅ 只在 products 或 categoryFilter 变化时重新过滤
  const filteredProducts = useMemo(() => {
    console.log('过滤产品列表...'); // 可以观察何时重新计算
    return products.filter(product => {
      const matchCategory = product.category === categoryFilter;
      const matchPrice = !maxPrice || product.price <= maxPrice;
      return matchCategory && matchPrice;
    });
  }, [products, categoryFilter, maxPrice]);

  return (
    <div>
      <h2>产品列表 ({filteredProducts.length})</h2>
      <ul>
        {filteredProducts.map(product => (
          <li key={product.id}>
            {product.name} - ¥{product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

2. 生产级案例:复杂计算优化

展示如何缓存复杂的数学计算,避免在每次渲染时重复执行:

import { useMemo, useState } from 'react';

interface DataPoint {
  x: number;
  y: number;
}

interface StatisticsResult {
  mean: number;
  median: number;
  standardDeviation: number;
  variance: number;
}

export function StatisticsCalculator({ data }: { data: DataPoint[] }) {
  const [showDetails, setShowDetails] = useState(false);

  // ✅ 缓存复杂的统计计算
  const statistics = useMemo((): StatisticsResult => {
    console.log('计算统计数据...');

    if (data.length === 0) {
      return { mean: 0, median: 0, standardDeviation: 0, variance: 0 };
    }

    // 1. 计算平均值
    const sum = data.reduce((acc, point) => acc + point.y, 0);
    const mean = sum / data.length;

    // 2. 计算方差
    const squaredDiffs = data.map(point => Math.pow(point.y - mean, 2));
    const variance = squaredDiffs.reduce((acc, val) => acc + val, 0) / data.length;

    // 3. 计算标准差
    const standardDeviation = Math.sqrt(variance);

    // 4. 计算中位数
    const sortedValues = [...data].sort((a, b) => a.y - b.y);
    const mid = Math.floor(sortedValues.length / 2);
    const median = sortedValues.length % 2 !== 0
      ? sortedValues[mid].y
      : (sortedValues[mid - 1].y + sortedValues[mid].y) / 2;

    return { mean, median, standardDeviation, variance };
  }, [data]);

  return (
    <div className="p-6 bg-white rounded-lg shadow">
      <h2 className="text-2xl font-bold mb-4">统计分析</h2>

      {/* 基础统计 */}
      <div className="space-y-2">
        <p>数据点数量: {data.length}</p>
        <p>平均值: {statistics.mean.toFixed(2)}</p>
        <p>中位数: {statistics.median.toFixed(2)}</p>
      </div>

      {/* 切换详细显示 - 不会触发重新计算 statistics */}
      <button
        onClick={() => setShowDetails(!showDetails)}
        className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
      >
        {showDetails ? '隐藏' : '显示'}详细统计
      </button>

      {/* 详细统计 - 使用缓存的值 */}
      {showDetails && (
        <div className="mt-4 p-4 bg-gray-50 rounded space-y-2">
          <p>方差: {statistics.variance.toFixed(2)}</p>
          <p>标准差: {statistics.standardDeviation.toFixed(2)}</p>
        </div>
      )}
    </div>
  );
}

3. 生产级案例:避免子组件渲染

使用 useMemo 稳定对象引用,避免子组件因为父组件的无关状态变化而重新渲染:

import { useMemo, useState } from 'react';

interface User {
  id: string;
  name: string;
  avatar: string;
}

interface UserProfileProps {
  user: User;
  onEdit: (user: User) => void;
}

// ✅ 使用 React.memo 优化子组件
const UserProfile = React.memo(({ user, onEdit }: UserProfileProps) => {
  console.log('UserProfile 渲染'); // 观察渲染次数
  return (
    <div className="p-4 border rounded">
      <img src={user.avatar} alt={user.name} className="w-12 h-12 rounded-full" />
      <h3 className="font-semibold">{user.name}</h3>
      <button onClick={() => onEdit(user)} className="text-blue-500">
        编辑
      </button>
    </div>
  );
});

export function UserList({ users }: { users: User[] }) {
  const [filter, setFilter] = useState('');
  const [otherState, setOtherState] = useState(0);

  // ❌ 错误:每次渲染都创建新函数,导致 UserProfile 重新渲染
  // const handleEdit = (user: User) => {
  //   console.log('编辑用户:', user);
  // };

  // ✅ 正确:使用 useCallback 稳定函数引用
  const handleEdit = useMemo(() => (user: User) => {
    console.log('编辑用户:', user);
  }, []);

  // ✅ 过滤后的用户列表
  const filteredUsers = useMemo(() => {
    return users.filter(user =>
      user.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [users, filter]);

  return (
    <div className="p-6">
      <input
        type="text"
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="搜索用户..."
        className="w-full px-4 py-2 border rounded mb-4"
      />

      {/* 这个按钮不会影响 UserProfile 的渲染 */}
      <button onClick={() => setOtherState(otherState + 1)}>
        其他状态: {otherState}
      </button>

      <div className="space-y-2 mt-4">
        {filteredUsers.map(user => (
          <UserProfile
            key={user.id}
            user={user}
            onEdit={handleEdit}
          />
        ))}
      </div>
    </div>
  );
}

4. 生产级案例:防止依赖循环

在 useMemo 中返回对象,确保对象引用稳定,避免无限循环:

import { useMemo, useEffect, useState } from 'react';

interface ChartConfig {
  width: number;
  height: number;
  padding: number;
  colors: string[];
}

export function Chart({ data }: { data: number[] }) {
  const [containerSize, setContainerSize] = useState({ width: 800, height: 600 });

  // ❌ 错误:每次渲染都创建新对象
  // const config = {
  //   width: containerSize.width,
  //   height: containerSize.height,
  //   padding: 20,
  //   colors: ['#FF6B6B', '#4ECDC4'],
  // };

  // ✅ 正确:使用 useMemo 缓存配置对象
  const config = useMemo<ChartConfig>(() => ({
    width: containerSize.width,
    height: containerSize.height,
    padding: 20,
    colors: ['#FF6B6B', '#4ECDC4'],
  }), [containerSize.width, containerSize.height]);

  // ✅ 因为 config 是稳定的,这个 effect 不会无限循环
  useEffect(() => {
    console.log('初始化图表,配置:', config);
    // 初始化图表库...
  }, [config]); // 依赖 config 是安全的

  // 模拟容器尺寸变化
  useEffect(() => {
    const handleResize = () => {
      setContainerSize({
        width: window.innerWidth - 40,
        height: window.innerHeight - 200,
      });
    };

    window.addEventListener('resize', handleResize);
    handleResize(); // 初始尺寸

    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return (
    <div>
      <h3>图表配置:</h3>
      <pre>{JSON.stringify(config, null, 2)}</pre>
      <div style={{ width: config.width, height: config.height, backgroundColor: '#f0f0f0' }}>
        {/* 图表渲染 */}
        <p>图表区域 ({config.width}x{config.height})</p>
      </div>
    </div>
  );
}

避坑指南

陷阱 1: 过度使用 useMemo

问题:对所有计算都使用 useMemo,包括简单的运算。 这会增加代码复杂度,甚至可能降低性能(缓存比较也有成本)。

// ❌ 过度优化:简单计算不需要 useMemo
function Component({ a, b }) {
  const sum = useMemo(() => a + b, [a, b]);
  const product = useMemo(() => a * b, [a, b]);
  const isPositive = useMemo(() => a > 0, [a]);

  return <div>{sum} {product} {isPositive}</div>;
}
// ✅ 正确:简单计算直接在渲染中执行
function Component({ a, b }) {
  // JavaScript 引擎对这些基本运算优化得很好
  return <div>{a + b} {a * b} {a > 0}</div>;
}

// ✅ 使用 useMemo 的场景:O(n) 或更复杂的计算
function Component({ items }) {
  const sorted = useMemo(() => {
    // 这是一个 O(n log n) 的排序操作
    return [...items].sort((a, b) => a.value - b.value);
  }, [items]);

  return <div>{sorted.map(...)}</div>;
}

经验法则:只有当计算的复杂度是 O(n) 或更高,或者性能测试显示该计算是瓶颈时, 才使用 useMemo 优化。优先确保代码清晰,再考虑性能优化。

陷阱 2: 依赖数组不完整或错误

问题:依赖数组不完整会导致返回过期的缓存值, 依赖数组包含不必要的值会导致缓存失效,失去优化效果。

// ❌ 错误:缺少依赖 options
function Component({ data, options }) {
  const result = useMemo(() => {
    return processData(data, options); // 使用了 options 但未声明
  }, [data]); // 缺少 options!

  return <div>{result}</div>;
}

// ❌ 错误:依赖过多导致缓存频繁失效
function Component({ data, options, theme, locale, user }) {
  const result = useMemo(() => {
    return data.filter(item => item.value > options.min);
  }, [data, options, theme, locale, user]); // theme, locale, user 不在计算中使用

  return <div>{result}</div>;
}
// ✅ 正确:依赖数组包含所有使用的值
function Component({ data, options }) {
  const result = useMemo(() => {
    return processData(data, options);
  }, [data, options]); // 完整的依赖

  return <div>{result}</div>;
}

// ✅ 使用 ESLint 的 exhaustive-deps 规则自动检查
// eslint-disable-next-line react-hooks/exhaustive-deps

陷阱 3: 在 useMemo 中执行副作用

问题:在 useMemo 的计算函数中执行副作用(如 API 调用、DOM 操作), 违反了纯函数原则,可能导致不可预测的行为。

// ❌ 错误:在 useMemo 中调用 API
function UserProfile({ userId }) {
  const user = useMemo(() => {
    // ❌ 不要在 useMemo 中执行副作用!
    fetch('/api/users/' + userId).then(res => res.json());
    // 这会导致每次渲染都发起请求,且返回值不是 Promise
    return null;
  }, [userId]);

  return <div>{user?.name}</div>;
}
// ✅ 正确:使用 useEffect 处理副作用
import { useEffect, useState } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // ✅ 副作用应该在 useEffect 中
  useEffect(() => {
    let cancelled = false;

    fetch('/api/users/' + userId)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setUser(data);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [userId]);

  return <div>{user?.name}</div>;
}

陷阱 4: 期望 useMemo 减少渲染次数

常见误区:认为使用 useMemo 后组件就不会重新渲染。 实际上,useMemo 只是跳过计算,组件仍然会重新渲染,只是使用了缓存的值。

function Component({ count }) {
  const expensiveValue = useMemo(() => {
    console.log('执行昂贵计算...');
    return count * 1000;
  }, [count]);

  // 即使 expensiveValue 被缓存,组件仍然会重新渲染
  console.log('组件渲染');

  return <div>{expensiveValue}</div>;
}

// 执行流程:
// 1. count 变化触发重新渲染
// 2. 组件函数执行,console.log('组件渲染')
// 3. useMemo 检查依赖,如果 count 没变,跳过计算,返回缓存值
// 4. 组件返回 JSX
// 5. React 更新 DOM

// 关键:组件函数仍然执行了,只是跳过了 useMemo 中的计算

心智模型纠正:useMemo 的价值在于"跳过计算",而非"跳过渲染"。 如果要避免子组件渲染,需要结合 React.memo 和 useMemo 稳定 props 引用。

最佳实践

✅ 推荐模式

  • 对 O(n) 或更复杂的计算使用 useMemo
  • 使用 ESLint exhaustive-deps 规则检查依赖
  • 用 useMemo 稳定传递给纯组件的 props
  • 在 useMemo 中返回对象,避免依赖循环
  • 先确保代码正确,再考虑性能优化
  • 使用性能分析工具验证优化效果

❌ 避免模式

  • 不要对简单计算使用 useMemo
  • 不要在 useMemo 中执行副作用
  • 不要忽略依赖数组的警告
  • 不要期望 useMemo 减少组件渲染
  • 不要为了优化而牺牲代码可读性
  • 不要在 useMemo 中修改外部状态

📊 useMemo vs 直接计算

场景直接计算useMemo
每次渲染重新执行计算跳过计算(使用缓存)
首次渲染执行计算执行计算并缓存
依赖变化重新执行重新执行并更新缓存
内存开销需要存储缓存值

延伸阅读

这篇文章有帮助吗?