iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Modern Web

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

【 Day 02 】一個 Hook 的複雜度,到底從哪裡來?

  • 分享至 

  • xImage
  •  

昨天那個不到十行的 usePrevious,最後停在一個沒有答案的地方:我們每天都說某個 Hook 很複雜,卻很少說得清楚,那個複雜度到底是從哪裡長出來的。

「複雜」這個詞的麻煩在於,它同時被拿來形容太多種感覺。程式碼很長是複雜,看不懂是複雜,看得懂但不敢動它也是複雜。三種感覺擠在同一個詞裡面,就沒辦法比較,也沒辦法追問下去。

今天想從三段我自己寫過的 Hook 出發,試著把這個詞拆成幾個可以分開談的概念。

每個字我都看得懂,但我不敢改它

如果使用 useEffect 抓取非同步資料,我們常會用客製化元件來封裝邏輯,為保持簡單,以下只用簡化過的寫法來呈現核心邏輯:

export function useUser(userId: string): User | null {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    let ignore = false;

    fetchUser(userId).then((data) => {
      if (!ignore) setUser(data);
    });

    return () => {
      ignore = true;
    };
  }, [userId]);

  return user;
}

只要稍微熟悉 React,這段程式碼應該是淺顯易懂的:透過 useEffect 抓取用戶資料,然後把資料回傳出來。另外,也宣告了 ignore 變數,清理的時候把它設成 true,然後資料回來的時候用它來判斷要不要 setUser

但老實說,第一次看到 ignore 的邏輯時令我蠻錯愕的,因為在學習 React 時,講師並沒有特別提及。我確實知道這段程式碼怎麼去處理這個變數,卻對它的具體用途所知甚少,進而疑惑:

那如果我把 ignore 相關的三行拿掉呢?

大部分時候,什麼事都不會發生。它通常只有在 userId 已經換掉、而上一個請求才姍姍來遲的時候才會出事 —— 畫面會停在錯的那個人身上。

這其實是一種競態條件 (race condition),比較晚回來的舊資料,把比較新的資料蓋掉了。而 ignore 主要就是來防止這種情況的。拿掉了,就少了一層防線。

這段程式碼裡面沒有任何一個字在講這件事。ignore 為什麼在這裡、拿掉會壞在哪,這些理由都不在這段程式碼裡面。它在寫下這段程式碼的那個人的腦子裡,而三個月後那個人也可能忘了。

💡 處理 API 請求,通常還需要管理 loading 與 error 狀態。另外也可以搭配 Web API 的 AbortController 直接取消請求,不過它取消的是請求本身,ignore 守的則是「要不要寫進狀態」,兩者處理的其實不是同一件事。

讓我們看看另外一個例子,體會一下另一種感覺。

這是一個提供多種管理頁面篩選狀態方法的 Hook:

type Filters = {
  keyword: string;
  tags: string[];
  page: number;
};

export function useFilters() {
  const [filters, setFilters] = useState<Filters>({
    keyword: "",
    tags: [],
    page: 1,
  });

  const reset = useCallback(() => {
    setFilters({ keyword: "", tags: [], page: 1 });
  }, []);

  const isEmpty =
    filters.keyword === "" && filters.tags.length === 0 && filters.page === 1;

  const toQueryString = useCallback(() => {
    const params = new URLSearchParams();

    if (filters.keyword !== "") params.set("keyword", filters.keyword);
    if (filters.tags.length > 0) params.set("tags", filters.tags.join(","));
    if (filters.page !== 1) params.set("page", String(filters.page));

    return params.toString();
  }, [filters]);

  return { filters, setFilters, reset, isEmpty, toQueryString };
}

試著想像,如果現在產品說要多一個排序欄位,我們要如何改動這段程式碼呢?

我數了一下,得動五個地方:Filters 這個型別、useState 的初始值、reset 裡面那份預設值、isEmpty 的判斷式、toQueryString 的序列化。

麻煩的不是五這個數字,而是即使先更新了 Filters 型別,只要我在 isEmpty 的判斷中,或是 toQueryString 的邏輯裡忘了加進新的篩選條件,TypeScript 並不會提醒我,這些疏漏就有可能會悄悄成為日後 Bug 的來源。

