iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0

「網站好慢!」客戶的這句話,是不是讓你心頭一緊?

昨天還好好的 Todo App,今天突然變得像烏龜一樣慢。使用者開始抱怨,老闆開始關心,而你卻不知道問題出在哪裡。這就是沒有效能測試的痛!

今天,我們要為 React 應用加上效能測試的防護網,讓效能問題無所遁形!

我們的 TDD 旅程

基礎測試 → Kata 實戰 → 框架特色 → 整合部署
  1-10        11-17       18-27       28-30

                            ↓ 我們在這裡(Day 26)
[==========================================>.......]

經過 25 天的學習,我們已經掌握了單元測試、整合測試、E2E 測試,今天要補上最後一塊拼圖:效能測試!

效能測試的必要性

效能問題往往是:

  • 隱蔽的:功能正常,但速度慢
  • 漸進的:一點一點變慢,直到崩潰
  • 昂貴的:發現越晚,修復成本越高

小挑戰

思考一下:你的 Todo App 在以下情況下會不會變慢?

  1. 有 1000 個待辦事項時
  2. 快速連續輸入文字時
  3. 頻繁切換篩選條件時

React 效能測試工具

React DevTools Profiler

// 建立 tests/day26/performance/performance.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render } from '@testing-library/react'
import { Profiler } from 'react'

describe('Performance Testing', () => {
  it('measures render performance', () => {
    const onRender = vi.fn()
    
    render(
      <Profiler id="TodoList" onRender={onRender}>
        <TodoList />
      </Profiler>
    )
    
    expect(onRender).toHaveBeenCalled()
    const [, , actualDuration] = onRender.mock.calls[0]
    expect(actualDuration).toBeLessThan(50)
  })
})

重渲染效能測試

最常見的效能問題就是不必要的重渲染:

// 建立 tests/day26/performance/rerender.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { useState } from 'react'

function UnoptimizedTodoItem({ todo, onToggle }: any) {
  console.log(`Rendering todo: ${todo.id}`)
  return (
    <input
      type="checkbox"
      checked={todo.completed}
      onChange={() => onToggle(todo.id)}
    />
  )
}

describe('Re-render Performance', () => {
  it('detects unnecessary re-renders', () => {
    const consoleSpy = vi.spyOn(console, 'log')
    
    function TestApp() {
      const [todos, setTodos] = useState([
        { id: 1, title: 'Task 1', completed: false },
        { id: 2, title: 'Task 2', completed: false }
      ])
      
      return (
        <div>
          {todos.map(todo => (
            <UnoptimizedTodoItem
              key={todo.id}
              todo={todo}
              onToggle={(id: number) => {
                setTodos(prev => prev.map(t => 
                  t.id === id ? { ...t, completed: !t.completed } : t
                ))
              }}
            />
          ))}
        </div>
      )
    }
    
    render(<TestApp />)
    consoleSpy.mockClear()
    
    fireEvent.click(screen.getAllByRole('checkbox')[0])
    expect(consoleSpy.mock.calls.length).toBe(2) // 所有項目都重渲染
    
    consoleSpy.mockRestore()
  })
})

優化與測試

使用 React.memo 優化

// 建立 tests/day26/performance/optimized.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { useState, memo, useCallback } from 'react'

const OptimizedTodoItem = memo(function TodoItem({ todo, onToggle }: any) {
  console.log(`Rendering todo: ${todo.id}`)
  return (
    <input
      type="checkbox"
      checked={todo.completed}
      onChange={() => onToggle(todo.id)}
    />
  )
})

describe('Optimized Performance', () => {
  it('prevents unnecessary re-renders with memo', () => {
    const consoleSpy = vi.spyOn(console, 'log')
    
    function TestApp() {
      const [todos, setTodos] = useState([
        { id: 1, title: 'Task 1', completed: false },
        { id: 2, title: 'Task 2', completed: false }
      ])
      
      const handleToggle = useCallback((id: number) => {
        setTodos(prev => prev.map(todo => 
          todo.id === id ? { ...todo, completed: !todo.completed } : todo
        ))
      }, [])
      
      return (
        <div>
          {todos.map(todo => (
            <OptimizedTodoItem
              key={todo.id}
              todo={todo}
              onToggle={handleToggle}
            />
          ))}
        </div>
      )
    }
    
    render(<TestApp />)
    consoleSpy.mockClear()
    
    fireEvent.click(screen.getAllByRole('checkbox')[0])
    expect(consoleSpy.mock.calls.length).toBe(1) // 只有被修改的項目重渲染
    
    consoleSpy.mockRestore()
  })
})

