引言

React 的性能优化是一个被广泛讨论但经常被误解的话题。许多开发者过早地使用 memo、useMemo 和 useCallback,不仅没有带来性能提升,反而增加了代码复杂度和维护成本。

本文将深入 React 的渲染机制,系统地梳理性能优化的优先级和实战技巧——从理解什么是"不必要的渲染",到虚拟列表、代码分割等重量级优化手段。


一、理解 React 的渲染机制

在讨论优化之前,必须理解 React 何时会触发重新渲染。

渲染的触发条件

React 组件在以下三种情况下会重新渲染:

  1. State 发生变化 — 组件自身的 state 通过 setState 或 useState 更新
  2. 父组件重新渲染 — 即使传给子组件的 props 没有变化,子组件默认也会重新渲染
  3. Context 值变化 — 组件消费的 Context 值发生改变
tsx
function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>
      {/* 即使 ExpensiveChild 不需要 count,它也会重新渲染 */}
      <ExpensiveChild />
    </div>
  );
}

渲染 !== DOM 更新

一个重要认知:React 的"渲染"是生成 Virtual DOM 的过程,不等于实际的 DOM 操作。React 通过 Reconciliation 算法(diffing)来决定哪些 DOM 需要更新。渲染的开销主要在于组件函数的执行和 Virtual DOM 的比较,而非 DOM 操作本身。


二、React.memo:阻止不必要的渲染

React.memo 是最基础也是最常用的性能优化 API。它通过浅比较 props 来决定是否跳过渲染。

基础用法

tsx
import { memo } from 'react';

interface UserCardProps {
  name: string;
  avatar: string;
  bio: string;
}

const UserCard = memo(function UserCard({ name, avatar, bio }: UserCardProps) {
  console.log('UserCard rendered');

  return (
    <div className="user-card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
      <p>{bio}</p>
    </div>
  );
});

自定义比较函数

当浅比较不够用时,可以传入自定义比较函数:

tsx
const UserCard = memo(
  function UserCard({ user, onSelect }: UserCardProps) {
    return (
      <div onClick={() => onSelect(user.id)}>
        <h3>{user.name}</h3>
      </div>
    );
  },
  (prevProps, nextProps) => {
    // 仅当 user.id 或 onSelect 引用变化时才重新渲染
    return (
      prevProps.user.id === nextProps.user.id &&
      prevProps.onSelect === nextProps.onSelect
    );
  }
);

什么时候不该用 memo?

并非所有组件都需要 memo:

场景 是否使用 memo 原因
组件 props 频繁变化 ❌ 不需要 memo 的比较开销反而增加成本
组件本身渲染开销很小 ❌ 不需要 浅比较成本可能超过重渲染成本
props 中有 children ⚠️ 通常不需要 children 每次都是新引用,memo 几乎无效
渲染开销大的纯展示组件 ✅ 推荐 避免不必要的重复计算
大型列表中项组件 ✅ 强烈推荐 配合虚拟列表效果显著

三、useMemo:缓存计算值

useMemo 用来缓存昂贵的计算结果,避免每次渲染都重新计算。

正确的使用场景

tsx
function ProductList({ products, filters }: Props) {
  // ✅ 过滤和排序是昂贵操作,用 useMemo 缓存
  const filteredProducts = useMemo(() => {
    return products
      .filter((p) => {
        if (filters.category && p.category !== filters.category) return false;
        if (filters.minPrice && p.price < filters.minPrice) return false;
        if (filters.maxPrice && p.price > filters.maxPrice) return false;
        return true;
      })
      .sort((a, b) => {
        switch (filters.sortBy) {
          case 'price-asc': return a.price - b.price;
          case 'price-desc': return b.price - a.price;
          case 'rating': return b.rating - a.rating;
          default: return 0;
        }
      });
  }, [products, filters]);

  return (
    <ul>
      {filteredProducts.map((product) => (
        <ProductItem key={product.id} product={product} />
      ))}
    </ul>
  );
}

