iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Modern Web

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

【 Day 04 】兩個元件要同一份遠端資料,這份資料是誰的?

  • 分享至 

  • xImage
  •  

前幾天談的是臨摹的理由,以及 React 對 Hook 的約束。今天讓我們進入六個設計問題的第一關:遠端資料的複雜度 —— 這份資料是誰的?

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

先從上篇文末提到的那個畫面開始:兩個元件,共用一份從伺服器來的資料。

兩個元件,各自抓取一份資料

假使頁面上有兩個元件,一個顯示待辦事項的數量,一個顯示清單,它們需要是同一份 /api/todos 的資料。

我們先把抓取資料邏輯包成一個 Hook,讓兩個元件各自呼叫:

import { useEffect, useState } from "react";

type Todo = {
  id: number;
  title: string;
};

const fetchTodos = async (): Promise<Todo[]> => {
  console.log("送出一次請求");
  const response = await fetch("/api/todos");
  return response.json();
};

export function useTodos(): { todos: Todo[] | null; isLoading: boolean } {
  const [todos, setTodos] = useState<Todo[] | null>(null);
  const [isLoading, setIsLoading] = useState(true);

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

    fetchTodos().then((data) => {
      if (ignore) return;
      setTodos(data);
      setIsLoading(false);
    });

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

  return { todos, isLoading };
}

兩個元件各自呼叫它:

const TodoCount = () => {
  const { todos, isLoading } = useTodos();
  if (isLoading) return <span>載入中…</span>;
  return <span>{todos?.length ?? 0} 件待辦</span>;
};

