iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Modern Web

再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學系列 第 22 篇

【 Day 21 】分層之後,「要切在哪一層」這個決定歸誰?|互動行為(二)

  • 分享至 

  • xImage
  •  

昨天我們照 Downshift 的 useSelect 臨摹了一個下拉選單:一個 Hook 交回一包 prop getter,呼叫端把它們展開到自己寫的元素上。DOM 歸呼叫端所管,但合約完不完整,也落在呼叫端的紀律上。在按鈕上自己多掛一個 onKeyDown,或是忘了在選項上展開 getItemProps,合約就斷了,而且沒有任何東西會出聲。

回頭看,在昨天的臨摹裡,它之所以安靜,是因為狀態機和把事件送進狀態機的處理器,被包在同一個 Hook 裡一起交出來。呼叫端拿得到處理器,卻拿不到狀態機本身。所以處理器一被蓋掉,狀態機就沒有別的入口了。

React Aria 的作法是把這兩件事拆開:react-stately 管狀態,@react-aria/* 管行為,兩層之間用一個物件接起來。

可是一旦拆成兩層,呼叫端就多了一個選擇:要一路用到行為層,還是只拿狀態層,其餘自己接?

分層之後,「要切在哪一層」這個決定,歸誰?

今天讓我們把昨天的 useSelect 拆成這兩層,看看昨天那個靜默失效,會變成什麼。

兩層怎麼接在一起

進入程式碼之前,先把昨天和今天的結構並排來看。圖裡的箭頭,表示誰把什麼交給誰:

左邊是昨天:處理器與狀態機都包在 useSelect 裡,呼叫端只拿到 prop getter。右邊是今天:狀態層把 state 交給行為層,行為層再把 props 交給呼叫端;呼叫端也能直接拿到 state

昨天,處理器和狀態機都在 useSelect 裡面,呼叫端只拿得到它交出來的 prop getter。

今天,狀態機搬到狀態層的 useSelectState,由它交出一個 state 物件。行為層的 useSelect 與 useOption 收下這個物件,再把它變成按鈕、清單與選項的 props。

兩層之間只隔著這個物件,而這個物件,呼叫端自己也拿得到。

狀態層:只交出一個物件

先從狀態層開始。狀態層用 useReducer 跑昨天那份合約的狀態機 transition,交回一個物件 SelectState:

export interface SelectState<T> extends ListboxState {
  readonly items: readonly T[];
  readonly selectedItem: T | null;
  send(event: ListboxEvent): void;
}

export function useSelectState<T>(items: readonly T[]): SelectState<T> {
  const [state, send] = useReducer(
    (current: ListboxState, event: ListboxEvent) =>
      transition(current, event, items.length),
    initialState,
  );

  return {
    ...state,
    items,
    selectedItem:
      state.selectedIndex === null
        ? null
        : (items[state.selectedIndex] ?? null),
    send,
  };
}

這一層沒有元素、沒有事件物件,也沒有 id。它只知道合約的狀態:選單開著沒有、目前項目是哪一項、選了哪一項。

跟昨天的 useSelect 不一樣的,是最後一個欄位 send。

昨天,把合約事件送進狀態機的入口藏在 getter 的處理器裡,呼叫端看不到。今天,它是 SelectState 上的一個方法,直接交到呼叫端手上。

行為層:每個 Hook 都要先拿到 state

有了狀態層交回來的 SelectState 物件,接著,我們把它交給行為層的 Hook,作為參數使用。

比如說:useSelect(state) 交回按鈕與清單的 props:

export function useSelect<T>(state: SelectState<T>): SelectAria {
  const ids = createIds(useId());
  idsByState.set(state, ids);

  return {
    triggerProps: {
      ...triggerAttributes(state, ids),
      onClick: () => state.send({ type: "TriggerClick" }),
      onKeyDown: (event) => {
        if (contractKeys.has(event.key)) event.preventDefault();
        state.send({ type: "TriggerKeyDown", key: event.key });
      },
      onBlur: () => state.send({ type: "TriggerBlur" }),
    },
    listboxProps: listboxAttributes(ids),
  };
}

至於 useOption(state, index) 則交回一個選項的 props:

export function useOption<T>(
  state: SelectState<T>,
  index: number,
): OptionProps {
  const ids = idsByState.get(state);
  if (ids === undefined)
    throw new Error("useOption 要在呼叫過 useSelect(state) 的元件之下使用");

  return {
    ...optionAttributes(state, ids, index),
    onClick: () => state.send({ type: "OptionClick", index }),
    onMouseMove: () => state.send({ type: "OptionPointerMove", index }),
    onMouseDown: (event) => event.preventDefault(),
  };
}

它們做的事情,跟昨天的 getter 一樣:把合約的屬性展開,再加上把 DOM 事件翻成合約事件的處理器。按下選項時的 event.preventDefault() 也還在,焦點因此留在按鈕上。

不一樣的,是狀態從哪裡來。

昨天的 getter 從同一個 Hook 的閉包裡拿到狀態,呼叫端看不到這條線。今天,狀態是一個參數,寫在每一個 Hook 的簽章上。React Aria 的 useSelect 與 useOption 也是這個形狀,參數裡都有一個 state。

連 id 也要經過 state

兩個 Hook 裡都有一個 idsByState。它在解決一個昨天不存在的問題。

按鈕上的 aria-activedescendant 要指到目前項目的 id。昨天,按鈕與選項的 getter 出自同一個 Hook,共用同一組 id。但今天,useOption 會在另一個元件裡呼叫(下一節會看到為什麼),拿不到 useSelect 那邊用 useId 產生的 id。

兩邊共同握著的,只有 state。所以 id 也只好掛在它身上傳過去:

const idsByState = new WeakMap<SelectState<unknown>, ListboxIds>();

useSelect 用 idsByState.set(state, ids) 把 id 存進去,useOption 再用 idsByState.get(state) 讀出來。React Aria 的 listbox 也是這樣做的:useListBox 把 id 存進一個以 state 為鍵的 WeakMap,useOption 再從那裡讀出來。

連 id,都要經過兩層之間的這個物件。

呼叫端:一個選項,一個元件

呼叫端要做的事情變成三步:用 useSelectState 拿到 state,把它交給 useSelect 拿到按鈕與清單的 props,再交給每一個選項的 useOption:

import type { JSX } from "react";

import { useOption, useSelect } from "../behavior";
import { type SelectState, useSelectState } from "../state";

const Option = ({
  state,
  index,
}: {
  state: SelectState<string>;
  index: number;
}): JSX.Element => {
  const optionProps = useOption(state, index);
  return <li {...optionProps}>{state.items[index]}</li>;
};

export const Select = ({
  items,
}: {
  items: readonly string[];
}): JSX.Element => {
  const state = useSelectState(items);
  const { triggerProps, listboxProps } = useSelect(state);

  return (
    <div>
      <button type="button" {...triggerProps}>
        {state.selectedItem ?? "選一個"}
      </button>
      <ul {...listboxProps}>
        {state.isOpen &&
          items.map((item, index) => (
            <Option key={item} state={state} index={index} />
          ))}
      </ul>
    </div>
  );
};

跟昨天相比,多了一個 Option 元件。

這是因為 useOption 是一個 Hook。照 Hook 的規則,它不能在 items.map 的回呼裡呼叫,所以每一個選項都得是一個元件,state 再從 Select 以 prop 傳下去。

這也順帶決定了誰會被通知:state 活在 Select 的 useReducer 裡,每按一次方向鍵,七個 Option 都會跟著重新渲染。誰該被通知,在這裡是由交付的形狀決定的,這個問題我們在共享狀態與使用者輸入的領域已經看了八天,這裡先記一筆。

換一次 DOM,要改幾行

跟昨天一樣,把 <ul>/<li> 換成 <div role="listbox">/<div role="option">。兩份呼叫端都跑同一組焦點合約斷言,每份十條,兩份都是綠的。

再用 pnpm test 數兩份呼叫端的差異,只算程式碼行:

形狀 呼叫端程式碼行 拿掉 寫上 形狀層改了幾行
一疊行為層 34 3 3 0

拿掉的三行是:

L14 return <li {...optionProps}>{state.items[index]}</li>;
L30 <ul {...listboxProps}>
L35 </ul>

寫上的三行,是同樣位置的 <div>。

改的一樣是元素本身,只是散在兩個元件裡:選項的元素在 Option,清單的元素在 Select。昨天是四行,今天是三行,但這一行的差別來自排版,不是形狀:Option 回傳的 JSX 短到排成一行,<li> 的開標籤與閉標籤落在同一行上。換掉的,一樣是兩個元素。

useSelectState、useSelect 與 useOption 都一行沒改。「不決定你的 DOM」在這裡也兌現了,兌現的方式跟昨天差不多。

昨天的靜默失效,到哪裡去了?

那麼,開頭那個問題呢?

先試著把 Option 寫錯。假使我們忘了把 state 傳給 Option:

<Option key={item} index={index} />

型別檢查會先擋下來:

error TS2741: Property 'state' is missing in type '{ index: number; }' but required in type '{ state: SelectState<string>; index: number; }'.

再假使 state 傳了,但 Select 裡沒有呼叫 useSelect(state)。這時 useOption 在 idsByState 裡找不到 id,渲染時就會丟出錯誤:

useOption 要在呼叫過 useSelect(state) 的元件之下使用

這兩種錯誤,都發生在兩層之間的那條線上。

昨天,選項的 getter 與按鈕的 getter 出自同一次 Hook 呼叫。它們屬於同一份合約,這件事不必寫出來,也就沒有地方可以檢查。今天,這條線寫在每一個 Hook 的參數上,寫錯了,型別或執行期會出聲。

但再把昨天第一個情況重做一次,在展開 triggerProps 之後自己掛一個 onKeyDown:

<button
  type="button"
  {...triggerProps}
  onKeyDown={(event) => {
    if (event.key === "Tab") console.log("離開選單");
  }}
>

結果跟昨天一樣。型別檢查通過,Tab 會印出「離開選單」,滑鼠點擊也正常,可是方向鍵打不開選單,也沒有任何東西會出聲。

接縫出現在狀態層與行為層之間。props 與元素之間,仍然只隔著一個展開運算子。

這是這個形狀贏的地方。昨天那份靠紀律維持的接線,有一段變成了一個看得見的物件:每一個行為層的 Hook 都得拿到 state 才叫得起來,而 state 是誰、從哪裡來,寫在程式碼裡。

切在哪一層,由誰決定?

既然狀態層是一個可以單獨拿到的物件,呼叫端也就可以決定,不要上面那一層。

例如,我不想為了 useOption 多寫一個 Option 元件,想像昨天那樣,在 items.map 裡直接把選項畫出來。按鈕的部分,useSelect 用得好好的,那就只把選項那一半換掉。先自己用 useId 產生一組 id:

const state = useSelectState(items);
const { triggerProps, listboxProps } = useSelect(state);
const ids = createIds(useId());

再用合約的 optionAttributes 與 state.send,在 items.map 裡組出選項的 props:

<li
  key={item}
  {...optionAttributes(state, ids, index)}
  onClick={() => state.send({ type: "OptionClick", index })}
  onMouseMove={() => state.send({ type: "OptionPointerMove", index })}
  onMouseDown={(event) => event.preventDefault()}
>
  {item}
</li>

型別檢查通過,方向鍵也能動。但按兩下方向鍵之後,看一下按鈕與選項上的 id:

按鈕的 aria-activedescendant:_r_2_-option-1
第 1、2 項的 id:_r_3_-option-0、_r_3_-option-1

按鈕指向的 id,不存在於頁面上的任何元素。

這正是昨天第二個情況:忘了展開 getItemProps,aria-activedescendant 指向一個不存在的 id。它回來了,而且一樣安靜。

原因在 idsByState。按鈕的 id 是 useSelect 自己用 useId 產生、存在 WeakMap 裡的,呼叫端拿不到。我們另外產生的那一組,跟它對不上。

所以,要不用 useOption,就得連 useSelect 一起不用,只拿狀態層,其餘全部自己組:

import type { JSX } from "react";
import { useId } from "react";

import {
  contractKeys,
  createIds,
  listboxAttributes,
  optionAttributes,
  triggerAttributes,
} from "../../../core/src";
import { useSelectState } from "../state";

export const Select = ({
  items,
}: {
  items: readonly string[];
}): JSX.Element => {
  const state = useSelectState(items);
  const ids = createIds(useId());

  return (
    <div>
      <button
        type="button"
        {...triggerAttributes(state, ids)}
        onClick={() => state.send({ type: "TriggerClick" })}
        onKeyDown={(event) => {
          if (contractKeys.has(event.key)) event.preventDefault();
          state.send({ type: "TriggerKeyDown", key: event.key });
        }}
        onBlur={() => state.send({ type: "TriggerBlur" })}
      >
        {state.selectedItem ?? "選一個"}
      </button>
      <ul {...listboxAttributes(ids)}>
        {state.isOpen &&
          items.map((item, index) => (
            <li
              key={item}
              {...optionAttributes(state, ids, index)}
              onClick={() => state.send({ type: "OptionClick", index })}
              onMouseMove={() =>
                state.send({ type: "OptionPointerMove", index })
              }
              onMouseDown={(event) => event.preventDefault()}
            >
              {item}
            </li>
          ))}
      </ul>
    </div>
  );
};

這個版本跑同一組焦點合約斷言,十條全綠,Option 元件也不見了。但呼叫端從 34 行變成 50 行,而且多出來的不只是行數:哪些鍵要 preventDefault、按下選項時為什麼要攔住 mousedown、失焦時要送出 TriggerBlur、按鈕與選項的 id 要從同一個 useId 長出來,這些原本是行為層替我們記住的事,現在都得由呼叫端自己知道。

所以,回到今天的問題。要切在哪一層,是呼叫端決定的。

但可以切在哪裡,是分層模型先畫好的。

在狀態層與行為層之間可以切,因為兩層之間只隔著 state 這個物件。在行為層的中間就不行,因為 useSelect 與 useOption 之間,還有一份沒有寫在簽章上的約定:id 存在一個以 state 為鍵的 WeakMap 裡。只換掉其中一半,昨天的靜默失效就回來了。

這是這個形狀的缺點。要用得起來,得先知道它怎麼分層:每一層交出什麼、哪一個 Hook 要在哪一個之後呼叫、為什麼選項得是一個元件。而切得越低,要自己組裝的就越多。

昨天形狀的優點則是,抽象相對少許多:要理解它,讀一個 Hook 的回傳值就夠了。今天這段看得見的接縫,正是用那份「少」換來的。同一個性質,從昨天的優點,變成了今天的缺點。

不過,不管切在哪一層,最後把 props 展開到元素上的,都還是呼叫端。在按鈕上自己多掛一個 onKeyDown,今天一樣會讓方向鍵安靜地失效。

狀態與行為之間的線,交出來變成了一個物件;props 與元素之間的那條線,還留在呼叫端手上。那如果,連這條接線都不交給你呢?

臨摹的 useOption 內部沒有再呼叫其他 Hook,所以在 items.map 裡呼叫其實跑得動;這裡照 Hook 的規則寫成一項一個元件。React Aria 的 useOption 內部會呼叫 useSelectableItem、useHover 等 Hook。useSelect(props, state, ref)、useOption(props, state, ref) 的簽章,以及 useListBox 寫入、useOption 讀取的 listData(WeakMap<ListState, ListData>),依 react-aria 3.52.1 發布的原始碼,查核於 2026-10-06。

本文對照 react-aria 3.52.1、react-stately 3.50.0,查核於 2026-10-06;實驗跑在 react 19.3.0 與 react-dom 19.3.0。


上一篇
【 Day 20 】方向鍵會動了,焦點該在誰身上?|互動行為(一)
下一篇
【 Day 22 】用了元件樹,為什麼還需要一個把它打洞的 API?|互動行為(三完)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言