Command Palette

Search for a command to run...

Command Palette

Search for a command to run...

Tech
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 Management — query, results, isOpen
  • Event Handlers — input, select, keydown
  • Debounce Logic — 300ms 防抖

Service Layer — Cache + Network

  • CacheService — LRU 快取
  • NetworkService — API 請求

資料流(Data Flow)

flowchart TD
    A["User Input"] --> B["Debounce (等待 300ms)"]
    B --> C{"Check Cache"}
    C -->|"Hit"| D["Return Cached"]
    C -->|"Miss"| E["API Request"]
    E --> F["Update Cache"]
    F --> D
    D --> G["Update State"]
    G --> H["Render Results"]

元件職責分離

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

D - Data Model(資料模型)

State 設計

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 負擔
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

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 使用範例

基本使用:

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

完整配置:

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 設計

// 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 風險。

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

// 簡化版 - 實務上可用 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 支援
// 方法一: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);
}
// 方法二: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 可大幅提升體驗。

核心邏輯:

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移到下一個表單元素
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是否被選中
<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?

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. 考慮邊界情況 — 空結果、錯誤處理、大量資料

延伸閱讀