Michael Lo

Command Palette

Search for a command to run...

Blog
PreviousNext

Autocomplete System Design

--

使用 RADIO Pattern 設計一個現代化的 Autocomplete 元件,涵蓋需求分析、架構設計、資料模型、API 介面與效能優化

Autocomplete 是前端系統設計面試中最常見的題目之一,被 Google、Meta、Apple、Uber 等大廠頻繁考察。本文將使用 RADIO Pattern 系統性地拆解這個問題。

TL;DR

  1. 先問需求 — 結果類型?裝置?離線?
  2. 三層架構 — View → Controller (Hook) → Service (Cache + Network)
  3. 關鍵優化 — Debounce 300ms、LRU Cache、Race Condition 處理
  4. 別忘 A11yrole="combobox"aria-expanded、鍵盤導航

題目

Design an autocomplete UI component that allows users to enter a search term into a text box, a list of search results appear in a popup and the user can select a result.

難度:Medium ~ Hard

你可能在這些地方看過這個元件:

產品特色結果類型
Google Search即時搜尋建議、歷史紀錄純文字
YouTube影片預覽縮圖文字 + 圖片
Facebook好友、粉專、社團Rich Media
GitHubRepo、Issue、User混合類型

YouTube Search Bar AutoComplete


RADIO Pattern 快速導覽

階段核心問題產出
Requirements要解決什麼問題?功能清單、限制條件
Architecture系統長什麼樣?架構圖、元件拆分
Data Model資料怎麼流動?State、Cache、Props
Interface怎麼使用這個元件?API 設計、使用範例
Optimizations如何做得更好?效能、UX、A11y

R - Requirements(需求釐清)

面試時該問的問題

在開始設計之前,主動向面試官確認需求是展現專業的第一步:

問題為什麼重要可能答案
支援哪些結果類型?影響 UI 複雜度純文字 / Rich Media / 混合
目標裝置是?影響互動設計Desktop only / 全裝置
需要離線支援嗎?影響快取策略是 / 否
搜尋是 client 還是 server?影響架構設計Server API / Client filtering
需要模糊搜尋嗎?影響實作複雜度精確匹配 / Fuzzy match

需求分類

Functional Requirements(功能需求)

  • 輸入文字時即時顯示搜尋建議
  • 支援鍵盤導航(↑↓ 選擇、Enter 確認、Esc 關閉)
  • 點擊選項後觸發回調
  • 支援自訂結果渲染

Non-Functional Requirements(非功能需求)

  • 效能:輸入到顯示結果 < 100ms
  • 無障礙:符合 WCAG 2.1 AA 標準
  • 可擴展:支援不同資料來源與 UI 樣式
  • 響應式:適配各種螢幕尺寸

A - Architecture(架構設計)

高層架構圖

View Layer

React Components
Input Field使用者輸入
Dropdown List搜尋結果
Loading State載入狀態

Controller

useAutocomplete Hook
State Managementquery, results, isOpen
Event Handlersinput, select, keydown
Debounce Logic300ms 防抖

Service Layer

Cache + Network
CacheServiceLRU 快取
NetworkServiceAPI 請求

資料流(Data Flow)

User Input
Debounce
等待 300ms
Check Cache
Hit
Return Cached
Miss
API Request
Update Cache
Update State
Render Results

元件職責分離

層級元件職責原則
ViewInput接收輸入、顯示當前值純展示,無邏輯
ViewDropdown渲染搜尋結果列表純展示,無邏輯
ControlleruseAutocomplete狀態管理、事件處理業務邏輯集中
ServiceCacheService快取讀寫、淘汰策略可替換實作
ServiceNetworkServiceAPI 請求、錯誤處理可替換實作

D - Data Model(資料模型)

State 設計

typescript
interface AutocompleteState<T> {
  // 輸入相關
  query: string;
 
  // 結果相關
  results: T[];
  isLoading: boolean;
  error: Error | null;
 
  // UI 相關
  isOpen: boolean;
  highlightedIndex: number;
}
 
// 初始狀態
const initialState: AutocompleteState<unknown> = {
  query: "",
  results: [],
  isLoading: false,
  error: null,
  isOpen: false,
  highlightedIndex: -1,
};

Cache 設計

為什麼需要 Cache?

  1. 減少重複的 API 請求(使用者常會刪除再重打)
  2. 提升回應速度(cache hit 可達 < 1ms)
  3. 減輕 server 負擔
typescript
interface CacheEntry<T> {
  data: T[];
  timestamp: number;
  expiresAt: number;
}
 