大量資料效能測試

// 建立 tests/day26/performance/large-dataset.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { useState, useMemo } from 'react'

describe('Large Dataset Performance', () => {
  it('handles 1000 items efficiently', () => {
    function TodoListWithFilter() {
      const [todos] = useState(() => 
        Array.from({ length: 1000 }, (_, i) => ({
          id: i + 1,
          title: `Task ${i + 1}`,
          completed: i % 3 === 0
        }))
      )
      
      const filtered = useMemo(() => todos.filter(t => t.completed), [todos])
      return <div>{filtered.length} items</div>
    }
    
    const startTime = performance.now()
    render(<TodoListWithFilter />)
    expect(performance.now() - startTime).toBeLessThan(100)
  })
})

虛擬化測試

// 建立 tests/day26/performance/virtualization.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
// 註:需要先安裝 npm install react-window
// @ts-ignore
import { FixedSizeList } from 'react-window'

describe('Virtualization Performance', () => {
  it.skip('renders 10000 items efficiently', () => {  // 使用 skip 跳過測試
    const todos = Array.from({ length: 10000 }, (_, i) => ({
      id: i + 1,
      title: `Task ${i + 1}`
    }))
    
    const VirtualizedList = () => (
      <FixedSizeList height={600} itemCount={todos.length} itemSize={50} width="100%">
        {({ index, style }) => (
          <div style={style} data-testid={`todo-${todos[index].id}`}>
            {todos[index].title}
          </div>
        )}
      </FixedSizeList>
    )
    
    const startTime = performance.now()
    render(<VirtualizedList />)
    expect(performance.now() - startTime).toBeLessThan(100)
    expect(screen.getAllByTestId(/^todo-/).length).toBeLessThan(20)
  })
})

實戰:Todo App 效能測試

步驟 1:基本效能測試

// 建立 tests/day26/performance/todo-app.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TodoApp } from '../../../src/todo/TodoApp'

describe('Todo App Performance', () => {
  it('handles rapid input without lag', () => {
    render(<TodoApp />)
    const input = screen.getByPlaceholderText('Add a new todo')
    
    const inputTimes: number[] = []
    for (let i = 0; i < 5; i++) {
      const startTime = performance.now()
      fireEvent.change(input, { target: { value: `Task ${i}` } })
      inputTimes.push(performance.now() - startTime)
    }
    
    const avgTime = inputTimes.reduce((a, b) => a + b) / inputTimes.length
    expect(avgTime).toBeLessThan(5)
  })
  
  it('filters large lists efficiently', () => {
    const todos = Array.from({ length: 50 }, (_, i) => ({
      id: i,
      title: `Task ${i}`,
      completed: i % 2 === 0
    }))
    
    render(<TodoApp initialTodos={todos} />)
    
    const startTime = performance.now()
    fireEvent.click(screen.getByText('Active'))
    const filterTime = performance.now() - startTime
    
    expect(filterTime).toBeLessThan(20)
  })
})

步驟 2:記憶體使用測試

// 更新 tests/day26/performance/memory.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'

describe('Memory Performance', () => {
  it('batch processing stays within memory limits', () => {
    const TestComponent = () => {
      const todos = Array.from({ length: 1000 }, (_, i) => ({
        id: i,
        title: `Task ${i}`,
        completed: false
      }))
      
      return (
        <div>
          {todos.map(todo => (
            <div key={todo.id}>{todo.title}</div>
          ))}
        </div>
      )
    }
    
    const memBefore = (performance as any).memory?.usedJSHeapSize || 0
    render(<TestComponent />)
    const memAfter = (performance as any).memory?.usedJSHeapSize || 0
    
    const memUsedMB = (memAfter - memBefore) / 1024 / 1024
    expect(memUsedMB).toBeLessThan(50)
  })
})

步驟 3:並發測試

