鲁斯前端布鲁斯前端

文章中英模式

常见的前端面试题目 - React 渲染优化 (memo, useMemo, useCallback)

深入解析 React 中的 memo, useMemo 与 useCallback 效能优化技巧,包含实用范例、效能测量方法、权衡分析以及面试常见问题的完整解答。

影片縮圖

懒得看文章?那就来看视频吧

基本概念

React 元件渲染机制是前端开发中常见的效能瓶颈,当父组件重新渲染时,所有子组件默认也会重新渲染,不管 props 是否有变化。React 提供了三种主要工具来避免不必要的渲染:

  • 1. React.memo:记忆化整个组件
  • 2. useMemo:记忆化计算结果
  • 3. useCallback:记忆化函数

React.memo:元件级优化

React.memo 是一个高阶组件,只有当传入的 props 发生变化时才会重新渲染元件。

// 未优化版本
function ProductItem({ product, onAddToCart }) {
  console.log(`渲染 ${product.name}`);
  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={() => onAddToCart(product.id)}>加入购物车</button>
    </div>
  );
}

// 优化版本
const MemoizedProductItem = React.memo(ProductItem);

注意:使用物件或函数作为 props 时,每次父元件渲染都会创建新的引用,使 memo 失效。

useMemo:记忆化计算结果

useMemo 用于避免在每次渲染时重复执行昂贵的计算。

function ProductList({ products, filter }) {
  // 未优化:每次渲染都会重新计算
  // const filteredProducts = products.filter(p => p.name.includes(filter));
  
  // 优化:只有当 products 或 filter 变化时才重新计算
  const filteredProducts = useMemo(() => {
    console.log('过滤产品...');
    return products.filter(p => p.name.includes(filter));
  }, [products, filter]);
  
  return (
    <div>
      {filteredProducts.map(product => (
        <ProductItem key={product.id} product={product} />
      ))}
    </div>
  );
}

useCallback:记忆化函数

useCallback 用于记忆化函数,避免在每次渲染时创建新的函数引用。

function ShoppingCart() {
  const [items, setItems] = useState([]);
  
  // 未优化:每次渲染都是新函数
  // const handleAddItem = (id) => {
  //   setItems(prev => [...prev, id]);
  // };
  
  // 优化:只有当 setItems 变化时才创建新函数
  const handleAddItem = useCallback((id) => {
    setItems(prev => [...prev, id]);
  }, [setItems]);
  
  return (
    <div>
      <h2>购物车 ({items.length})</h2>
      <ProductList onAddItem={handleAddItem} />
    </div>
  );
}

效能优化决策图

是否需要优化?
组件是否经常重新渲染? → 否 → 暂不优化
渲染是否导致明显延迟? → 否 → 暂不优化
选择优化工具:
- 整个组件需要优化 → React.memo
- 昂贵计算需要优化 → useMemo
- 回调函数需要优化 → useCallback

常见陷阱与注意事项

理解记忆化的陷阱能帮助你避免常见的效能优化错误:

  • 1. 过度优化:不要为每个组件或函数都添加记忆化,这本身会增加性能开销。
  • 2. 依赖项遗漏:在 useMemo 和 useCallback 中遗漏依赖项会导致 bug。
  • 3. 物件/数组引用问题:直接在 render 中创建物件/数组会使 memo 失效。
// 错误示例
<MemoizedComponent data={{id: 1}} /> // 每次渲染都是新物件

// 正确示例
const data = useMemo(() => ({id: 1}), []);
<MemoizedComponent data={data} />

记忆化与重新渲染的权衡

了解什么时候使用记忆化技术是 React 效能优化的关键。过早优化可能是无效的,甚至有反效果。

测量优化效益

在决定是否使用记忆化前,先进行效能测量:

import { useEffect, useState } from 'react';

function RenderMetrics({ componentName }) {
  const [renderCount, setRenderCount] = useState(0);
  const [renderTime, setRenderTime] = useState(0);
  
  useEffect(() => {
    const start = performance.now();
    setRenderCount(prev => prev + 1);
    
    return () => {
      const time = performance.now() - start;
      setRenderTime(time);
    };
  });
  
  return (
    <div className="text-xs text-gray-400">
      {componentName}: {renderCount} 次渲染,最近耗时 {renderTime.toFixed(2)}ms
    </div>
  );
}

记忆化成本与效益分析

// 情境1:简单组件,优化效益小
function SimpleLabel({ text }) {
  return <p>{text}</p>;
}
// 不需要 memo - 渲染成本极低,记忆化反而增加开销

