iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Modern Web

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

【 Day 22 】用了元件樹,為什麼還需要一個把它打洞的 API?|互動行為(三完)

  • 分享至 

  • xImage
  •  

過去兩天,我們把同一份焦點合約,用兩種形狀交了出去。Day 20 照 Downshift 的 useSelect,由一個 Hook 交回一包 prop getter;昨天照 React Aria,把它拆成狀態層與行為層,兩層之間用 state 這個物件接起來。

這兩種形狀有一件事是一樣的:最後把 props 展開到元素上的,都是呼叫端。所以在按鈕上自己多掛一個 onKeyDown,兩天的方向鍵都會安靜地失效。

Radix,以及 Radix 原作者參與打造的 Base UI,則連這一步都不交出來。它們交給呼叫端的是一棵已經接好的元件樹:按鈕、清單、選項各是一個元件,合約的 props 在元件裡面就接上了元素。

可是這兩個函式庫,都另外提供了一個 API,讓呼叫端在這棵樹上打一個洞,把自己的元素放進去。

這個洞就像讓我們放入 children 的插槽。呼叫端可以透過 children 決定元件裡面放什麼;透過這個洞,則可以決定元件本身要畫成哪個元素。

既然接線都已經替呼叫端做好了,為什麼還需要這個洞?

今天是探索互動行為的最後一天,讓我們先把下拉選單寫成一棵元件樹,看看這個洞是從哪裡長出來的。

遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✓ · 瀏覽器 API ✓ · 互動行為 ✓ · 真實 DOM

把下拉選單寫成一棵元件樹

既然不把 props 交給呼叫端,合約就得在元件裡面接好。讓我們先看看呼叫端會寫什麼:

import type { JSX } from "react";

import { Item, Popup, Root, Trigger, Value } from "../Select";

export const Select = ({
  items,
}: {
  items: readonly string[];
}): JSX.Element => (
  <Root items={items}>
    <Trigger>
      <Value placeholder="選一個" />
    </Trigger>
    <Popup>
      {items.map((item, index) => (
        <Item key={item} index={index}>
          {item}
        </Item>
      ))}
    </Popup>
  </Root>
);

跟前兩天相比,這段程式碼裡沒有任何合約的 props:沒有 getItemProps,也沒有 useOption。呼叫端只決定要放哪幾個元件、放在哪裡。

那合約接在哪裡?

在 Root 裡。Root 用 useReducer 跑同一份狀態機 transition,再透過 Context API 把狀態交給底下的元件:

interface SelectContextValue {
  readonly state: ListboxState;
  readonly ids: ListboxIds;
  readonly items: readonly string[];
  send(event: ListboxEvent): void;
}

const SelectContext = createContext<SelectContextValue | null>(null);

export const Root = ({
  items,
  children,
}: {
  items: readonly string[];
  children: ReactNode;
}): JSX.Element => {
  const ids = createIds(useId());
  const [state, send] = useReducer(
    (current: ListboxState, event: ListboxEvent) =>
      transition(current, event, items.length),
    initialState,
  );

  return (
    <SelectContext.Provider value={{ state, ids, items, send }}>
      {children}
    </SelectContext.Provider>
  );
};

底下的每一個元件,從上下文 (context) 拿到狀態,再把合約的屬性與處理器接到自己畫的元素上。以選項為例:

export const Item = ({
  index,
  children,
}: {
  index: number;
  children?: ReactNode;
}): JSX.Element => {
  const { state, ids, send } = useSelectContext("Item");

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

useSelectContext 從上下文讀出 Root 放進去的值,元件沒有放在 Root 底下時會丟出錯誤。

這些屬性與處理器,跟 Day 20 的 getItemProps 交回的一樣,只是展開的位置,從呼叫端的 <li> 搬進了 Item 自己的 <div>。按鈕的 Trigger 與清單的 Popup 也是同一個寫法。

沒有地方可以貼錯

展開的位置搬進元件之後,前兩天那個失效還會發生嗎?

再做一次同樣的事,在按鈕上掛一個 onKeyDown,記錄使用者按了 Tab 離開選單:

<Trigger
  onKeyDown={(event) => {
    if (event.key === "Tab") console.log("離開選單");
  }}
>
  <Value placeholder="選一個" />
</Trigger>

型別檢查會先擋下來:

error TS2322: Type '{ children: Element; onKeyDown: (event: any) => void; }' is not assignable to type 'IntrinsicAttributes & PartProps'.
  Property 'onKeyDown' does not exist on type 'IntrinsicAttributes & PartProps'.

在這棵樹裡,按鈕的元素是 Trigger 自己畫的,呼叫端手上沒有它,也就沒有「寫在展開之後」這個位置。選項也一樣,沒有 getItemProps 可以忘記展開。

呼叫端沒有貼錯的機會,因為根本沒有需要貼的東西。

這是這個形狀的優點。Day 20 的缺點,是合約完不完整取決於呼叫端的紀律;到了元件樹,這件事變成了結構上的保證。

換一次 DOM,要改幾行

不過,元素既然是元件自己畫的,換 DOM 這件事,也跟著搬進了元件裡。

前兩天的呼叫端,都是把 <ul>/<li> 換成 <div role="listbox">/<div role="option">。但在這棵樹裡,Popup 與 Item 畫的本來就是 <div>。那麼,想要 <ul>/<li> 的時候呢?

Item 的 <div> 寫在元件裡面,呼叫端碰不到。照上一節的寫法,呼叫端沒有任何地方可以換掉它。

要換,元件得留一個入口。

這個入口就是打洞 API。在 Base UI 裡,它是每個元件都有的 render 屬性:呼叫端交一個元素進來,元件把合約的 props 合併上去,畫出來的便是呼叫端的元素。

我們照這個形狀,把 Item 裡的 <div> 換成 useRender:

export const Item = ({
  index,
  render,
  children,
}: PartProps & { index: number }): JSX.Element => {
  const { state, ids, send } = useSelectContext("Item");

  return useRender({
    render: render ?? null,
    defaultTagName: "div",
    props: {
      ...optionAttributes(state, ids, index),
      onClick: () => send({ type: "OptionClick", index }),
      onMouseMove: () => send({ type: "OptionPointerMove", index }),
      onMouseDown: (event: MouseEvent) => event.preventDefault(),
      children,
    },
  });
};

useRender 收下呼叫端的 render、預設的標籤與合約的 props。沒有交 render,就畫預設的 <div>;交了一個元素,就把合約的 props 合併到那個元素上:

export function mergeProps(contract: Props, own: Props): Props {
  const merged: Props = { ...contract };
  for (const [key, value] of Object.entries(own)) {
    const inner = contract[key];
    if (isHandler(key, value) && isHandler(key, inner)) {
      merged[key] = (event: unknown) => {
        value(event);
        inner(event);
      };
    } else if (key === "className" && typeof inner === "string") {
      merged[key] = `${inner} ${String(value)}`;
    } else if (key === "children" && value === undefined) {
      continue;
    } else {
      merged[key] = value;
    }
  }
  return merged;
}

export function useRender({
  render,
  defaultTagName,
  props,
}: UseRenderOptions): ReactElement {
  if (render === null) return createElement(defaultTagName, props);
  if (isValidElement(render))
    return cloneElement(render, mergeProps(props, render.props));
  return render(props);
}

最後一行的函式形式,下一節再回來看。

呼叫端要 <ul>/<li>,就從 render 把它們交進去:

<Popup render={<ul />}>
  {items.map((item, index) => (
    <Item key={item} index={index} render={<li />}>
      {item}
    </Item>
  ))}
</Popup>

兩份呼叫端一樣跑同一組焦點合約斷言,每份十條,都是綠的。再用 pnpm test 數兩份呼叫端的差異,只算程式碼行,並把前兩天的數字放在一起:

形狀 呼叫端程式碼行 拿掉 寫上 形狀層改了幾行
一包 props 30 4 4 0
一疊行為層 34 3 3 0
元件樹 + 打洞 20 2 2 0

元件樹拿掉的兩行是:

L14 <Popup render={<ul />}>
L16 <Item key={item} index={index} render={<li />}>

寫上的兩行,是同樣位置、不帶 render 的 <Popup> 與 <Item>。

改的不是元素,而是打洞 API 的引數。

一包 props 換的是元素本身;一疊行為層換的也是元素本身,只是散在兩個元件裡;元件樹換的,是那個洞。

三種形狀的形狀層都一行沒改,「不決定你的 DOM」三種都兌現了。但在元件樹這一格,它是透過那個洞兌現的。

拿掉 render,這棵樹就會替呼叫端決定 DOM。

這個洞,還回來的是什麼

回到開頭的問題。既然接線都做好了,為什麼還需要這個洞?

因為元件樹交出去的,不只是行為。

Day 20 的 getter 只交 props,元素由呼叫端寫;昨天的行為層也一樣。元件樹把 props 接上元素的同時,也決定了元素是哪一個、哪個節點包著哪個節點、上下文掛在哪一層。在行為之外,它還交出了一份結構。

打洞 API,是元件樹為了把結構還給呼叫端,才長出來的東西。

所以它比較像是一份證據,而不是元件樹沒做好的地方:這個洞存在,說明元件樹交付的單位,大到把結構也包了進去。

不過,還回來的只有殼。每個節點換成什麼元素,呼叫端可以決定;哪個節點包著哪個節點、上下文掛在哪,仍然是元件樹的。

洞裡的規則

洞把殼還給了呼叫端。那麼,殼上原本就帶著的屬性呢?

呼叫端交進來的元素,自己也可以寫屬性。假使我們想替每一個選項加上自己的 id,方便測試時找到它:

<Item key={item} index={index} render={<li id={`fruit-${index}`} />}>
  {item}
</Item>

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

按鈕的 aria-activedescendant:_r_0_-option-1
第 1、2、3 項的 id:fruit-0、fruit-1、fruit-2

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

Day 20 那個忘了展開 getItemProps 的情況,從洞裡回來了,而且一樣安靜。

原因在 mergeProps 最後的 merged[key] = value。一般屬性是呼叫端的蓋掉合約的,選項的 id 因此被換成 fruit-1,按鈕卻還指著原本的那一個。

但同一個洞,對事件處理器的處理不一樣。把開頭那個 onKeyDown 改寫在按鈕的洞裡:

<Trigger
  render={
    <button
      onKeyDown={(event) => {
        if (event.key === "Tab") console.log("離開選單");
      }}
    />
  }
>
  <Value placeholder="選一個" />
</Trigger>

按下 Tab 會印出「離開選單」,方向鍵也照常打開選單,因為兩個處理器都會被呼叫。

同樣是寫在洞裡的屬性,有的會蓋掉合約,有的會跟合約並存。要知道是哪一種,得先知道合併規則:一般屬性呼叫端優先、事件處理器兩個都跑、className 串起來。Base UI 的 useRender 在型別說明裡,也寫了同樣形狀的規則:

event handlers are merged, className strings and style properties are joined, while other external props overwrite the internal ones.

而我的臨摹還省略了一件事:ref 怎麼轉發。原作的洞也要處理它,那又是另一份規則。

這是這個形狀的缺點。DOM 的結構是元件樹的,呼叫端要的組合它沒有提供時,打洞是唯一的出口。而這個出口的行為,props 怎麼合併、事件怎麼疊、ref 怎麼轉發,從呼叫端的程式碼上看不出來。

Day 20 那個形狀的優點,是 DOM 歸呼叫端所管;到了元件樹,它成了要打洞才拿得回來的東西。而 Day 20 靠紀律維持的缺點,在這裡成了結構的保證。同一組性質,在這兩種形狀之間換了手。

從 asChild 到 useRender

這個洞,在 Radix 與 Base UI 之間換過一次形狀。

Radix 的洞叫 asChild,它是一個布林值。設成 true,元件就不畫自己的元素,改由一個叫 Slot 的元件,把 props 合併到呼叫端放進來的子元素上。呼叫端交出元素,但碰不到被合併的那包 props。

Base UI 的洞是 render。除了交一個元素,它也接受一個函式:元件把合約的 props 交給這個函式,由呼叫端自己展開。上一節 useRender 最後的 return render(props),就是這個形式:

<Item key={item} index={index} render={(props) => <li {...props} />}>
  {item}
</Item>

Base UI 也把同一個機制公開成 useRender 這個 Hook,讓呼叫端自己寫的元件,也能有同樣的洞。(Base UI 的套件名稱是 @base-ui/react,舊的 @base-ui-components/react 已被標為 deprecated。)

<li {...props} /> 這個寫法,Day 20 已經看過了。

洞的函式形式,交回呼叫端手上的,是一包 props。所以 Day 20 的那個失效也一起回來了:在按鈕的函式裡展開 props 之後,再寫一個 onKeyDown:

<Trigger
  render={(props) => (
    <button
      {...props}
      onKeyDown={(event) => {
        if (event.key === "Tab") console.log("離開選單");
      }}
    />
  )}
>
  <Value placeholder="選一個" />
</Trigger>

方向鍵又打不開選單了,而且沒有任何東西會出聲。

把原本藏在元件裡的那一步交出來,介面就變寬了。在同一格裡,交付的單位往回退了一步:從一棵樹,退回到一包 props。

一包 props、一疊行為層、一棵元件樹,交付的單位一格比一格大,但這條軸並不是只能往一個方向走。

每一次交付,拿走了什麼

三種形狀都攤開了。每擴大一次交付的單位,究竟替呼叫端拿走了哪個決定,又替呼叫端承擔了哪個責任?

形狀之外的三欄分別是:

  • 拿走的決定:呼叫端不能再決定的事
  • 承擔的責任:形狀替呼叫端做好,而且保證做對的事
  • 留給呼叫端的:仍然得由呼叫端自己做、自己做對的事
形狀 拿走的決定 承擔的責任 留給呼叫端的
一包 props 幾乎沒有:元素、結構、組合都是呼叫端的 合約的內容:目前項目、鍵盤怎麼移、aria-activedescendant 指向誰 把 props 貼對地方,而且不蓋掉它們
一疊行為層 狀態機的形狀 合約的內容,加上層與層之間的接縫 切在哪一層,以及那一層以下的組裝
元件樹 + 打洞 DOM 的結構:哪個節點包哪個節點、上下文掛在哪裡 合約的內容、接縫,加上接線本身 每個節點的殼,以及打洞時 props 怎麼合併

最後一欄,正是換 DOM 實驗量到的東西。一包 props 改的是元素,因為貼在哪裡歸呼叫端;一疊行為層改的是散在兩個元件裡的元素,因為切在行為層之下的組裝,包括選項得是一個元件,都歸呼叫端;元件樹改的是 render 的引數,因為留給呼叫端的只剩殼。

這張表同時問了拿走什麼、承擔什麼。往下一列,拿走的變多,承擔的也變多,所以沒有哪一列單純是「多」或「少」。

我們在 Day 17 探索瀏覽器 API 時問過:呼叫端還需要知道什麼?在這三種形狀上,答案的份量差不多,形狀卻完全不同:一個要知道貼在哪,一個要知道切在哪一層,一個要知道怎麼打洞。

為什麼撞上編譯器的是一包 props

這張表上的差異,並沒有只留在呼叫端的程式碼裡。

Downshift 在 9.4.0 修了一個與 React Compiler 的相容性問題(PR #1690)。React Compiler 會在建置時把渲染中的計算記下來,用到的值沒變就重用上一次的結果。

修正之前,Downshift 為了讓 getter 維持同一個參考,把它們用 useCallback 包起來,依賴陣列裡只放一個參考 (ref)。狀態每次渲染時寫進這個參考,getter 被呼叫時,再從 ref.current 讀出來。

讓我們把 Day 20 的 useSelect 改成這個形狀。triggerHandlers(send) 是 Day 20 按鈕上那三個處理器,這裡抽成一個函式:

const latest = useRef<ListboxState>(state);
latest.current = state;

const getToggleButtonProps = useCallback(
  (): TriggerProps => ({
    ...triggerAttributes(latest.current, ids),
    ...triggerHandlers(send),
  }),
  [latest, ids],
);

呼叫端跟 Day 20 的一字不差,只是開啟了 React Compiler。按兩下方向鍵:

選項:3 項
按鈕的 aria-expanded:false
按鈕的 aria-activedescendant:(沒有)

選單打開了,但按鈕上的 aria-expanded 還是 false,aria-activedescendant 也沒有出現。關掉編譯器,同一份程式碼一切正常。

那麼,編譯器對呼叫端做了什麼?這是它編譯出來的程式碼中,處理按鈕 props 的那一段:

const {
  isOpen,
  selectedItem,
  getToggleButtonProps,
  getMenuProps,
  getItemProps,
} = useSelectReadingRef(items);
let t1;
if ($[0] !== getToggleButtonProps) {
  t1 = getToggleButtonProps();
  $[0] = getToggleButtonProps;
  $[1] = t1;
} else {
  t1 = $[1];
}

編譯器只在 getToggleButtonProps 這個參考變了的時候,才重新呼叫它。而在這個形狀裡,它的參考永遠不變。所以按鈕的 props,停在了第一次渲染。

把 getter 改成直接讀 state,並把 state 放進依賴陣列,同樣開著編譯器,按鈕就正常了:aria-expanded 變成 true,aria-activedescendant 指向第 2 項。

PR #1690 走的也是這個方向。處理器與 effect 繼續從參考讀最新的狀態,getter 則改讀狀態本身,讓 getter 的參考跟著狀態一起變。它沒有放棄 prop getter,而是把狀態與參考這兩條資料流拆開,重新排過。所以 prop getter 這個形狀,是修得回來的。

撞上這件事的也不只 Downshift。TanStack Table #5567 與 TanStack Virtual #736 各自回報過同樣的症狀,React Compiler 工作群組的 Discussion #10 也提醒過:函式庫裡不是 Hook 的 API,應該要能被記憶;除非另有機制讓呼叫的地方重新渲染,它的內部不該讀參考或其他會變的共享狀態。

那另外兩種形狀呢?

這個故障有一個前提:要有一個函式,被呼叫端單獨拿出來、在渲染時呼叫,而它的內部讀著會變的狀態。

交付的單位越窄,這個前提越容易成立。交一包 props,就一定有一個 getter,在呼叫端的渲染裡被呼叫。交一疊行為層,呼叫端呼叫的是 useOption(state, index) 這樣的 Hook,state 寫在參數上。交一棵元件樹,算出 props 的那次呼叫,發生在函式庫的元件裡面,呼叫端的程式碼裡根本沒有它。

在 Day 20 的臨摹裡,「什麼時候該重新算 props」是 Hook 自己的事:每次渲染都交出新的 getter。開了編譯器之後,做這個判斷的換成了編譯器,而它看的只是參考有沒有變。交付的形狀,決定了這個判斷會落在誰手上。

這三天的複雜度,從哪裡來

那麼,用 Day 02 的詞來說,這個領域的複雜度,到底是從哪裡長出來的?

不在鍵盤事件。方向鍵、Enter、Escape,Day 20 第一個用 useState 寫的版本就接上了。

複雜度在焦點合約。焦點在誰身上,與哪一項是目前項目,可以是兩件不同的事;而這份合約要同時對鍵盤、螢幕閱讀器與滑鼠成立。

三種形狀藏起來的東西,份量相近,位置不同。一包 props 藏了合約的內容,把接線留給呼叫端;一疊行為層多藏了狀態機的形狀,把切在哪一層留給呼叫端;元件樹連接線都藏了,只留下殼。

藏得多,不等於藏得深。藏在哪裡,決定了呼叫端還需要知道什麼。

那麼,這個領域的深度在哪裡?

三份臨摹包的是同一份合約。不只是概念上的同一份,而是程式碼上的同一個檔案:transition、triggerAttributes、optionAttributes,三種形狀都從那裡匯入。我想,三個原作也都認出了焦點合約才是要交出去的單位。

用深度去量,這三者大概會疊在同一個位置上。

所以這一格要問的,也許不是深在哪,而是寬在哪。

三者的差別,落在介面寬度。而介面寬度在這裡算的,不是 API 有幾個函式,而是交付的單位有多大:一包 props、一疊行為層,還是一棵元件樹。

Day 20 那個安靜失效的 onKeyDown,一路陪我們走到今天。它在 prop getter 裡出現過,在行為層裡出現過,在 render 函式裡又出現了一次。問題沒有消失,只是在不同形狀裡換了位置。

互動行為結束,準備邁入真實 DOM 的探索

這三天問的都是同一件事:如果 DOM 由你決定,複雜度會跑去哪裡?答案從一包得自己貼上的 props 開始,走到一個看得見的接縫,再走到一棵接好的樹,以及樹上那個把結構還回來的洞。

三種形狀都替你拿走了一些決定。而被拿走的,並不代表不重要。

那如果被拿走的那個,恰好是你非知道不可的呢?

臨摹的 Trigger 只收 render 與 children,所以掛 onKeyDown 會被型別擋下。原作的元件也收一般的 HTML 屬性,事件處理器同樣跟合約的合併、兩個都跑。render 的合併規則、函式形式交出 props,以及公開的 useRender,依 @base-ui/react 1.8.0 發布的 useRender 型別說明與 internals/useRenderElement;asChild 經由 Slot 合併 props,依 @radix-ui/react-slot 1.4.0;皆查核於 2026-10-07。Radix Primitives 目前由 WorkOS 維護,查核於 2026-08-31。

PR #1690 的改法依該 PR 的描述與 diff,它併入後隨 downshift 9.4.0 發布;TanStack Table #5567、TanStack Virtual #736 與 Discussion #10 的內容查核於 2026-09-16,標題與狀態再查核於 2026-10-07。編譯器是 babel-plugin-react-compiler 1.0.0,文中編譯出來的那一段,是用同一個外掛另外輸出的。Base UI 的 GitHub README 自述「From the creators of Radix, Floating UI, and Material UI」,查核於 2026-10-07。

本文對照 downshift 9.4.0、@base-ui/react 1.8.0,查核於 2026-10-07;實驗跑在 react 19.3.0 與 react-dom 19.3.0。


上一篇
【 Day 21 】分層之後,「要切在哪一層」這個決定歸誰?|互動行為(二)
下一篇
【 Day 23 】為什麼我剛畫出來的東西,會讓我剛算好的位置變成錯的?|真實 DOM(一)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言