// 更新 tests/day26/performance/concurrent.test.tsx
import { describe, it, expect } from 'vitest'
import { render, fireEvent, screen } from '@testing-library/react'

describe('Concurrent Operations', () => {
  it('handles concurrent updates gracefully', () => {
    const TestApp = () => {
      const [count, setCount] = useState(0)
      
      const handleClick = () => {
        // 模擬並發更新
        for (let i = 0; i < 10; i++) {
          setCount(prev => prev + 1)
        }
      }
      
      return (
        <div>
          <button onClick={handleClick}>Update</button>
          <span>{count}</span>
        </div>
      )
    }
    
    const results: number[] = []
    render(<TestApp />)
    
    for (let i = 0; i < 10; i++) {
      const startTime = performance.now()
      fireEvent.click(screen.getByText('Update'))
      results.push(performance.now() - startTime)
    }
    
    const avgTime = results.reduce((a, b) => a + b) / results.length
    const maxTime = Math.max(...results)
    
    expect(avgTime).toBeLessThan(10)
    expect(maxTime).toBeLessThan(20)
  })
})

效能測試最佳實踐

1. 設定合理的基準

function testReasonablePerformance() {
  // ❌ 太嚴格
  expect(responseTime).toBeLessThan(0.001)
  
  // ✅ 合理的期望
  expect(responseTime).toBeLessThan(100)  // 一般操作
  expect(responseTime).toBeLessThan(20)   // 快取查詢
}

2. 隔離測試環境

import { afterEach } from 'vitest'

afterEach(() => {
  // 清理可能影響效能的副作用
  jest.clearAllMocks()
  localStorage.clear()
})

3. 使用統計方法

function measurePerformance(fn: () => void, runs = 10) {
  const times: number[] = []
  for (let i = 0; i < runs; i++) {
    const start = performance.now()
    fn()
    times.push(performance.now() - start)
  }
  
  return {
    min: Math.min(...times),
    max: Math.max(...times),
    mean: times.reduce((a, b) => a + b) / times.length,
    median: times.sort()[Math.floor(times.length / 2)]
  }
}

常見效能陷阱

N+1 渲染問題

// ❌ 錯誤:多次渲染
function BadList() {
  const [items, setItems] = useState([])
  
  items.forEach(item => {
    // 每個項目都觸發重渲染
    fetchDetails(item.id)
  })
}

// ✅ 正確:批次處理
function GoodList() {
  const [items, setItems] = useState([])
  
  useEffect(() => {
    // 一次取得所有資料
    fetchAllDetails(items.map(i => i.id))
  }, [items])
}

今日成果總結

今天我們學會了:

已掌握

  1. React Profiler - 測量元件渲染效能
  2. 重渲染檢測 - 找出不必要的重渲染
  3. 效能優化技巧 - memo、useMemo、useCallback
  4. 虛擬化測試 - 處理大量資料
  5. 記憶體測試 - 監控記憶體使用
  6. 並發測試 - 處理同時操作

關鍵要點

  • 效能測試要設定基準線
  • 先測量,再優化
  • 不要過早優化
  • 關注使用者體驗指標

實戰心得

效能優化的黃金法則:

  1. 測量先於優化 - 沒有數據就沒有優化
  2. 漸進式改進 - 一步步找出瓶頸
  3. 使用者優先 - 關注實際體驗

效能測試不是奢侈品,而是必需品。當你的應用成長到一定規模,效能問題會變成最大的技術債。透過 TDD 的方式持續監控效能,可以在問題變嚴重之前就發現並解決。

小挑戰

試試看這些進階效能測試:

  1. 測試列表虛擬化:實作 react-window 測試 10000 個項目
  2. 測試輸入防抖:加入 debounce 並測試效能提升
  3. 測試快取效果:實作 LRU 快取並測試命中率

下一步挑戰

嘗試為你的專案加入效能測試:

  • 找出最慢的元件
  • 設定效能基準線
  • 逐步優化並用測試驗證改善

記住:「快」不是偶然,是刻意設計和持續測試的結果!

下一步

明天是 Day 27,我們將進入測試的最後階段,完成 TDD 旅程的最後一塊拼圖!


上一篇
Day 25 - 整合測試 🔗
下一篇
Day 27 - E2E 測試預覽 🎬
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言