interface CacheConfig {
  maxSize: number; // 最大快取數量,建議 50-100
  ttl: number; // Time to live (ms),建議 5 分鐘
  strategy: "lru" | "fifo"; // 淘汰策略,建議 LRU
}

LRU vs FIFO 選擇:

策略優點缺點適用場景
LRU保留熱門查詢實作較複雜大多數情況
FIFO實作簡單可能淘汰熱門項簡單場景

Props Interface

typescript
interface AutocompleteProps<T> {
  // === 核心功能 ===
  fetchSuggestions: (query: string) => Promise<T[]>;
  onSelect?: (item: T) => void;
 
  // === 受控模式(可選)===
  value?: string;
  onChange?: (value: string) => void;
 
  // === 行為配置 ===
  debounceMs?: number; // default: 300
  minQueryLength?: number; // default: 1
  maxResults?: number; // default: 10
 
  // === 自訂渲染(Render Props Pattern)===
  renderItem?: (item: T, highlighted: boolean) => ReactNode;
  renderEmpty?: () => ReactNode;
  renderLoading?: () => ReactNode;
 
  // === 無障礙 ===
  id?: string;
  ariaLabel?: string;
  placeholder?: string;
}

I - Interface(介面設計)

Component API 使用範例

基本使用:

tsx
<Autocomplete
  fetchSuggestions={async (query) => {
    const res = await fetch(`/api/search?q=${query}`);
    return res.json();
  }}
  onSelect={(item) => console.log("Selected:", item)}
  placeholder="Search..."
/>

完整配置:

tsx
interface User {
  id: string;
  name: string;
  avatar: string;
  email: string;
}
 
<Autocomplete<User>
  // 核心
  fetchSuggestions={searchUsers}
  onSelect={(user) => router.push(`/users/${user.id}`)}
  // 行為
  debounceMs={300}
  minQueryLength={2}
  maxResults={8}
  // 自訂渲染
  renderItem={(user, highlighted) => (
    <div
      className={cn("flex items-center gap-3 p-2", highlighted && "bg-blue-50")}
    >
      <img src={user.avatar} className="w-8 h-8 rounded-full" />
      <div>
        <p className="font-medium">{user.name}</p>
        <p className="text-sm text-gray-500">{user.email}</p>
      </div>
    </div>
  )}
  renderEmpty={() => (
    <p className="p-4 text-center text-gray-500">No users found</p>
  )}
  // 無障礙
  id="user-search"
  ariaLabel="Search for users"
  placeholder="Type a name..."
/>;

Server API 設計

typescript
// GET /api/search?q={query}&limit={limit}&offset={offset}
 
// Request Query Params
interface SearchParams {
  q: string; // 搜尋關鍵字
  limit?: number; // 每頁數量,default: 10
  offset?: number; // 分頁偏移,default: 0
}
 
// Response
interface SearchResponse<T> {
  results: T[];
  total: number;
  hasMore: boolean;
}
 
// Error Response
interface ErrorResponse {
  error: string;
  code: "INVALID_QUERY" | "RATE_LIMITED" | "SERVER_ERROR";
}

O - Optimizations(優化策略)

1. Debounce(防抖)

問題:每次按鍵都發送 API 請求會造成伺服器負擔過重、網路資源浪費、Race condition 風險。

核心概念: 等待使用者停止輸入一段時間後才觸發搜尋。

typescript
// 簡化版 - 實務上可用 lodash.debounce 或 use-debounce
const debouncedSearch = useDebouncedCallback((query: string) => {
  fetchSuggestions(query);
}, 300);
裝置建議延遲原因
Desktop300ms打字快,短延遲
Mobile400-500ms虛擬鍵盤打字較慢

2. Race Condition 處理

問題:使用者快速輸入 "react" → "redux",如果 "react" 的請求比較慢,可能會覆蓋 "redux" 的結果。

兩種解法:

方法優點缺點
Request ID簡單、不依賴 API舊請求仍會完成
AbortController真正取消請求需要 API 支援
typescript
// 方法一:Request ID - 只用最新請求的結果
const requestId = useRef(0);
 
async function search(query: string) {
  const currentId = ++requestId.current;
  const result = await fetchSuggestions(query);
 
  // 丟棄過期的結果
  if (currentId !== requestId.current) return;
 
  setResults(result);
}
typescript
// 方法二:AbortController - 取消前一個請求
const controllerRef = useRef<AbortController>();
 
async function search(query: string) {
  controllerRef.current?.abort(); // 取消前一個
  controllerRef.current = new AbortController();
 
  const result = await fetch(`/api/search?q=${query}`, {
    signal: controllerRef.current.signal,
  });
}

3. LRU Cache