這兩種感覺,John Ousterhout 在《A Philosophy of Software Design》裡各給了一個名字:晦澀 (obscurity) 以及 相依 (dependencies)

在抓取資料範例裡的 ignore 是晦澀,重要的資訊沒有出現在明顯的地方;

篩選器的例子則是相依,一個改動牽連到多處程式碼,無法在一個地方完成。

在這本書裡,Ousterhout 把複雜度 (complexity) 定義為「任何與系統結構有關、使它難以理解與修改的東西」,而晦澀與相依,正是他點名的兩個成因。

不過,這本書談的是通用的軟體設計。而我們在這個系列只會專注於 Hook,因此定義上,可能會與書中有些微出入。

讓我們回頭看昨天的 usePrevious,它就像晦澀的一個小型例子:在 useEffect 裡的這段 ref.current = value,它默默決定了「上一次」指的是哪一次,但單看程式碼,卻很難意識得到。

同一個 debounce,兩種對外的承諾

防抖 (debounce) 是一個常見的效能優化邏輯,只要是頻繁觸發事件的操作,就可以考慮使用。也因為如此,它也是最常見的 React 客製化 Hook 之一,以下是其中一種寫法:

export function useDebouncedValue<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

我們可以把這個 useDebouncedValue 應用在搜尋框上,來避免頻繁的 onChange 事件:

const SearchBox = () => {
  const [keyword, setKeyword] = useState("");
  const debounced = useDebouncedValue(keyword, 300);

  return (
    <div>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <Results keyword={debounced} />
    </div>
  );
};

不過目前的 useDebouncedValue 有一個問題:當需求變成「按 Enter 立刻搜尋」時,呼叫端無法要求等待中的 debounce 立即執行,因為 Hook 並沒有把任何控制能力暴露出來。

所以,另一種把計時器 id 回傳出去的版本就產生了:

export function useDebouncedValueWithTimer<T>(
  value: T,
  delay: number,
): { value: T; timerId: ReturnType<typeof setTimeout> | null } {
  const [debounced, setDebounced] = useState(value);
  const timerId = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    timerId.current = setTimeout(() => setDebounced(value), delay);
    return () => {
      if (timerId.current !== null) clearTimeout(timerId.current);
    };
  }, [value, delay]);

  return { value: debounced, timerId: timerId.current };
}

💡 這兩版都是為了讓設計決定現形而寫的,並非可以直接上線的完整實作。其他可能要考慮的邏輯還包括處理卸載時的補送、delay 為 0 的情況,以及 value 是物件時的相等性比較等等。

兩版的功能是一樣的,不過第二版讓呼叫端可以透過拿到的 timerId,來決定清除計時器的時機。不過在享有這項優點的同時,呼叫端多了需要知道的事。

讓我們先看看這個 timerId 在每次渲染時,分別印出什麼:

timerId 序列:null → 52 → 55

數字本身沒有意義,換一台機器就會變。有意義的是它的身分。我們要拿這個 id 去清除計時器,得先知道三件事:

  • 它來自 setTimeout
  • 第一次渲染的時候它是 null
  • 它每次渲染都會換成一個新的

當然,最重要的還是第一項,因為呼叫端才知道要用對應的 clearTimeout 來清理。而這三件事,第一版的呼叫端一件都不用知道。

兩者差別不在功能,在於它們對呼叫端承諾了什麼,多給出來的東西也是一種額外的承諾。相對於第一版只承諾給出「一個延遲過的值」,第二版還承諾了,「你可以管理這個計時器」。

承諾一旦給出去,就不太收得回來了。哪天想把 setTimeout 換成別的排程方式,第一版可以直接換,但第二版沒辦法,因為已經有人拿著那個 id 了。

這件事叫做資訊隱藏 (information hiding):模組的作者要決定哪些實作細節要留在裡面、隱藏起來,哪些決策要變成對外承諾。