const TodoList = () => {
  const { todos, isLoading } = useTodos();
  if (isLoading) return <p>載入中…</p>;
  return (
    <ul>
      {todos?.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
};

const Page = () => (
  <>
    <TodoCount />
    <TodoList />
  </>
);

這樣寫畫面確實能夠正常運作,但打開主控台,你會發現印出兩行:

送出一次請求
送出一次請求

那是 fetchTodos 裡的 console.log:這份資料被抓了兩次,存成兩份狀態,畫面上也有兩個各自跑的載入中。

這算是一個錯誤嗎?分開看,兩個元件各自都做了該做的事:我要這份資料,所以我去拿。

但問題在於,它們彼此不知道對方的存在。

這不是 fetch 的問題,是同一份資料有兩個消費者,而沒有人負責它的生命週期

所以,這份資料是誰的?

把資料搬到元件外面

既然兩個元件要的是同一份資料,那我們可以在元件外部放置一個 Map,用資料的名字當 key,把早到的 Promise 存進去,如果之後有人要同一份資料,就接上同一個 Promise

const shared = new Map<string, Promise<unknown>>();

export function readShared<T>(
  key: string,
  fetcher: () => Promise<T>,
): Promise<T> {
  const hit = shared.get(key);
  if (hit) return hit as Promise<T>;

  const promise = fetcher();
  shared.set(key, promise);
  return promise;
}

export function dropShared(key: string): void {
  shared.delete(key);
}

之所以用 Map,是因為它提供了簡單的索引,讓我們能夠用 key 找到共享資源。

存進去的是 Promise 而不是取回來的資料,這是因為第二個元件掛上的時候,第一個元件的請求通常還在路上,那時候還沒有資料可以存;如果只存資料,兩個元件仍然有可能會各自送出一次請求。

當然,這是試著把資料的歸屬,從「元件」轉移到「元件之外的地方」的嘗試,距離上線還有很長一段距離。

有了這個共享資源的地方之後,我們就可以把 useTodos 直接抓取遠端資料的 fetchTodos,換成 readShared("todos", fetchTodos)

export function useTodos(): { todos: Todo[] | null; isLoading: boolean } {
  const [todos, setTodos] = useState<Todo[] | null>(null);

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

    readShared("todos", fetchTodos).then((data) => {
      if (!ignore) setTodos(data);
    });

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

  return { todos, isLoading: todos === null };
}

換成這個邏輯後,主控台這次只印一行「送出一次請求」,代表這段邏輯確實能夠成功解決重複向同一個 API 請求資料的問題。

讓我們來比較一下前後兩次的資料流:

  • 第一版,由兩個元件各自發出請求給同一個遠端資料:

    
    TodoCount ── fetch ──┐
                         │
                         ▼
                     Remote Data
                         ▲
                         │
    TodoList  ── fetch ──┘
    
    
  • 第二版,把請求轉移至 Map 上,讓需要同份遠端資料的元件共用:

    
    TodoCount ──┐
                │
                ▼
            Shared Map ── fetch ── Remote Data
                ▲
                │
    TodoList ───┘
    
    

共享 Map 沒有回答的部分

現在,如果我們把那兩個元件放進一個可以收合的區塊,然後透過按鈕來切換是否顯示:

const Page = () => {
  const [visible, setVisible] = useState(true);

  return (
    <>
      <button type="button" onClick={() => setVisible((v) => !v)}>
        {visible ? "隱藏待辦" : "顯示待辦"}
      </button>
      {visible && (
        <>
          <TodoCount />
          <TodoList />
        </>
      )}
    </>
  );
};

按下「隱藏待辦」,兩個元件都會被卸載;再按一次讓它們重新掛載。主控台的輸出是這樣:

第一次顯示:  送出一次請求
隱藏待辦:    (沒有輸出)
再次顯示:    (沒有輸出)

當元件重新掛載的時候沒有再送出請求。這通常是我們想要的,畢竟是已經抓取過的資料,不必重來一次。

然而,這也代表:那份資料依然留在 shared 裡,而 shared 是一個模組層級的變數。即使兩個元件都卸載了,它仍然會在那裡。

💡 元件第二次掛上時,資料明明還在 Map 裡,但畫面還是會閃一次「載入中」:這是因為取得資料的邏輯是在 useEffect 中,副作用觸發與畫面渲染完成會有一段時間差。這裡先不處理這個問題。

我們其實有寫 dropShared 來刪除裡面的請求,問題在於,應該由誰在什麼時候使用這個方法?

如果交給元件在卸載時呼叫,看起來很自然,卻馬上碰到一個問題:另一個元件可能還開著。比如說,我們把上面條件渲染的範圍縮小,讓它只作用於一個元件:

const Page = () => {
  const [visible, setVisible] = useState(true);

  return (
    <>
      <button type="button" onClick={() => setVisible((v) => !v)}>
        {visible ? "隱藏數量" : "顯示數量"}
      </button>
      {visible && <TodoCount />}
      <TodoList />
    </>
  );
};

當我們點擊「隱藏數量」,把 TodoCount 收起來的時候,TodoList 還在用同一份資料。

於是問題變成「還有沒有別人在看」。而這個版本沒有地方可以回答它 —— 從頭到尾,它沒有記下任何一個人。

沒有人被記下,也就沒有人會離開。對那個 Map 來說,曾經出現過的 key 會永遠存在;它不知道誰正在使用,也不知道誰早就離開了。

💡 另外,失敗的請求會留下「已經被拒絕的 Promise」,它同樣會一直留在 Map 當中,而且之後每個要這份資料的元件都會拿到這個被拒絕的 Promise

所有權搬離了元件

回頭看這兩個版本的差別,真正換掉的不是抓取的方式,是這份資料的所有權。

第一個版本裡,資料屬於元件。兩個元件各自持有一份,因此有兩份狀態、兩個載入中,而兩邊都不知道對方。第二個版本裡,資料屬於 Map,元件只是去讀它。

我們換用 Map 來實踐共享資源的同時,也做了一個決定:這份資料不再屬於任何一個元件。

這個決定解決了相同請求執行多次的問題。它同時也帶來另一種狀況:原本由元件負責的事情,現在沒有人負責了。元件的生死很明確,掛上、卸載,React 會告訴你。離開了元件,那份資料的生死由誰決定,就變成一個必須另外回答的問題。

擁有一份資料,要負的責任不只是在有人要的時候把它給出來,也要在適當的時機,讓資料離去。

現在持有這份資料的,不再是元件,而是那個共享資源。

而一旦開始持有資料,它很快就會變成我們熟悉的另一種東西:快取 (cache)。

但快取怎麼知道,還有沒有人在看?

💡 本文是以 @tanstack/react-query 5.102.8、swr 2.5.1 為基礎,於 2026-08-31 檢閱。


上一篇
【 Day 03 】React 給 Hook 設計者設下了哪些約束?
下一篇
【 Day 05 】這份資料什麼時候算舊,什麼時候該消失?
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言