2026年8月11日

React useMount Hook:只在掛載時執行一次的正確姿勢 (2026)

「元件出現時,把這段程式碼跑一次。」這是 React 裡最常見的 effect 需求——聚焦輸入框、上報埋點、建立連線、讀取瀏覽器 API。所有人的第一反應都是空依賴陣列的 useEffect

useEffect(() => {
  trackPageView("/checkout");
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

能用,但代價不小:一個必須記得寫的空陣列(忘了寫,effect 就每次渲染都跑);只要 effect 裡碰了任何外部值,就得加一行 lint 抑制註解;最糟的是——意圖完全沒有表達出來useEffect(fn, []) 只說了怎麼做,從沒說為什麼。半年後,同事為了「修掉 lint 警告」往陣列裡加了個依賴,你的「只跑一次」就悄悄變成了「每次變化都跑」。

@reactuses/core 裡的 useMount 就是給這個慣用法起了名字:useMount(fn) 在每次掛載時精確執行一次 fn,而這個名字本身就是文件。本文會講清楚它編譯後到底是什麼、React 18+ StrictMode 讓所有人第一次都愣住的雙重執行、手寫卸載清理裡大多數人沒意識到的過期閉包 bug(以及 useUnmount 怎麼繞開它)、掛載時的非同步操作,以及同樣重要的——哪些場景不該用它。

快速開始

npm install @reactuses/core
import { useMount, useUnmount } from "@reactuses/core";
import { useRef } from "react";

function SearchBox() {
  const inputRef = useRef<HTMLInputElement>(null);

  useMount(() => {
    inputRef.current?.focus();
  });

  useUnmount(() => {
    console.log("搜尋框已移除");
  });

  return <input ref={inputRef} placeholder="搜尋…" />;
}

沒有依賴陣列,沒有 lint 註解,讀程式碼的人不用看函式本體就知道意圖:掛載時執行,僅此而已。

useMount 到底是什麼

沒有任何魔法——刨去一段僅開發環境的型別檢查,這就是完整實作:

export const useMount = (fn: () => void) => {
  useEffect(() => {
    fn?.();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
};

就這樣:一個空依賴陣列的 useEffect,封裝一次,從此不用再寫陣列和抑制註解。從這五行定義能推出三件事:

  1. 時機就是 useEffect 的時機。 回呼在元件提交到 DOM 之後觸發——首幀繪製之後,瀏覽器 API 已就緒。它不是 useLayoutEffect;如果你需要在繪製前測量並修改,請用 layout effect。
  2. 天生 SSR 安全。 effect 在伺服器端根本不會執行,所以 useMount 是 SSR 應用裡存取 window/document 的天然歸宿——和手寫 useEffect(fn, []) 同樣的保證,但意圖寫在了名字裡。
  3. 回傳值會被忽略。 useMount 呼叫 fn?.() 後丟棄結果——它不會把回傳的函式轉交給 React 作為清理函式。清理屬於 useUnmount(見下文)。這個設計還有個副產品:傳 async 函式是安全的,這一點裸的 useEffect 可做不到(稍後細說)。

有一個推論需要刻進腦子裡:因為依賴陣列是空的,回呼閉包捕獲的是首次渲染的值。在 useMount 裡讀到的 props 和 state 被凍結在初始值。對掛載 effect 來說這幾乎總是你想要的——但如果你發現自己想在裡面讀到最新值,那就是訊號:你真正需要的是帶依賴的 useEffect,或者一個 useLatest ref。

StrictMode 陷阱:「為什麼我的掛載 effect 跑了兩次?」

搜「useEffect 執行兩次」,你會看到十年的困惑。短版本是:從 React 18 起,開發環境下的 <StrictMode> 會故意把每個元件掛載、卸載、再掛載一遍。任何掛載 effect——useEffect(fn, []) 也好、useMount 也好——在開發環境都會跑兩次。正式環境只跑一次。

React 是故意的,為的是暴露那些不會自我清理的 effect。官方建議是:別對抗雙重執行,把 effect 寫成冪等的——跑兩次應該無害,因為清理函式會撤銷第一次的效果:

useMount(() => {
  const controller = new AbortController();
  fetch("/api/config", { signal: controller.signal }).then(applyConfig);
  // 配合 useUnmount(() => controller.abort())
});

但有些 effect 確實只該發生一次,跑兩次是真 bug 而不是衛生警告:埋點上報發了兩次、歡迎 toast 彈了兩次、支付意向在開發環境建立了兩次然後 QA 開了工單。針對這些場景,@reactuses/core 提供了 useOnceEffect

import { useOnceEffect } from "@reactuses/core";

useOnceEffect(() => {
  trackPageView("/checkout"); // 只觸發一次,StrictMode 下也是
});

內部的技巧很精巧:useOnceEffect 在執行前把每個 effect 函式記錄進一個 WeakSet。StrictMode 的重掛載複用的是同一次渲染產生的同一個 effect 函式實例,第二次呼叫發現已被記錄,直接跳過。而真正的重新掛載(元件真的離開又回來)會產生新的函式,照常執行——恰好就是「每次掛載一次、無視 StrictMode 彩排」的語義。

經驗法則:預設 useMount + 冪等;當雙重觸發會被使用者或後端觀察到時,用 useOnceEffect

useUnmount——以及它避開的過期閉包坑

最直覺的手寫卸載清理,藏著一個大多數人上線了都沒發現的 bug:

// ⚠️ 手寫版本
useEffect(() => {
  return () => {
    saveDraft(draft); // 第一次渲染的 draft——永遠是空字串!
  };
}, []); // 空依賴 ⇒ 清理閉包建立於第 1 次渲染

清理函式在首次渲染時建立,捕獲的是首次渲染的 draft。三分鐘、四十次按鍵之後元件卸載,它儲存的是一個空字串。把 draft 加進依賴陣列的「修法」更糟——清理函式變成每次按鍵都執行,而不是卸載時執行。

useUnmount 正確地解決了這個問題。它內部把你的回呼存進一個每次渲染都更新的 useLatest ref,卸載清理時透過 ref 呼叫:

import { useUnmount } from "@reactuses/core";

function Composer() {
  const [draft, setDraft] = useState("");

  useUnmount(() => {
    saveDraft(draft); // ✅ 最後一次渲染時的 draft
  });

  return <textarea value={draft} onChange={e => setDraft(e.target.value)} />;
}

你的回呼精確執行一次、在卸載時執行、並且看到最新的 state。這就是清理邏輯讀取 state 或 props 時應該用 useUnmount 而不是 return () => {} 慣用法的具體理由——它不是語法糖,是 bug 修復。

掛載時的非同步操作

useEffect 出了名地拒絕非同步函式——useEffect(async () => {...}, []) 遞給 React 的是一個 Promise,而 React 期望的是清理函式,結果是一條警告加一個被跳過的清理。而 useMount 會丟棄回呼的回傳值,所以非同步回呼完全沒問題:

useMount(async () => {
  const user = await fetchCurrentUser();
  setUser(user);
});

它不提供的是對「元件在 await 中途卸載」的保護——卸載後呼叫 setUser 在 React 18+ 裡無害,但往往仍不是你想要的(你可能正在寫入一個會被重新掛載的實例覆蓋的狀態)。庫裡有兩個答案:

  • useMountedState 回傳一個由 ref 支撐的 isMounted() 函式——每個 await 之後檢查一下:

    const isMounted = useMountedState();
    
    useMount(async () => {
      const user = await fetchCurrentUser();
      if (isMounted()) setUser(user);
    });
  • useAsyncEffect 把這個模式泛化到帶依賴的 effect,給你的非同步函式本體一個存活檢查,並支援清理。

真正的資料請求——帶快取、去除重複、重試——這兩個都會不夠用,那是 React Query / SWR 或框架 loader 的領域。useMount 負責的是邊緣處那些一次性的命令式操作。

鏡像需求:跳過掛載

有時你要的恰恰相反——回應變化,但不回應首次掛載。把篩選條件同步到 URL,但首次載入不要改寫 URL;設定變更時提示「已儲存」,但剛進頁面時不要提示。這就是 useUpdateEffect,以及它的底層兄弟 useFirstMountState——後者只做一件事:告訴你當前是不是首次渲染:

import { useUpdateEffect } from "@reactuses/core";

useUpdateEffect(() => {
  syncFilterToUrl(filter); // filter 變化時執行,跳過掛載
}, [filter]);

這四個 hook 合起來,覆蓋了 class 時代用 componentDidMount / componentDidUpdate / componentWillUnmount 拼出來的整套生命週期詞彙:

你想讓程式碼執行的時機…用哪個
一次,元件出現之後useMount
一次,StrictMode 開發環境雙重執行下也只一次useOnceEffect
元件被移除時,且能讀到最新 stateuseUnmount
僅在更新時,跳過首次渲染useUpdateEffect
按「是否首次渲染」做條件分支useFirstMountState

什麼時候不該用 useMount

誠實環節。useMount 是給一個真實 React 原語起名字的糖,而有些時候原語本身才是對的:

  • effect 讀取的 prop 或 state 會變化。 如果 roomId 變了需要重連,那就是 useEffect(connect, [roomId])——在這裡用掛載 hook 是一個披著便利 API 外衣的同步 bug。這種情況下空陣列不是繁文縟節,是錯的。
  • 你在為渲染取伺服器端資料。 框架 loader、React Query、SWR——任何帶快取、去除重複、重新驗證的方案都勝過掛載 effect 裡的一次 fetch。React 官方文件自己都不再把「在 useEffect 裡 fetch」當主推模式了。
  • 你需要繪製前測量。 useMount 在繪製之後。先測量再修改的工作屬於 layout effect。
  • 「掛載事件」其實是使用者事件。 如果程式碼可以放在導致元件出現的那次點擊的處理函式裡,就放在那裡——effect 是用來和外部系統同步的,不是雜物抽屜。

檢驗只需一個問題:*元件存活期間,這段程式碼有沒有可能需要重新執行?*只要答案是任何形式的「是,當 X 變化時」,你要的就是 useEffect 加依賴陣列。如果是乾脆的「否」,useMount 會把這件事說出來,而 [] 永遠不會。

要點回顧

  • useMount(fn) 就是把意圖寫進名字的 useEffect(fn, [])——沒有會忘寫的陣列、沒有 lint 抑制註解;首次渲染閉包語義應該擁抱,而不是對抗。
  • React 18+ 開發環境 StrictMode 下所有掛載 effect 都跑兩次。預設把 effect 寫成冪等;當雙重觸發對使用者或後端可見時,用 useOnceEffect
  • 空依賴 + 手寫 return () => {} 清理會捕獲首次渲染的 state——是真實存在、正在線上跑的 bug。useUnmount 透過 latest-ref 讀取,看到的是最終 state。
  • useMount 接受 async 回呼(回傳值被丟棄);await 之後的狀態寫入用 useMountedState 守衛,或改用 useAsyncEffect
  • 只回應變化的 effect,用鏡像 hook useUpdateEffect

useMountuseUnmountuseOnceEffect 以及另外 110+ 個 SSR 安全、TypeScript 優先的 hook 都在 @reactuses/core 裡——一次安裝,可 tree-shake,零依賴負擔。

npm install @reactuses/core