為什麼用 LRU? 保留最近使用的查詢,刪除最久沒用的。使用者常會刪除再重打,cache 可大幅提升體驗。

核心邏輯:

typescript
class LRUCache<T> {
  private cache = new Map<string, { data: T[]; expiresAt: number }>();
 
  get(key: string): T[] | null {
    const entry = this.cache.get(key);
    if (!entry || Date.now() > entry.expiresAt) return null;
 
    // LRU 關鍵:移到最後(最新)
    this.cache.delete(key);
    this.cache.set(key, entry);
    return entry.data;
  }
 
  set(key: string, data: T[]): void {
    // 超過容量,刪除第一個(最舊)
    if (this.cache.size >= 100) {
      const oldest = this.cache.keys().next().value;
      this.cache.delete(oldest);
    }
    this.cache.set(key, { data, expiresAt: Date.now() + 5 * 60 * 1000 });
  }
}

建議配置: maxSize: 50-100TTL: 5 分鐘

4. Keyboard Navigation

按鍵行為
ArrowDown高亮下一個選項
ArrowUp高亮上一個選項
Enter選擇當前高亮項
Escape關閉下拉選單
Tab移到下一個表單元素
typescript
function handleKeyDown(e: KeyboardEvent) {
  switch (e.key) {
    case "ArrowDown":
      e.preventDefault();
      setHighlightedIndex((prev) => Math.min(prev + 1, results.length - 1));
      break;
    case "ArrowUp":
      e.preventDefault();
      setHighlightedIndex((prev) => Math.max(prev - 1, 0));
      break;
    case "Enter":
      if (highlightedIndex >= 0) onSelect(results[highlightedIndex]);
      break;
    case "Escape":
      setIsOpen(false);
      break;
  }
}

5. Accessibility(無障礙)

為什麼重要? 法規要求(ADA、WCAG)、提升使用者體驗、SEO 加分。

必要的 ARIA 屬性:

元素屬性說明
Containerrole="combobox"標識元件類型
Containeraria-expanded選單是否展開
Inputaria-autocomplete="list"有自動完成列表
Inputaria-activedescendant當前高亮選項 ID
Listrole="listbox"標識列表
Optionrole="option"標識選項
Optionaria-selected是否被選中
tsx
<div role="combobox" aria-expanded={isOpen} aria-haspopup="listbox">
  <input
    aria-autocomplete="list"
    aria-activedescendant={
      highlightedIndex >= 0 ? `option-${highlightedIndex}` : undefined
    }
  />
  <ul role="listbox">
    {results.map((item, i) => (
      <li
        key={i}
        id={`option-${i}`}
        role="option"
        aria-selected={i === highlightedIndex}
      >
        {item.name}
      </li>
    ))}
  </ul>
</div>

Deep Dive:常見追問

面試官可能會追問這些進階問題,準備好簡短的回答。

Q1: 如何處理大量搜尋結果?

答:Virtual Scrolling — 只渲染可視區域的 DOM,使用 @tanstack/react-virtualreact-window

Q2: 如何實作 Highlight Matching Text?

tsx
function highlightMatch(text: string, query: string) {
  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const parts = text.split(new RegExp(`(${escaped})`, "gi"));
  return parts.map((part, i) =>
    part.toLowerCase() === query.toLowerCase() ? (
      <mark key={i}>{part}</mark>
    ) : (
      part
    ),
  );
}
// highlightMatch("TypeScript", "type") → <mark>Type</mark>Script

Q3: 如何處理網路錯誤?

答:Retry with Exponential Backoff — 失敗後等待 1s、2s、4s... 重試,最多 3 次。也可以 fallback 到 cache 資料。

Q4: Mobile 有什麼特殊考量?

考量解法
虛擬鍵盤遮擋調整 dropdown 位置或用 modal
點擊區域太小增加 padding,至少 44x44px
打字較慢增加 debounce 到 400-500ms

總結

使用 RADIO Pattern 設計 Autocomplete,我們系統性地處理了:

階段關鍵產出
R Requirements功能/非功能需求清單、面試提問技巧
A Architecture三層架構圖、資料流設計、職責分離
D Data ModelState 結構、Cache 策略、Props Interface
I InterfaceComponent API、Server API 設計
O OptimizationsDebounce、Race Condition、LRU Cache、A11y

面試技巧提醒

  1. 先問再答 — 展現需求分析能力
  2. 畫圖說明 — 架構圖比程式碼更有說服力
  3. 說明 Why — 不只說做什麼,還要說為什麼
  4. 提出 Trade-offs — 沒有完美方案,只有適合的方案
  5. 考慮邊界情況 — 空結果、錯誤處理、大量資料

延伸閱讀