// 情境2:中等复杂度,视情况而定
function UserCard({ user, onEdit }) {
  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <button onClick={() => onEdit(user.id)}>编辑</button>
    </div>
  );
}
// 若父组件频繁更新但 user 很少变化,使用 memo 可能有益

// 情境3:复杂组件,优化效益大
function DataTable({ rows, columns, onRowSelect }) {
  // 大量数据与复杂 DOM
  return (
    <table>
      {/* 复杂渲染逻辑 */}
    </table>
  );
}
// 强烈建议使用 memo,渲染成本高昂

⚠️ 避免过度优化的实用准则

  1. 1. 先衡量效能再优化:使用 React DevTools Profiler 或 console.time() 测量实际耗时
  2. 2. 80/20 法则:优化对用户体验影响最大的 20% 重点组件
  3. 3. 考虑记忆化本身的成本:增加内存使用、依赖比较时间和代码复杂度

记住:过早优化是万恶之源。优化应该是解决已确认的问题,而不是预防性的措施。

高频重复渲染场景分析

以下是两个在实际开发中常见的高频重复渲染场景,以及如何测量和解决这些性能问题:

场景一:大数据量图表渲染

当需要在同一页面上展示多个基于相同大数据集的不同图表时,每次数据更新都可能导致所有图表重新计算和渲染。

优化解决方案:

// 优化后的大数据图表渲染
function DashboardPage() {
  const [salesData, setSalesData] = useState([]);
  const [filterDate, setFilterDate] = useState('last30days');
  
  // 获取销售数据
  useEffect(() => {
    fetchSalesData(filterDate).then(data => setSalesData(data));
  }, [filterDate]);
  
  // 使用 useMemo 记忆化数据处理结果
  const processedData = useMemo(() => {
    console.time('数据处理时间');
    const result = salesData.map(item => ({
      ...item,
      revenue: calculateRevenue(item),
      growth: calculateGrowth(item)
    }));
    console.timeEnd('数据处理时间');
    return result;
  }, [salesData]); // 只在 salesData 变化时重新计算
  
  // 记忆化各图表的数据聚合
  const pieChartData = useMemo(() => 
    aggregateForPieChart(processedData), 
  [processedData]);
  
  const barChartData = useMemo(() => 
    aggregateForBarChart(processedData), 
  [processedData]);
  
  const lineChartData = useMemo(() => 
    aggregateForLineChart(processedData), 
  [processedData]);
  
  // 使用 React.memo 优化各图表组件
  const MemoizedPieChart = React.memo(PieChart);
  const MemoizedBarChart = React.memo(BarChart);
  const MemoizedLineChart = React.memo(LineChart);
  
  return (
    <div className="dashboard">
      <DateFilter value={filterDate} onChange={setFilterDate} />
      
      <div className="charts-grid">
        <MemoizedPieChart data={pieChartData} />
        <MemoizedBarChart data={barChartData} />
        <MemoizedLineChart data={lineChartData} />
        <MemoizedDataTable data={processedData.slice(0, 100)} />
      </div>
    </div>
  );
}

效果对比: 优化前每次筛选条件变更或父组件渲染都会重新计算所有数据和重绘所有图表(350ms+)。优化后只在数据实际变化时才重新计算(首次可能仍需350ms,但后续筛选相同日期范围时几乎为0ms)。

场景二:WebSocket 交易所订单簿

交易所订单簿通过 WebSocket 接收高频更新(每秒可能有数十次),每次更新都会触发组件重新渲染。

优化解决方案:

// 优化后的订单簿
function OrderBook() {
  const [orderBook, setOrderBook] = useState({ bids: [], asks: [] });
  const [selectedPair, setSelectedPair] = useState('BTC-USDT');
  
  // 使用 useRef 存储最新数据,避免不必要的渲染
  const orderBookRef = useRef(orderBook);
  
  // 使用节流控制更新频率
  const updateOrderBook = useCallback(throttle((newData) => {
    setOrderBook(newData);
  }, 100), []); // 每100ms最多更新一次UI
  
  // 连接 WebSocket
  useEffect(() => {
    const ws = new WebSocket('wss://exchange.example.com/ws');
    
    ws.onopen = () => {
      ws.send(JSON.stringify({
        method: 'subscribe',
        params: [`orderbook.${selectedPair}`]
      }));
    };
    
    // 高频数据更新
    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.channel === `orderbook.${selectedPair}`) {
        // 更新 ref 立即反映最新数据(不触发渲染)
        orderBookRef.current = data.data;
        // 节流更新 state 以控制渲染频率
        updateOrderBook(data.data);
      }
    };
    
    return () => {
      ws.close();
      updateOrderBook.cancel(); // 清理节流函数
    };
  }, [selectedPair, updateOrderBook]);
  
  // 记忆化计算值
  const bookStats = useMemo(() => {
    console.time('订单簿计算');
    const totalBidVolume = orderBook.bids.reduce((sum, [_, amount]) => sum + amount, 0);
    const totalAskVolume = orderBook.asks.reduce((sum, [_, amount]) => sum + amount, 0);
    const spreadValue = orderBook.asks[0]?.[0] - orderBook.bids[0]?.[0] || 0;
    const spreadPercentage = (spreadValue / orderBook.bids[0]?.[0]) * 100 || 0;
    console.timeEnd('订单簿计算');
    
    return {
      totalBidVolume,
      totalAskVolume,
      spreadValue,
      spreadPercentage
    };
  }, [orderBook]);
  
  // 记忆化子组件
  const MemoizedOrderTable = React.memo(OrderTable);
  const MemoizedDepthChart = React.memo(DepthChart);
  
  return (
    <div className="order-book">
      <h2>{selectedPair} Order Book</h2>
      <div className="stats">
        <div>Spread: {bookStats.spreadValue.toFixed(2)} ({bookStats.spreadPercentage.toFixed(2)}%)</div>
        <div>Bid Volume: {bookStats.totalBidVolume.toFixed(2)}</div>
        <div>Ask Volume: {bookStats.totalAskVolume.toFixed(2)}</div>
      </div>
      
      <div className="book-tables">
        <MemoizedOrderTable type="asks" orders={orderBook.asks.slice(0, 15)} />
        <MemoizedOrderTable type="bids" orders={orderBook.bids.slice(0, 15)} />
      </div>
      
      <MemoizedDepthChart bids={orderBook.bids} asks={orderBook.asks} />
    </div>
  );
}

效果对比: 优化前每次 WebSocket 消息(可能每秒数十次)都会触发完整渲染,导致页面卡顿和高 CPU 使用率。优化后通过节流控制渲染频率(每秒最多10次),并使用 useMemo 和 React.memo 避免重复计算和不必要的子组件渲染,大幅提升了交互流畅度。

这两个场景都是前端开发中常见的性能瓶颈。通过适当的测量工具(如 console.time、React Profiler)识别问题,再结合 useMemo、React.memo、useCallback 和节流等技术,可以有效解决高频渲染带来的性能问题。

🔥 常见面试题目

(一)React.memo、useMemo 和 useCallback 的区别?

解答:

  • 1. React.memo:高阶组件,记忆化整个 React 组件,当 props 不变时跳过渲染。
  • 2. useMemo:Hook,记忆化计算结果(可以是任何值),依赖项不变时不重新计算。
  • 3. useCallback:Hook,记忆化函数引用,依赖项不变时不创建新函数。

关键区别:React.memo 优化整个组件渲染;useMemo 优化值计算;useCallback 优化函数引用。

(二)React.memo 使用时机和注意事项?

解答:

使用时机:

  • 1. 纯展示组件且经常重新渲染
  • 2. 组件接收相同 props 长时间不变
  • 3. 渲染开销大的组件

注意事项:

// 问题:传入匿名函数,每次都是新引用
function Parent() {
  return <MemoChild onClick={() => console.log('click')} />;
}

// 解决:使用 useCallback 记忆化函数
function Parent() {
  const handleClick = useCallback(() => console.log('click'), []);
  return <MemoChild onClick={handleClick} />;
}

(三)为什么以下代码 memo 不生效?如何修复?

解答:

function App() {
  const [count, setCount] = useState(0);
  
  // 问题:options 每次渲染都是新对象
  const options = { theme: 'dark' };
  
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <MemoizedComponent options={options} />
    </>
  );
}

// 修复方案:
function App() {
  const [count, setCount] = useState(0);
  
  // 解决:使用 useMemo 记忆化对象
  const options = useMemo(() => ({ theme: 'dark' }), []);
  
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <MemoizedComponent options={options} />
    </>
  );
}

(四)如何判断是否需要使用 memo、useMemo 或 useCallback?

解答:

判断标准:

  1. 1. 先测量性能,确认是否真有问题
  2. 2. 组件是否频繁渲染但 props 很少改变
  3. 3. 计算是否复杂或处理大数据
  4. 4. 函数是否传给 memo 组件或用作其他 hook 的依赖
// 实用的性能测量方法
import { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    const start = performance.now();
    return () => {
      const end = performance.now();
      console.log(`渲染耗时: ${end - start}ms`);
    };
  });
  
  // 组件内容...
}