第二版並不是寫壞了。它在回答另一個問題:呼叫端有沒有可能比我更知道什麼時候該取消?如果答案是肯定的,那把計時器交出去就是合理的作法。當然,把 timerId 交出去只是比較極端的比方,如果 Hook 回傳的是把清理邏輯封裝的 cancel 函式,承諾的分量也會小一點。

我們也可以從這個角度去看上方 useFilters 的例子。當 filters 的結構暴露出去後,會有越來越多程式開始知道它長什麼樣子。

一旦某個結構被越來越多程式碼認得,不論是在 Hook 裡面還是外面,未來修改它時需要同步調整的地方就會增加。被藏在模組裡的實作細節通常不會有這個問題,因為外面根本不知道它存在。

兩個一模一樣的簽章,一個 20 行,一個 400 行

如果現在有兩份 debounce 的實作擺在面前:

function useDebouncedValue<T>(value: T, delay: number): T;

它們的簽章一模一樣。呼叫端的程式碼一個字都不用改。差別是一份 20 行,另一份 400 行。

要選哪個?

這很看情況。有人說,程式碼是負債,因為隨著程式碼越多,維護成本及認知負擔也會上升,我也認同。

我想討論的是,函式的簽章一樣,代表呼叫端要知道的事情是一樣多的,就防抖而言,可以很輕量的就上述的程式碼範例來達成基本需求,但它同時也忽略掉一些邊緣案例,而這些案例,就可能是那多出的 380 行在處理的,比如說:

  • delay 在等待途中被改掉了,要不要重新計時
  • 值在等待途中又變回原本的值,要不要更新
  • 元件卸載之前,等待中的更新要丟掉,還是補送出去
  • 第一次呼叫要不要立刻給一次,還是一律等
  • 上游一直在變的時候,要不要給一個最長等待的上限

這些情況在 20 行的版本裡不會消失。它們只是不在 Hook 裡面,當元件遇到狀況時,依然要回頭處理。

對呼叫端來說,要使用這兩種 Hook 的實作所必須知道的事情是一樣多的,都是傳入兩個參數,一個是要延遲的值,另一個則是代表毫秒的數字;也就是說,呼叫端接觸到的介面寬度是一樣的,但是藏在介面背後的實作則相差甚遠。

值得一提的是,介面寬度算的不只是參數個數。上方 timerId 的例子就沒有增加參數,卻一樣增加呼叫端的認知負擔。回傳值也是組成介面寬度的一部分。

在《A Philosophy of Software Design》裡,如果以同樣的介面寬度來看,後面承接許多邏輯的,Ousterhout 把這種模組叫做深模組 (deep module)。反之,後面沒承接多少的,則稱做淺模組 (shallow module)

模組是否越深越好?也許這個問題並沒有標準答案。在那 400 行的邏輯裡,可能有一大半在處理我這輩子不會遇到的情況,而它們仍然要被下載、被讀懂、被維護。

深比較像是一個座標,而不是一個分數。

它說的是複雜度被放到那一邊,並不是說那樣放比較對。

想想昨天的 usePrevious,它只有一個參數、一個回傳值,程式碼量不多。它是淺模組。而淺在這裡不是缺點,它能夠封裝的複雜度,本來就很有限。

複雜度、資訊隱藏、深模組

複雜度、資訊隱藏、深模組,是我之後臨摹程式碼時主要思考的三把量尺。

它們通常息息相關。我們可能先看見複雜度,才會想問它被藏到哪裡去了;知道什麼被藏起來,才有辦法問一個模組到底藏了多少;當然,也有可能反過來,從「這個東西藏了什麼」作為起手式。

只是這把尺量的對象,並不是一般的模組。它量的是 Hook,而 Hook 活在 React 裡面。什麼時候被呼叫、能不能提早回傳、狀態改變之後由誰決定要不要重新渲染,這些都不是寫 Hook 的人能決定的。

React 這個場地有它自己的規則。那些規則是什麼,又為什麼會在那裡?我們下一篇聊。


上一篇
【 Day 01 】只讀原始碼,究竟會漏掉什麼?
下一篇
【 Day 03 】React 給 Hook 設計者設下了哪些約束?
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言