useMemo 的常见误用

tsx
// ❌ 错误:缓存一个原始值或简单的对象字面量
const count = useMemo(() => 1 + 2, []);
const style = useMemo(() => ({ color: 'red' }), []);

// ✅ 正确:直接声明即可
const count = 1 + 2;

// ✅ 正确:真正需要缓存的场景——传给 memo 组件的引用类型 prop
const sortedItems = useMemo(() => {
  return items.slice().sort(complexSortFn);
}, [items]);

useMemo 与引用稳定性

useMemo 的一个重要用途是保持引用稳定,配合 React.memo 使用:

tsx
function Dashboard({ rawData }: Props) {
  // 保持 chartData 引用稳定,避免子组件无意义重渲染
  const chartData = useMemo(() => {
    return processChartData(rawData);
  }, [rawData]);

  return <ExpensiveChart data={chartData} />;
}

四、useCallback:缓存函数引用

useCallback 是 useMemo 的特化版本,专门用于缓存函数。

核心使用场景

tsx
function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  // ✅ 用 useCallback 保证 handleSearch 引用稳定
  const handleSearch = useCallback(async (searchQuery: string) => {
    const data = await fetch(`/api/search?q=${searchQuery}`).then((r) => r.json());
    setResults(data);
  }, []); // 依赖为空,函数引用永远不变

  return (
    <>
      <SearchInput onSearch={handleSearch} />
      <SearchResults results={results} />
    </>
  );
}

useCallback + memo 的组合模式

tsx
const ExpensiveList = memo(function ExpensiveList({
  items,
  onItemClick,
}: {
  items: Item[];
  onItemClick: (id: string) => void;
}) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id} onClick={() => onItemClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

function Parent() {
  const [items, setItems] = useState<Item[]>([]);

  // 如果不使用 useCallback,每次渲染 handleClick 都是新函数
  // → ExpensiveList 的 memo 失效 → 每次都重新渲染
  const handleClick = useCallback((id: string) => {
    setItems((prev) => prev.filter((item) => item.id !== id));
  }, []);

  return <ExpensiveList items={items} onItemClick={handleClick} />;
}

当 state 依赖不可避免时

tsx
function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);

  // ✅ 使用函数式更新避免依赖 todos
  const handleToggle = useCallback((id: string) => {
    setTodos((prev) =>
      prev.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }, []); // 依赖为空

  // ❌ 这会导致 handleDelete 每次 todos 变化时都创建新引用
  const handleDelete = useCallback(
    (id: string) => {
      setTodos(todos.filter((t) => t.id !== id));
    },
    [todos] // 依赖变化频繁
  );
}

五、虚拟列表:渲染海量数据

当列表包含数百甚至数千项时,虚拟列表是解决性能问题的终极方案。

实现原理

虚拟列表的核心思想是只渲染可视区域内的元素:

code
┌─────────────────────┐
│  (hidden padding)   │  ← 滚动偏移量
├─────────────────────┤
│  Item 47            │  ← 可视区域
│  Item 48            │
│  Item 49            │
│  Item 50            │
│  Item 51            │
├─────────────────────┤
│  (hidden padding)   │  ← 剩余高度
└─────────────────────┘

使用 react-window

tsx
import { FixedSizeList as List } from 'react-window';

interface RowProps {
  index: number;
  style: React.CSSProperties;
  data: Item[];
}

const Row = memo(function Row({ index, style, data }: RowProps) {
  const item = data[index];

  return (
    <div style={style} className="list-item">
      <span className="index">{index + 1}</span>
      <span className="name">{item.name}</span>
      <span className="value">{item.value}</span>
    </div>
  );
});

function VirtualList({ items }: { items: Item[] }) {
  return (
    <List
      height={600}
      itemCount={items.length}
      itemSize={48}
      width="100%"
      itemData={items}
    >
      {Row}
    </List>
  );
}

动态高度的虚拟列表

