系列文章: 前端工程師的 Modern Web 實踐之道 - Day 13
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆
在前一篇文章中,我們探討了響應式設計的現代化技術。今天我們將深入使用者體驗最佳化的核心議題 - 如何透過技術手段提升使用者感知效能和互動品質。這不只是技術問題,更是產品成功的關鍵因素。
想像這個場景:你負責開發一個企業級安全事件監控系統,需要處理數千筆即時事件資料。使用者抱怨:
這些痛點不僅影響使用者體驗,也反映了程式碼架構問題。今天我們將透過真實專案案例,學習如何系統性地解決這些問題。
讓我分享一個來自企業級安全監控系統的真實案例。原始程式碼存在嚴重的重複問題:
// ❌ 問題代碼: useSequentialAntiMalwareEvents.js (249 行)
export const useSequentialAntiMalwareEvents = (initialFilters = {}) => {
const [allEvents, setAllEvents] = useState([]);
const [currentScanRange, setCurrentScanRange] = useState(null);
const [loadingState, setLoadingState] = useState({
isLoading: false,
message: '準備搜尋反惡意軟體事件...',
hasMorePages: false,
isComplete: false,
currentPage: 0,
});
const [searchAntiMalwareEvents, { isFetching }] = useLazySearchAntiMalwareEventsQuery();
// ... 接下來 240 行幾乎相同的邏輯
const fetchNextBatch = useCallback(async () => {
// 複雜的分頁載入邏輯
}, [/* 大量依賴 */]);
return {
allEvents,
loadingState,
startSearch,
stopSearch,
// ...
};
};
問題嚴重性:
// 問題: 使用 useState 管理複雜狀態
const [loadingState, setLoadingState] = useState({
isLoading: false,
hasMorePages: false,
currentPage: 0,
// ... 多個相關狀態
});
// 每次更新都要:
setLoadingState(prev => ({
...prev,
isLoading: true,
message: `載入第 ${currentPage} 頁...`,
currentPage: prev.currentPage + 1
}));
問題點:
// 使用者看不到進度
setLoadingState({ isLoading: true });
await fetchData(); // 黑箱等待
setLoadingState({ isLoading: false });
使用者感知:
// ✅ 改進方案: useSequentialEvents.js (核心邏輯)
// 定義清晰的狀態結構
const initialState = {
events: [],
scanRange: null,
currentPage: 0,
isLoading: false,
isComplete: false,
hasMorePages: false,
error: null,
};
// 使用 Reducer 管理狀態轉換
function eventsReducer(state, action) {
switch (action.type) {
case 'START':
return { ...initialState, isLoading: true };
case 'BATCH_LOADED':
return {
...state,
events: [...state.events, ...action.events],
scanRange: action.scanRange || state.scanRange,
currentPage: state.currentPage + 1,
hasMorePages: !!action.nextToken,
isLoading: false,
};
case 'COMPLETE':
return {
...state,
isComplete: true,
hasMorePages: false,
isLoading: false
};
case 'ERROR':
return {
...state,
error: action.error,
isLoading: false,
hasMorePages: false
};
case 'RESET':
return initialState;
default:
return state;
}
}
技術優勢:
/**
* 通用的序列事件載入 Hook
* @param {Object} params - Hook 參數
* @param {Function} params.queryHook - RTKQ lazy query hook
* @param {Object} params.config - 載入設定
*/
export const useSequentialEvents = ({ queryHook, config = {} }) => {
const [state, dispatch] = useReducer(eventsReducer, initialState);
const [triggerQuery, { isFetching }] = queryHook();
// 使用 ref 管理控制狀態 (避免閉包問題)
const controlRef = useRef({
nextToken: null,
filters: {},
active: false,
timeoutId: null,
});
// 清理函式 - 確保沒有記憶體洩漏
const cleanup = useCallback(() => {
controlRef.current.active = false;
if (controlRef.current.timeoutId) {
clearTimeout(controlRef.current.timeoutId);
controlRef.current.timeoutId = null;
}
}, []);
// 批次載入邏輯
const fetchBatch = useCallback(async () => {
if (!controlRef.current.active) return;
try {
const response = await triggerQuery({
...controlRef.current.filters,
nextToken: controlRef.current.nextToken,
searchTimePerBatch: config.searchTimePerBatch || 20,
eventCountPerBatch: config.eventCountPerBatch || 500,
maxEventCount: config.maxEventCount || 1000,
}).unwrap();
if (!response?.events) {
dispatch({ type: 'COMPLETE' });
cleanup();
return;
}
// 分發批次載入成功事件
dispatch({
type: 'BATCH_LOADED',
events: response.events,
scanRange: response.scanRange,
nextToken: response.nextToken,
});
// 繼續載入下一批 (如果有)
if (response.nextToken && controlRef.current.active) {
controlRef.current.nextToken = response.nextToken;
// 500ms 延遲避免 API 過載
controlRef.current.timeoutId = setTimeout(fetchBatch, 500);
} else {
dispatch({ type: 'COMPLETE' });
cleanup();
}
} catch (err) {
dispatch({ type: 'ERROR', error: err });
cleanup();
}
}, [triggerQuery, config, cleanup]);
// 公開 API
const start = useCallback((filters = {}) => {
cleanup();
controlRef.current = {
nextToken: null,
filters,
active: true,
timeoutId: null,
};
dispatch({ type: 'START' });
controlRef.current.timeoutId = setTimeout(fetchBatch, 100);
}, [fetchBatch, cleanup]);
const stop = useCallback(() => {
cleanup();
dispatch({ type: 'COMPLETE' });
}, [cleanup]);
const reset = useCallback(() => {
cleanup();
dispatch({ type: 'RESET' });
}, [cleanup]);
return {
allEvents: state.events,
currentScanRange: state.scanRange,
loadingState: {
isLoading: state.isLoading,
hasMorePages: state.hasMorePages,
isComplete: state.isComplete,
currentPage: state.currentPage,
},
error: state.error,
isLoading: isFetching || state.isLoading,
startSearch: start,
stopSearch: stop,
clearData: reset,
stats: {
totalEvents: state.events.length,
totalPages: state.currentPage,
isComplete: state.isComplete,
},
};
};
設計亮點:
狀態與控制分離
state
: 使用 useReducer
管理可預測狀態controlRef
: 使用 useRef
管理命令式控制邏輯記憶體洩漏防護
錯誤處理完整
// ✅ 各事件類型的適配層 (每個只需 20 行!)
// useSequentialAntiMalwareEvents.js
import { useLazySearchAntiMalwareEventsQuery } from 'src/rtkqApis/antiMalwareRtkqApi';
import { useSequentialEvents } from './useSequentialEvents';
/**
* 反惡意軟體事件的序列載入 Hook
* @param {Object} initialFilters - 初始過濾條件
*/
export const useSequentialAntiMalwareEvents = (initialFilters = {}) => {
return useSequentialEvents({
queryHook: useLazySearchAntiMalwareEventsQuery,
config: {
searchTimePerBatch: 20,
eventCountPerBatch: 500,
maxEventCount: 1000,
},
});
};
export default useSequentialAntiMalwareEvents;
重構成果對比:
指標 | 重構前 | 重構後 | 改善 |
---|---|---|---|
總程式碼行數 | 3,984 行 | ~300 行 | -92% |
核心邏輯 | 16 份重複 | 1 份共用 | -94% |
適配層程式碼 | N/A | 16 × 20 行 | 乾淨簡潔 |
維護成本 | 高 (改 16 次) | 低 (改 1 次) | -94% |
// ✅ 在 UI 層提供清晰的載入狀態回饋
function EventsTable() {
const {
allEvents,
loadingState,
startSearch,
stopSearch,
stats,
} = useSequentialAntiMalwareEvents();
return (
<div>
{/* 載入狀態指示器 */}
{loadingState.isLoading && (
<LoadingProgress
currentPage={loadingState.currentPage}
totalEvents={stats.totalEvents}
hasMore={loadingState.hasMorePages}
/>
)}
{/* 完成狀態 */}
{loadingState.isComplete && !loadingState.isLoading && (
<CompletionBanner
totalEvents={stats.totalEvents}
totalPages={stats.totalPages}
/>
)}
{/* 資料表格 */}
<DataGrid
data={allEvents}
loading={loadingState.isLoading}
/>
{/* 控制按鈕 */}
<div>
<Button onClick={() => startSearch(filters)}>
搜尋
</Button>
{loadingState.hasMorePages && (
<Button onClick={stopSearch} variant="secondary">
停止載入
</Button>
)}
</div>
</div>
);
}
// ✅ 分批顯示資料,避免長時間等待
function LoadingProgress({ currentPage, totalEvents, hasMore }) {
return (
<div className="loading-progress">
<Spinner size="small" />
<div className="progress-info">
<strong>正在載入第 {currentPage} 頁</strong>
<span>已載入 {totalEvents.toLocaleString()} 筆事件</span>
{hasMore && <span>繼續載入中...</span>}
</div>
</div>
);
}
使用者感知改善:
體驗指標 | 改善前 | 改善後 |
---|---|---|
首次回饋時間 | 10 秒 (全部載入完) | 0.5 秒 (第一批資料) |
載入狀態可見性 | ❌ 無提示 | ✅ 即時進度 |
使用者控制力 | ❌ 無法中斷 | ✅ 隨時停止 |
錯誤處理 | ❌ 直接失敗 | ✅ 友善提示 |
// ✅ 監控 Core Web Vitals
import { onCLS, onFID, onLCP } from 'web-vitals';
function reportWebVitals() {
// Largest Contentful Paint (最大內容繪製)
onLCP((metric) => {
console.log('LCP:', metric.value);
// 目標: < 2.5 秒
if (metric.value > 2500) {
analytics.track('performance_issue', {
metric: 'LCP',
value: metric.value,
page: window.location.pathname,
});
}
});
// First Input Delay (首次輸入延遲)
onFID((metric) => {
console.log('FID:', metric.value);
// 目標: < 100 毫秒
});
// Cumulative Layout Shift (累積佈局偏移)
onCLS((metric) => {
console.log('CLS:', metric.value);
// 目標: < 0.1
});
}
// 1. 程式碼分割 (Code Splitting)
const EventViewer = lazy(() => import('./features/EventViewer'));
function App() {
return (
<Suspense fallback={<AppLoading />}>
<EventViewer />
</Suspense>
);
}
// 2. 路由層級懶載入
const routes = [
{
path: '/anti-malware',
component: lazy(() => import('./containers/AntiMalware')),
},
{
path: '/firewall',
component: lazy(() => import('./containers/Firewall')),
},
];
// 3. 資源預載入 (Preload)
<link
rel="preload"
href="/api/user-profile"
as="fetch"
crossOrigin="anonymous"
/>
// 1. 虛擬化長列表
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedEventList({ events }) {
const parentRef = useRef();
const virtualizer = useVirtualizer({
count: events.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // 每行高度
overscan: 5, // 預渲染行數
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<EventRow
key={virtualRow.index}
event={events[virtualRow.index]}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
/>
))}
</div>
</div>
);
}
// 2. 記憶化計算
const processedEvents = useMemo(() => {
return events.map(event => ({
...event,
formattedTime: formatTimestamp(event.eventTime),
severityLevel: getSeverityLevel(event.severity),
}));
}, [events]);
// 3. 防抖搜尋
const debouncedSearch = useMemo(
() => debounce((query) => {
startSearch({ query });
}, 300),
[startSearch]
);
src/features/EventViewer/
├── hooks/
│ ├── useSequentialEvents.js # 核心邏輯 (通用)
│ ├── useSequentialAntiMalwareEvents.js # 適配層
│ ├── useSequentialFirewallEvents.js # 適配層
│ └── ... # 其他 14 個適配層
├── components/
│ ├── EventsTable.jsx # 資料表格
│ ├── LoadingProgress.jsx # 載入進度
│ └── FilterPanel.jsx # 過濾面板
└── containers/
└── AntiMalware/
└── AntiMalware.jsx # 業務容器
// containers/AntiMalware/AntiMalware.jsx
import React, { useState, useCallback } from 'react';
import { useSequentialAntiMalwareEvents } from '../../hooks/useSequentialAntiMalwareEvents';
import EventsTable from '../../components/EventsTable';
import LoadingProgress from '../../components/LoadingProgress';
import FilterPanel from '../../components/FilterPanel';
function AntiMalware() {
const [filters, setFilters] = useState({});
const {
allEvents,
loadingState,
error,
startSearch,
stopSearch,
clearData,
stats,
} = useSequentialAntiMalwareEvents();
const handleSearch = useCallback(() => {
clearData(); // 清除舊資料
startSearch(filters);
}, [filters, startSearch, clearData]);
const handleStop = useCallback(() => {
stopSearch();
}, [stopSearch]);
return (
<div className="anti-malware-container">
<h1>反惡意軟體事件監控</h1>
{/* 過濾面板 */}
<FilterPanel
filters={filters}
onChange={setFilters}
onSearch={handleSearch}
disabled={loadingState.isLoading}
/>
{/* 錯誤提示 */}
{error && (
<Alert variant="error">
載入失敗: {error.message}
</Alert>
)}
{/* 載入進度 */}
{loadingState.isLoading && (
<LoadingProgress
currentPage={loadingState.currentPage}
totalEvents={stats.totalEvents}
hasMore={loadingState.hasMorePages}
onStop={handleStop}
/>
)}
{/* 完成提示 */}
{loadingState.isComplete && !loadingState.isLoading && (
<Alert variant="success">
✅ 載入完成! 共 {stats.totalEvents.toLocaleString()} 筆事件
(分 {stats.totalPages} 頁載入)
</Alert>
)}
{/* 資料表格 */}
<EventsTable
events={allEvents}
loading={loadingState.isLoading}
emptyMessage={
stats.totalEvents === 0 && loadingState.isComplete
? '查無資料'
: '請設定過濾條件並開始搜尋'
}
/>
{/* 統計資訊 */}
<div className="stats-footer">
<span>總筆數: {stats.totalEvents.toLocaleString()}</span>
<span>載入頁數: {stats.totalPages}</span>
{loadingState.hasMorePages && (
<span className="more-indicator">
⏳ 更多資料載入中...
</span>
)}
</div>
</div>
);
}
export default AntiMalware;
核心概念: 使用者體驗最佳化是技術與設計的結合,需要從效能、互動、回饋三個層面系統性思考。
關鍵技術:
useReducer
建立可預測的狀態管理實踐要點:
狀態管理選擇
// ✅ 複雜狀態用 useReducer
const [state, dispatch] = useReducer(reducer, initialState);
// ✅ 簡單狀態用 useState
const [isOpen, setIsOpen] = useState(false);
漸進式回饋
// ✅ 分批顯示結果
dispatch({ type: 'BATCH_LOADED', events });
// ❌ 全部載入完才顯示
// setEvents(await loadAll());
效能監控
// ✅ 量化指標追蹤
onLCP((metric) => analytics.track('LCP', metric.value));
// ❌ 憑感覺判斷
過早最佳化
// ❌ 所有東西都虛擬化
<VirtualList items={5} /> // 只有 5 個項目
// ✅ 超過 100 個才虛擬化
{items.length > 100 ? <VirtualList /> : <SimpleList />}
忽略錯誤狀態
// ❌ 沒有錯誤處理
const data = await fetch(url).then(r => r.json());
// ✅ 完整錯誤處理
try {
const data = await fetch(url).then(r => r.json());
} catch (err) {
dispatch({ type: 'ERROR', error: err });
}
記憶體洩漏
// ❌ 忘記清理 timer
useEffect(() => {
const id = setTimeout(fetchData, 500);
}, []);
// ✅ 正確清理
useEffect(() => {
const id = setTimeout(fetchData, 500);
return () => clearTimeout(id);
}, []);
架構設計: 如果需要支援 20 種以上的事件類型,如何設計更靈活的適配機制?
效能權衡: 虛擬化列表雖然解決了大量資料渲染問題,但會失去原生瀏覽器的搜尋功能 (Ctrl+F),如何取捨?
實戰挑戰: 設計一個支援以下功能的通用載入 Hook: