iT邦幫忙

2026 iThome 鐵人賽

DAY 2
1
Modern Web

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

【 Day 01 】只讀原始碼,究竟會漏掉什麼?

  • 分享至 

  • xImage
  •  

昨天我在前言提到,這個系列會用「臨摹」的方式來理解函式庫。但如果原始碼就公開在那裡,為什麼不是打開來讀過一遍就好?今天想先用一個不到十行的 Hook,談談我對這個問題的答案。

一個不到十行的實驗

如果你寫過 React,大概會看過 usePrevious 這種簡單的客製化 Hook。它通常會結合 useRefuseEffect,用來記住某個值在前一次渲染時的內容。以下是常見的寫法:

import { useEffect, useRef } from "react";

export function usePrevious<T>(value: T): T | null {
  const ref = useRef<T | null>(null);

  useEffect(() => {
    ref.current = value;
  });

  return ref.current;
}

這個 Hook 並不複雜,既沒有非同步、也沒有訂閱及清理邏輯,單看程式碼,只要對 React 有基本的認識,要理解它的運作並不困難,至少我是這麼想的:它利用參考能在元件跨渲染間保存,且改變不觸發重新渲染的特性,讓元件能夠先拿到上次渲染的舊狀態,再把最新狀態存起來給下次渲染使用。

我們可以把這個 Hook 拿去應用在基本的計數器上,用它來紀錄每次渲染後,上一次的計數值:

import { useState } from "react";

const Counter = () => {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
      <>
        <h2>現在:{count}</h2>
        <h3>上一次:{prevCount}</h3>

        <button onClick={() => {
          setCount(c => c + 1)
        }}>
          +1
        </button>
      </>
  )
}

目前,usePrevious 這個 Hook 確實能夠正常運作。按了一次 +1,數字會從 0 變成 1,「上一次」正確地顯示 0。

但是,如果我們在 Counter 加上另一個會觸發狀態改變的元件,比如說切換深淺模式的主題按鈕,當我們點擊它,你會發現,明明這個按鈕與 count 狀態無關,但它上一次的值卻再次跳動,變成了 1:

現在:1
上一次:1

usePrevious 其實並沒有壞掉,這正是它遵守自己的定義運作出來的結果。

「上一次」是哪一個上一次?

問題出在,我們並沒有回答過一個問題:這個 previous 是「什麼」的 previous?

而它至少有兩個答案:

  • 上一次渲染時,這個值是多少。
  • 上一次這個值「改變」時,它是多少。

當前的 usePrevious 實作回答的是前者。切換主題觸發了一次重新渲染,count 沒變,但「上一次渲染」確實發生了,它也誠實地回報了那一次渲染的值,也就是 1。

如果我們希望的是第二個答案,就必須另外定義「當前參考」來與傳入的參數比對:

import { useRef } from "react";

export function usePrevious<T>(value: T): T | null {
  const current = useRef<T | null>(null);
  const previous = useRef<T | null>(null);

  if (!Object.is(current.current, value)) {
    previous.current = current.current;
    current.current = value;
  }

  return previous.current;
}

先不要把這個實作當成 production-ready 的實作。它是一個很小的實驗:把「值改變」與「渲染發生」這兩種定義直接寫成兩份不同的行為,看看它們在哪裡產生差異。

同樣不到十行,同樣的名字,同樣的簽章,同樣沒有非同步、沒有訂閱。把兩版實作放進同一個元件裡跑同一串操作,會產生不一樣的結果:

# 這次渲染的原因 count 版本 A 回傳 版本 B 回傳
1 初次渲染 0 null null
2 +1 1 0 0
3 切換主題(count 沒變) 1 1 0
4 +1 2 1 1

第 3 列就是兩種設計開始分岔的地方。而這個差異會不會影響到你,取決於另一件事:這個元件除了那個值以外,還會不會因為別的理由重新渲染。 在這個例子裡,造成重新渲染的是一顆主題按鈕;而在真實專案裡,它可以是父層的任何一次重新渲染、一個上下文 (context) 更新、一個不相干的狀態改變。

我以為我懂了,然後才發現有問題

我想留住的不是這次實驗的差異,是那個落差。讀第一份實作的時候,我的理解其實沒有錯。我確實看懂了每一行程式碼扮演的角色。

我遺漏掉的,是那裡有一個決定

因為讀程式碼的時候,previous 這個字會被我的大腦自動補完。我當下想解決的是「值變了,我要拿舊的來比對」,於是我讀到的 previous 就自動是「上一次改變時的值」。

程式碼沒有反駁我,因為程式碼裡沒有一行寫著「previous 指的是上一次渲染」,這句話不在檔案裡,它藏在這份實作所採用的行為定義裡。

比較容易在檔案裡看見的,是「怎麼做」;至於「為什麼是這個做法,而不是另一個」,有時候需要把另一個做法放在旁邊,差異才會顯形。

而這件事往往要等到我自己開始做的時候,才會變得明顯。不是讀的時候,而是我必須自己決定每一次渲染該回傳什麼的時候。

還有一件事值得說清楚:這兩份實作並不是簡單地分成「錯的」和「對的」。

版本 A 適合描述「我想知道前一次渲染看到的是什麼」;版本 B 則是比較接近「我想保留上一個不同的值」。

💡 如果是生產環境的實作,可能還要考慮到「在渲染階段修改參考」、「並行渲染」(concurrent rendering) 及「嚴格模式」等 React 行為。這裡刻意保持簡單。

同一個 usePrevious,光是「previous 到底是什麼」這件事,就足以產生不同的設計。這些差異比較像是一個設計決策,而非哪行程式碼寫錯了。

十行的東西尚且如此。那幾千行的函式庫呢?

打開它的原始碼,我看得到它怎麼做;但它為什麼在某個岔路口往左而不是往右,那個被放棄的選項通常不會出現在檔案裡。

就像解數學題目時,跳過親自解題的步驟,直接讀答案那樣,讀完只會得到一種平滑的感覺:它就是這樣寫的、看起來很合理。但這個合理,有時候只是因為我沒有同時看見另一種寫法。

而我想透過臨摹,試著把那個不在檔案中的選項重新找出來。看看不同的函式庫是如何做出不同選擇的,再跑同一組測試,觀察差異出現在哪裡。

那些差異,可能就是原作者當時做過的某個決定留下來的痕跡。

那個沒被寫下來的決定,通常在回答什麼?

usePrevious 的落差只有一格表格那麼大,因為它替呼叫端擋掉的複雜度也只有一格那麼大 —— 它不過是在幫你記住一個值。

真正讓我們每天在 package.json 裡裝上別人寫的 Hook 的,是大得多的東西。而那些東西並不是混成一團的「很複雜」,它們有形狀,會在不同的地方長出來,也會逼出不同的解法。

根據我目前的粗略分類,大概有六種。

這一篇不打算逐一解釋它們。因為如果現在就替每一種問題下摘要,可能反而會讓問題本身變得模糊。所以,暫且先直接把這六句話放在這裡。

三十天要問的六個問題

在這三十天中,我會挑六個 React 開發者每天都會碰到的設計問題,每個問題找兩三個成熟的函式庫當作不同的答案:

  • 遠端世界的複雜度 —— 這份資料是誰的?(遠端世界)
  • 共享狀態的複雜度 —— 共享狀態變了,哪些人該知道?(共享狀態)
  • 使用者輸入的複雜度 —— 每次擊鍵,誰該被通知?(使用者輸入)
  • 瀏覽器 API 的複雜度 —— 包一層,就算封裝了嗎?(瀏覽器 API)
  • 互動行為的複雜度 —— 如果 DOM 由你決定,複雜度會跑去哪裡?(互動行為)
  • 真實 DOM 的複雜度 —— 畫出來之後,你算的位置還算數嗎?(真實 DOM)

這六個問題,你大概都曾經遇過。當然,可能不是用「我今天要處理遠端資料的生命週期」這種態度來面對,不過如果用「這裡怎麼又發一次請求」、「這個元件為什麼沒有重新渲染」的說法,你可能就會因熟悉而會心一笑。

現象其實不陌生,只是它們可能還沒有被放到同一張地圖上。而我的作法傾向先讓現象站出來,把它重新描述成一個設計問題,然後才去看成熟的函式庫怎麼回答。

有趣的是,它們的答案往往不只是不同,而且彼此矛盾。同一個問題,兩個都被幾十萬個專案驗證過的答案,為什麼可以互相矛盾?如果兩邊都對,那它們一定是在回答稍微不一樣的問題,或者對呼叫端做了不一樣的假設。找出那個假設,就是這三十天我真正想做的事。

而今天這不到十行的例子想先說明的是:那個假設通常不會自己從螢幕上跳出來。

這三十天我實際上會做什麼

每個問題我會寫出最小的臨摹實作,目的是讓不同的設計決策現形,而非生產能用的套件,然後讓同一個問題的幾份實作共用同一組測試,來看看哪幾條測試出現差異、哪裡開始分叉,就會成為我們觀察設計立場的地方。

文章因為篇幅考量,只會放上關鍵的程式碼段落。至於完整的程式碼,我會附上固定的三個問題,放到公開的 Github Repo 的 mimesis/ 目錄當中。

這三個問題包括:

  • 「我想理解什麼?」
  • 「我實作了什麼?」
  • 「我刻意沒有實作什麼?」

如果這個不到十行的 Hook,都藏著一個我一開始沒看見的設計決定,那麼那些幾千行、幾萬行的函式庫裡,可能還有更多值得被重新看見的東西。

接下來的三十天,我想試著把它們一個一個找出來。

只是在開始找之前,也許得先有一把尺。我們每天都說某個 Hook 很複雜,卻很少說得清楚,那個複雜度到底是從哪裡長出來的。


上一篇
【 Day 00 】寫在再造輪子之前
下一篇
【 Day 02 】一個 Hook 的複雜度,到底從哪裡來?
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言