tsx
import { VariableSizeList as List } from 'react-window';

function DynamicVirtualList({ items }: { items: Post[] }) {
  const listRef = useRef<List>(null);
  const itemSizes = useRef<Map<number, number>>(new Map());

  const getItemSize = useCallback((index: number) => {
    return itemSizes.current.get(index) ?? 100; // 默认高度
  }, []);

  const Row = memo(function Row({ index, style, data }: RowProps) {
    return (
      <div style={style}>
        <h3>{data[index].title}</h3>
        <p>{data[index].body}</p>
      </div>
    );
  });

  return (
    <List
      ref={listRef}
      height={600}
      itemCount={items.length}
      itemSize={getItemSize}
      width="100%"
      itemData={items}
    >
      {Row}
    </List>
  );
}

自定义简易虚拟列表

理解原理有助于更好地使用现成库:

tsx
function SimpleVirtualList({ items, itemHeight, containerHeight }: Props) {
  const [scrollTop, setScrollTop] = useState(0);

  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 2);
  const endIndex = Math.min(
    items.length,
    Math.ceil((scrollTop + containerHeight) / itemHeight) + 2
  );

  const visibleItems = items.slice(startIndex, endIndex);
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleItems.map((item, i) => (
            <div key={startIndex + i} style={{ height: itemHeight }}>
              {item.content}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

六、代码分割与懒加载

React.lazy 与 Suspense

tsx
import { lazy, Suspense } from 'react';

// 路由级代码分割
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  );
}

组件级懒加载

tsx
const HeavyChart = lazy(() => import('./HeavyChart'));
const CodeEditor = lazy(() => import('./CodeEditor'));

function ContentPanel() {
  const [showEditor, setShowEditor] = useState(false);

  return (
    <div>
      <Suspense fallback={<div className="chart-skeleton" />}>
        <HeavyChart data={chartData} />
      </Suspense>

      <button onClick={() => setShowEditor(true)}>打开编辑器</button>

      {showEditor && (
        <Suspense fallback={<div>加载中...</div>}>
          <CodeEditor />
        </Suspense>
      )}
    </div>
  );
}

条件加载重型依赖

tsx
async function loadMarkdownEditor() {
  const [{ default: Editor }, { default: highlight }] = await Promise.all([
    import('@uiw/react-md-editor'),
    import('highlight.js'),
  ]);
  return { Editor, highlight };
}

function MarkdownSection() {
  const [isEditing, setIsEditing] = useState(false);
  const [EditorComp, setEditorComp] = useState<React.ComponentType | null>(null);

  const enableEditing = async () => {
    setIsEditing(true);
    const { Editor } = await loadMarkdownEditor();
    setEditorComp(() => Editor);
  };

  if (!isEditing) {
    return <button onClick={enableEditing}>编辑</button>;
  }

  if (!EditorComp) return <div className="editor-loading" />;

  return <EditorComp value={content} onChange={setContent} />;
}

七、状态管理优化

Context 拆分

将不同关注点的 Context 分开,减少无关组件的重渲染:

tsx
// ❌ 单一 Context 导致所有消费者在任一值变化时都重渲染
const AppContext = createContext({
  user: null as User | null,
  theme: 'light' as Theme,
  notifications: [] as Notification[],
});

// ✅ 拆分为独立 Context
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>('light');
const NotificationContext = createContext<Notification[]>([]);

function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<Theme>('light');
  const [notifications, setNotifications] = useState<Notification[]>([]);

  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <NotificationContext.Provider value={notifications}>
          {children}
        </NotificationContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

状态下沉

将状态尽可能地放在离使用它的组件最近的地方:

tsx
// ❌ 状态提升到了不需要它的父组件
function Page() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <div>
      <Header /> {/* 不需要 isModalOpen */}
      <Content /> {/* 不需要 isModalOpen */}
      <Footer>
        <ModalButton /> {/* 唯一需要的地方 */}
      </Footer>
    </div>
  );
}

