魯斯前端布魯斯前端

文章中英模式

常見的前端面試題目 - 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`);
    };
  });
  
  // 組件內容...
}