「網站好慢!」客戶的這句話,是不是讓你心頭一緊?
昨天還好好的 Todo App,今天突然變得像烏龜一樣慢。使用者開始抱怨,老闆開始關心,而你卻不知道問題出在哪裡。這就是沒有效能測試的痛!
今天,我們要為 React 應用加上效能測試的防護網,讓效能問題無所遁形!
基礎測試 → Kata 實戰 → 框架特色 → 整合部署
1-10 11-17 18-27 28-30
↓ 我們在這裡(Day 26)
[==========================================>.......]
經過 25 天的學習,我們已經掌握了單元測試、整合測試、E2E 測試,今天要補上最後一塊拼圖:效能測試!
效能問題往往是:
思考一下:你的 Todo App 在以下情況下會不會變慢?
// 建立 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()
})
})
// 建立 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)
})
})
// 建立 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)
})
})
// 更新 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)
})
})
// 更新 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)
})
})
function testReasonablePerformance() {
// ❌ 太嚴格
expect(responseTime).toBeLessThan(0.001)
// ✅ 合理的期望
expect(responseTime).toBeLessThan(100) // 一般操作
expect(responseTime).toBeLessThan(20) // 快取查詢
}
import { afterEach } from 'vitest'
afterEach(() => {
// 清理可能影響效能的副作用
jest.clearAllMocks()
localStorage.clear()
})
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)]
}
}
// ❌ 錯誤:多次渲染
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])
}
今天我們學會了:
效能優化的黃金法則:
效能測試不是奢侈品,而是必需品。當你的應用成長到一定規模,效能問題會變成最大的技術債。透過 TDD 的方式持續監控效能,可以在問題變嚴重之前就發現並解決。
試試看這些進階效能測試:
嘗試為你的專案加入效能測試:
記住:「快」不是偶然,是刻意設計和持續測試的結果!
明天是 Day 27,我們將進入測試的最後階段,完成 TDD 旅程的最後一塊拼圖!