// ✅ 将状态封装在真正使用它的组件中
function Page() {
  return (
    <div>
      <Header />
      <Content />
      <Footer>
        <ModalButton /> {/* 模态框状态在此组件内部 */}
      </Footer>
    </div>
  );
}

使用 useRef 避免不必要的渲染

tsx
function SearchInput() {
  const [query, setQuery] = useState('');
  const debounceTimerRef = useRef<ReturnType<typeof setTimeout>>();
  const latestQueryRef = useRef(query);

  // 保持 ref 同步,但不触发渲染
  useEffect(() => {
    latestQueryRef.current = query;
  }, [query]);

  const handleThrottledSearch = useCallback((value: string) => {
    setQuery(value); // 立即更新 input 显示

    // 防抖 API 调用
    if (debounceTimerRef.current) {
      clearTimeout(debounceTimerRef.current);
    }
    debounceTimerRef.current = setTimeout(() => {
      performSearch(latestQueryRef.current);
    }, 300);
  }, []);

  return <input value={query} onChange={(e) => handleThrottledSearch(e.target.value)} />;
}

八、性能分析工具

React DevTools Profiler

React DevTools 的 Profiler 面板可以帮助定位渲染瓶颈:

  1. 打开 React DevTools → Profiler 选项卡
  2. 点击录制按钮,执行操作,停止录制
  3. 查看火焰图——灰色组件表示跳过了渲染,黄色/红色表示渲染耗时较长

使用 Profiler API

tsx
import { Profiler } from 'react';

function onRenderCallback(
  id: string,
  phase: 'mount' | 'update' | 'nested-update',
  actualDuration: number,
  baseDuration: number,
  startTime: number,
  commitTime: number,
) {
  if (actualDuration > 16) {
    console.warn(`[${id}] 渲染耗时 ${actualDuration}ms,超过一帧预算`);
  }
}

function App() {
  return (
    <Profiler id="Dashboard" onRender={onRenderCallback}>
      <Dashboard />
    </Profiler>
  );
}

为什么不能用 useMemo/useCallback 包装所有东西

tsx
// ❌ 过度优化——得不偿失
const doubled = useMemo(() => count * 2, [count]);
const handleClick = useCallback(() => setCount((c) => c + 1), []);
const title = useMemo(() => `${name}'s Profile`, [name]);

// ✅ 仅在以下情况使用这些 API:
// 1. 计算确实昂贵(复杂的数据转换、排序、过滤)
// 2. 引用稳定性对于 memo 子组件至关重要
// 3. 作为其他 Hook(useEffect、useMemo)的依赖

九、优化决策流程图

在决定如何优化时,按以下优先级行动:

code
遇到性能问题
    │
    ├─→ 1. 先测量(Profiler/Performance),确定瓶颈
    │
    ├─→ 2. 是否是大量数据渲染?
    │       └─→ 使用虚拟列表(react-window/react-virtuoso)
    │
    ├─→ 3. 是否是首次加载慢?
    │       └─→ 代码分割 + 懒加载(React.lazy + Suspense)
    │
    ├─→ 4. 是否是组件频繁无意义重渲染?
    │       ├─→ 状态下沉到使用它的组件
    │       ├─→ Context 拆分
    │       ├─→ React.memo 包裹渲染开销大的组件
    │       └─→ useCallback/useMemo 稳定 props 引用
    │
    └─→ 5. 是否是昂贵的计算?
            └─→ useMemo 缓存结果

结语

React 性能优化的核心原则是:先测量,再优化。在没有明确性能问题的前提下,盲目添加 memo、useMemo 和 useCallback 只会让代码更难维护。

真正有效的性能优化策略从架构层面开始——合理的组件拆分、状态下沉、Context 设计和代码分割——这些基础做对了,微观层面的 API 才有意义。虚拟列表和代码分割是解决特定性能瓶颈的利器,但也只在确实需要时使用。

记住:过早的优化是万恶之源,但基于测量的优化是工程美德。