從「遠端世界」到「瀏覽器 API」,這四個領域包起來的,都是畫面上看不見的東西:一份遠端資料、一個 store、表單裡的值、localStorage 的一個 key。元件只負責把它們讀出來、顯示在畫面上。
但有一類程式碼,包的不是值,而是一段互動。例如一個下拉選單:按下按鈕會打開,方向鍵可以上下移動,Enter 選取,Escape 關閉。
這類函式庫常被描述成「把互動行為交給你,卻不決定你的 DOM」。這句話,大概每一個無頭 (headless) UI 函式庫都會同意。
今天進入第五個設計問題:互動行為的複雜度 —— 如果 DOM 由你決定,複雜度會跑去哪裡?
在讀任何一個函式庫之前,先讓我們自己用 useState 寫一個下拉選單,看看它缺了什麼。
遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✓ · 瀏覽器 API ✓ · 互動行為 ✍️ · 真實 DOM
一個下拉選單,至少要記住三件事:選單有沒有打開、方向鍵目前停在哪一項,以及使用者最後選了什麼。我們各用一個 useState 來管理,鍵盤事件掛在按鈕上:
import type { KeyboardEvent } from "react";
import { useState } from "react";
const items = ["蘋果", "香蕉", "櫻桃", "榴槤", "芭樂", "芒果", "番茄"];
export const Select = () => {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (!isOpen) {
if (event.key === "ArrowDown") {
event.preventDefault();
setIsOpen(true);
setHighlightedIndex(0);
}
return;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setHighlightedIndex((i) => Math.min(i + 1, items.length - 1));
break;
case "ArrowUp":
event.preventDefault();
setHighlightedIndex((i) => Math.max(i - 1, 0));
break;
case "Enter":
event.preventDefault();
setSelectedItem(items[highlightedIndex] ?? null);
setIsOpen(false);
break;
case "Escape":
setIsOpen(false);
break;
}
};
return (
<div>
<button
type="button"
onClick={() => setIsOpen((open) => !open)}
onKeyDown={onKeyDown}
>
{selectedItem ?? "選一個"}
</button>
{isOpen && (
<ul className="menu">
{items.map((item, index) => (
<li
key={item}
className={index === highlightedIndex ? "highlighted" : undefined}
onClick={() => {
setSelectedItem(item);
setIsOpen(false);
}}
>
{item}
</li>
))}
</ul>
)}
</div>
);
};
方向鍵停在哪一項,靠的是 highlighted 這個 class。滑鼠移過去的那一項,我們也希望它亮起來,所以兩者共用同一個樣式:
.menu li.highlighted,
.menu li:hover {
background: #e8eefc;
}
試著操作一次:按 Tab 讓按鈕拿到焦點,按一下方向鍵往下,選單打開,第 1 項亮起來;再按兩下,亮到第 3 項「櫻桃」;按 Enter,選單關閉,按鈕上寫著「櫻桃」。
打開、移動、選取、關閉都做到了。目前,這個選單似乎能夠正常運作。
那麼,在剛才按方向鍵、第 3 項亮起來的那一刻,焦點在誰身上?
答案是按鈕。
從按下 Tab 開始,到 Enter 關閉選單為止,document.activeElement 一直是那個 <button>。鍵盤事件之所以收得到,正是因為焦點從來沒有離開過它。
那第 3 項的「亮起來」,對瀏覽器來說又是什麼?
只是一個 class。
它讓畫面上的某一列換了背景色,但瀏覽器不知道這一列和按鈕有什麼關係。依賴焦點來判斷「使用者正在操作哪裡」的輔助技術,例如螢幕閱讀器,看到的通常也是那個按鈕。
畫面上的「目前項目」與焦點,在這個版本裡是分開的。只不過,這件事是我們不小心做出來的,不是決定出來的。
再多做一步。按方向鍵到第 3 項之後,把滑鼠移到第 5 項「芭樂」上。這時畫面上會有兩列同時亮著:第 3 項是 class,第 5 項是 :hover。
如果這時再按一次方向鍵,會從哪一項開始往下?第 4 項,還是第 6 項?
在我們的版本裡,答案是第 4 項「榴槤」。因為 highlightedIndex 只有方向鍵會改,滑鼠的移動只改變了樣式,沒有碰到狀態。
這是一個 bug 嗎?
我想不太算。可以補一個 onMouseMove,讓滑鼠也去改 highlightedIndex,那樣答案就會是第 6 項。但也可以主張,滑鼠只是剛好停在那裡,鍵盤的位置不應該被它搶走。兩種寫法都說得通,差別在於「目前項目」到底是什麼。
回頭看這兩個問題:焦點在哪裡,以及方向鍵從哪裡接著走。
一開始,我以為這個選單缺的是幾個鍵盤事件:Home、End、空白鍵,再補一個滑鼠的處理。但補上這些事件之前,有一件事得先決定:
焦點在誰身上?誰是目前項目?這兩件事,可以是兩個不同的東西嗎?
我缺的不是幾個事件,而是一份焦點合約。
那麼,這份合約的第一條,焦點該在誰身上?
這份合約有兩種常見的答案,W3C 的 ARIA 撰寫實務指南 (APG) 把它們並列成管理焦點的兩種方式。
第一種是 roving tabindex:焦點真的跟著目前項目走。按下方向鍵時,把下一項的 tabIndex 設成 0、其他設成 -1,再呼叫它的 focus()。在這個答案裡,焦點就是目前項目,兩者是同一件事。
第二種是 aria-activedescendant:焦點一直留在外層的元素上,例如我們的按鈕;目前項目改由按鈕上的 aria-activedescendant 屬性指出來,屬性的值是那一項的 id。在這個答案裡,焦點與目前項目是兩件事,一個不動,一個在動。
剛才那個「第 4 項還是第 6 項」的問題,就落在這兩個答案的分岔上。
如果目前項目就是焦點,滑鼠移過去時要讓它變成目前項目,就得把焦點搬過去,而搬動焦點不只是改一個值:APG 提到,瀏覽器會把剛拿到焦點的元素捲進視窗裡。所以這個答案往往傾向讓滑鼠只是滑鼠,答案是第 4 項。
如果目前項目只是一個狀態,滑鼠移過去時改掉它,成本跟方向鍵一樣,焦點也不必動。答案就可以是第 6 項。
兩種答案都不是 bug 的修法,在同一份合約裡也很難同時成立。這是一個只能選邊的設計決定。
在接下來的範例裡,我們選了 aria-activedescendant,因為它讓焦點與目前項目分得最開。合約只回答三件事:焦點在誰身上、哪一項是目前項目、鍵盤怎麼移。
先從狀態開始。目前項目在這裡是一個獨立的欄位 highlightedIndex,跟焦點無關:
export interface ListboxState {
readonly isOpen: boolean;
readonly highlightedIndex: number | null;
readonly selectedIndex: number | null;
}
接著是狀態機 (transition)。它是一個純函式,收下目前的狀態、一個事件與項目數,交回下一個狀態。以下節錄其中三種事件:
export function transition(
state: ListboxState,
event: ListboxEvent,
itemCount: number,
): ListboxState {
if (itemCount === 0) return state;
switch (event.type) {
// ...
case "TriggerKeyDown":
return state.isOpen
? onOpenKey(state, event.key, itemCount)
: onClosedKey(state, event.key, itemCount);
case "OptionPointerMove":
return state.isOpen && !Object.is(state.highlightedIndex, event.index)
? opened(state, event.index)
: state;
case "OptionClick":
return selected(state, event.index);
}
}
TriggerKeyDown 是按鈕收到的按鍵,OptionPointerMove 是滑鼠在某一項上移動。兩種事件改的是同一個 highlightedIndex,所以第 3 項、滑到第 5 項、再按方向鍵,會到第 6 項。這一行,就是我們在上一節選的那一邊。
方向鍵本身的移動寫在 onOpenKey 裡,一次一格,到底不繞回:
case "ArrowDown":
return opened(
state,
current === null ? 0 : clamp(current + 1, itemCount),
);
最後,合約要把「誰是目前項目」告訴按鈕以外的世界。按鈕上的 aria-activedescendant 指向目前項目的 id,選項則用 tabIndex: -1 讓自己不在 Tab 順序裡:
export const triggerAttributes = (
state: ListboxState,
ids: ListboxIds,
): TriggerAttributes => ({
id: ids.trigger,
role: "combobox",
tabIndex: 0,
"aria-haspopup": "listbox",
"aria-expanded": state.isOpen,
"aria-controls": ids.listbox,
"aria-activedescendant":
state.isOpen && state.highlightedIndex !== null
? ids.option(state.highlightedIndex)
: undefined,
});
這個檔案裡沒有 React,也沒有任何事件處理器。它只寫合約的內容:狀態怎麼變、每個元素身上該有哪些屬性。
這裡的合約是為了讓設計決定現形的小實驗,並不是 production-ready 的實作。
💡 這份合約刻意沒有處理打字跳到相符的項目 (typeahead)、停用的項目、多選、捲動到目前項目與浮層定位。roving tabindex 的版本也沒有寫,因為這裡要比較的不是這兩個答案,而是同一份合約怎麼交出去。
合約寫好了。但內容不等於交付:狀態要放在哪裡、事件處理器掛在哪個元素上、屬性怎麼交到呼叫端手上,transition 都沒有回答。
而「不決定你的 DOM」這句話,換個方式說,是 DOM 的結構歸你。函式庫只能交出合約,元素得由你來寫。
最直接的交法,是一個 Hook 交回一包可以展開的 props。這是 Downshift 的 useSelect 所採用的形狀,以下便是照這個形狀臨摹的一部分。2026 年談 Downshift,談的是 useSelect、useCombobox 這幾個 Hook,不是 render prop 形式的 <Downshift> 元件。
Hook 用 useReducer 跑上一節的狀態機,交回狀態與三個 prop getter:給按鈕的 getToggleButtonProps、給清單的 getMenuProps,以及給每一項的 getItemProps(index)。以下節錄按鈕與選項的兩個 getter。
它們做的事情都一樣:把合約的屬性展開,再加上把 DOM 事件翻譯成合約事件的處理器。
getToggleButtonProps: (own = {}) => ({
...triggerAttributes(state, ids),
onClick: callAll(own.onClick, () => send({ type: "TriggerClick" })),
onKeyDown: callAll(own.onKeyDown, (event) => {
if (contractKeys.has(event.key)) event.preventDefault();
send({ type: "TriggerKeyDown", key: event.key });
}),
onBlur: callAll(own.onBlur, () => send({ type: "TriggerBlur" })),
}),
getItemProps: (index, own = {}) => ({
...optionAttributes(state, ids, index),
onClick: callAll(own.onClick, () => send({ type: "OptionClick", index })),
onMouseMove: callAll(own.onMouseMove, () =>
send({ type: "OptionPointerMove", index }),
),
onMouseDown: callAll(own.onMouseDown, (event) => event.preventDefault()),
}),
要注意 onMouseDown 事件裡的 event.preventDefault()。
在瀏覽器裡,按下一個不能拿焦點的元素,原本拿著焦點的按鈕會失去焦點。所以按下選項時要 preventDefault,焦點才會留在按鈕上。這一行屬於合約的第一條,焦點在誰身上。
至於 callAll,它讓呼叫端自己的處理器與合約的處理器並存:
const callAll =
<E extends SyntheticEvent>(
own: ((event: E) => void) | undefined,
contract: (event: E) => void,
) =>
(event: E): void => {
own?.(event);
contract(event);
};
先呼叫呼叫端的,再呼叫合約的,兩個都會跑。
用起來是這樣。按鈕、清單與選項的元素,全部由呼叫端自己寫,再把對應的 getter 展開上去:
import { useSelect } from "../useSelect";
export const Select = ({ items }: { items: readonly string[] }) => {
const {
isOpen,
selectedItem,
getToggleButtonProps,
getMenuProps,
getItemProps,
} = useSelect(items);
return (
<div>
<button type="button" {...getToggleButtonProps()}>
{selectedItem ?? "選一個"}
</button>
<ul {...getMenuProps()}>
{isOpen &&
items.map((item, index) => (
<li key={item} {...getItemProps(index)}>
{item}
</li>
))}
</ul>
</div>
);
};
跟第一節的版本相比,呼叫端少了所有的 useState 與 switch,但 JSX 的形狀幾乎沒變。
既然 DOM 歸呼叫端,那就實際換一次。把 <ul>/<li> 換成 <div role="listbox">/<div role="option">,看看這個形狀要改多少。
在我的臨摹裡,同一個呼叫端寫了兩份,一份用 <ul>/<li>,一份用 <div>。兩份都跑同一組焦點合約斷言,例如「方向鍵移動時,焦點一直留在按鈕上」「選到第 3 項、滑到第 5 項,再按方向鍵到第 6 項」,每份十條。
兩份都是綠的。
接著用 pnpm test 數兩份呼叫端的差異,只算程式碼行:
| 形狀 | 呼叫端程式碼行 | 拿掉 | 寫上 | 形狀層改了幾行 |
|---|---|---|---|---|
| 一包 props | 30 | 4 | 4 | 0 |
拿掉的四行是 <ul>、<li> 各自的開標籤與閉標籤,寫上的四行是同樣位置的 <div>。role 不必自己補,因為 getter 交回的 props 裡已經帶著 role="listbox" 與 role="option"。useSelect 本身一行都沒有改。
在這個形狀裡,「不決定你的 DOM」兌現得很直接:元素本來就是呼叫端寫的,換元素也就只是換元素。
這是這個形狀贏的地方。DOM 完全是你的,資料從 Hook 直接流到元素上,中間沒有上下文,也沒有另外的元件。要理解它,讀一個 Hook 的回傳值就夠了。
不過,元素歸你,也代表最後一步歸你:getter 要不要呼叫、展開到哪個元素上,都由呼叫端決定。
假使我們想在按鈕上記錄使用者按了 Tab 離開選單,於是在展開 getter 之後,自己寫了一個 onKeyDown:
<button
type="button"
{...getToggleButtonProps()}
onKeyDown={(event) => {
if (event.key === "Tab") console.log("離開選單");
}}
>
{selectedItem ?? "選一個"}
</button>
型別檢查會通過,主控台沒有任何錯誤或警告,按下 Tab 也確實會印出「離開選單」。
但方向鍵不會動了。因為 JSX 的屬性是後面蓋掉前面,合約的 onKeyDown 被我們自己的那一個取代,按下方向鍵,選單不會打開,aria-activedescendant 也不會出現。滑鼠點擊按鈕倒是一切正常,因為 onClick 沒有被蓋掉。
另一種情況,是忘了在選項上展開 getItemProps。這時方向鍵看起來還能動,按鈕上的 aria-activedescendant 也照常更新,可是它指向的 id 不存在於頁面上的任何元素:選項上沒有 id,也沒有 role="option"。
這兩種情況,都沒有任何東西會出聲。
要避免第一種,可以把自己的處理器交給 getter,而不是寫在展開之後:
<button
type="button"
{...getToggleButtonProps({
onKeyDown: (event) => {
if (event.key === "Tab") console.log("離開選單");
},
})}
>
這樣 callAll 會讓兩個處理器都執行,方向鍵也恢復了。但這條路要不要走,仍然取決於呼叫端知不知道它存在。
這是這個形狀輸的地方。合約的內容寫在 Hook 裡,合約是否完整,則取決於呼叫端的紀律:getter 沒貼、貼錯元素,或是自己另外掛一個 onKeyDown 把它蓋掉,合約都會靜默失效。
一包 props 把合約的內容替我們寫好了,卻把「貼上去」這一步,原封不動地交還給呼叫端。
getter 與元素之間,只隔著一個展開運算子,合約在這裡斷掉時,沒有任何東西會出聲。那麼,這個靜默失效,有沒有可能變成一個看得見的接縫?
Downshift README 寫明,
useSelect與useCombobox支援最新的 ARIA combobox pattern,render prop 的<Downshift>元件則不支援,並將在 Hook 成熟後完全移除,查核於 2026-10-05。roving tabindex 與aria-activedescendant兩種方式,依 W3C APG〈Developing a Keyboard Interface〉,查核於 2026-10-05。本文對照
downshift9.4.0,查核於 2026-10-05;實驗跑在react19.3.0 與react-dom19.3.0。