HeyBinyang

閉包陷阱與 useCallback: 為什麼你總是拿到舊值?

·技術·visibility0 閱讀 ·開發·React

如果說前一篇解決的是「哪些邏輯不該放進 useEffect」,那這一篇要解決的,就是那些留下來的 effect 和回呼,為什麼還是經常出現一些古怪行為。

你大概見過這些場景:

  • 計時器裡印出來的 state 永遠是舊的。

  • 請求回呼裡拿到的參數不是目前輸入框的值。

  • 給子元件傳了個函式,結果為了「優化」加上 useCallback 之後,邏輯反而壞了。

  • ESLint 提示依賴沒寫全,你一補上就瘋狂重跑;你一刪掉,又開始出現詭異 bug。

這些問題表面上像是 React 的坑,實際上,它們是 JavaScript 閉包和 React 按渲染快照工作這兩件事,疊加在一起的結果。

一、先把問題說透:什麼叫「舊值」?

先看一個最經典的例子:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log('count:', count);
    }, 2000);

    return () => clearInterval(id);
  }, []);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

很多人第一次寫到這種程式碼時,直覺會覺得:

  • count 變了,計時器裡應該也能看到最新的 count。

但實際情況是:

  • effect 只在初次渲染後執行一次。

  • setInterval 裡的回呼閉包住的是第一次渲染時的 count

  • 所以後面不管你點多少次按鈕,主控台裡看到的都還是 0。

這就是所謂的 stale closure,也就是「陳舊閉包」問題。

要理解它,有一個非常關鍵的心智模型:

React 函式元件的每一次渲染,都會產生一套新的 props、state 和函式;你在這次渲染裡定義的 effect、回呼、事件處理器,拿到的是這一版渲染的快照。

所以不是 React 「沒更新」,而是你建立出來的那個函式,本來就活在舊的那次渲染裡。

二、為什麼依賴陣列會和閉包扯上關係?

依賴陣列的本質,不是「優化項」,而是在告訴 React:

  • 這個 effect / memo / callback 用到了哪些響應式值;

  • 當這些值變化時,你需要重新建立這段邏輯。

TkDodo 對這個問題講得很透:Hooks 中的依賴關係,本質上就是在管理閉包什麼時候該更新。

還是上面那個例子,如果你把 count 寫進依賴:

useEffect(() => {
  const id = setInterval(() => {
    console.log('count:', count);
  }, 2000);

  return () => clearInterval(id);
}, [count]);

這時候行為就變了:

  • 每次 count 變化,effect 都會先清理舊計時器,再建立一個新計時器。

  • 新計時器閉包住的是新的 count。

所以結論不是「依賴陣列決定何時執行」,而更準確地說: > 依賴陣列決定 React 什麼時候應該丟棄舊閉包,換成一份新的閉包。

這也是為什麼漏依賴會出 bug。不是因為 React 在懲罰你,而是因為你明明用了一個值,卻沒告訴 React 這份邏輯應該隨著它一起更新。

三、閉包陷阱最常見的 4 種場景

1. 計時器 / 輪詢

這個最典型,前面已經看過。 問題根源:計時器回呼不會自動「連到最新 state」,它拿的是定義它時的閉包。

錯誤寫法:

useEffect(() => {
  const id = setInterval(() => {
    fetchData(query);
  }, 5000);

  return () => clearInterval(id);
}, []);

如果 query 會變化,這裡大概率就是 bug。

常見解法有兩類:

解法 A:老實寫依賴

useEffect(() => {
  const id = setInterval(() => {
    fetchData(query);
  }, 5000);

  return () => clearInterval(id);
}, [query]);

優點:

  • 最直觀。

  • 沒有隱藏狀態。

缺點:

  • query 每變一次,計時器都會被重建一次。

  • 如果你不希望外部訂閱頻繁重建,這個做法就不合適。

解法 B:用 ref 儲存最新值

const queryRef = useRef(query);

useEffect(() => {
  queryRef.current = query;
}, [query]);

useEffect(() => {
  const id = setInterval(() => {
    fetchData(queryRef.current);
  }, 5000);

  return () => clearInterval(id);
}, []);

這個模式的本質是:

  • 計時器只註冊一次;

  • 但它讀取的資料來自一個始終更新的 ref,而不是舊閉包。

什麼時候用 A,什麼時候用 B?

  • 如果允許重建,優先使用依賴陣列,簡單直接。

  • 如果不希望重建外部訂閱,再考慮 ref 儲存最新值。

2. 非同步請求裡的舊參數

再看一個更接近業務的例子:

function SearchBox() {
  const [keyword, setKeyword] = useState('');

  useEffect(() => {
    async function search() {
      const res = await fetch(`/api/search?q=${keyword}`);
      const data = await res.json();
      console.log(data);
    }

    search();
  }, []);
}

如果這裡依賴陣列是空的,那這個請求永遠只會用初始 keyword。 這不是「偶發 bug」,而是確定性錯誤。

正確寫法通常是:

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

  async function search() {
    const res = await fetch(`/api/search?q=${keyword}`);
    const data = await res.json();
    if (!ignore) {
      console.log(data);
    }
  }

  search();

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

這裡順便處理了另一個問題:競態。 React 官方文件明確提醒過,effect 裡的非同步請求除了依賴要正確,還要防止舊請求在新請求之後返回,導致結果倒灌。

3. 事件回呼裡用舊 state

一個非常隱蔽但常見的坑:

const handleAdd = useCallback(() => {
  setCount(count + 1);
}, []);

看起來像在「優化」函式參考,實際上邏輯已經壞了:

  • 依賴陣列是空的,所以這個 callback 永遠是第一次渲染時那一版。

  • 它裡面的 count 永遠是初始值。

  • 每次點按鈕都在執行 setCount(0 + 1)

這也是為什麼很多文章強調:

useCallback 不是用來「讓邏輯正確」的,而只是讓函式參考更穩定;如果邏輯本身依賴沒寫對,它只會把錯誤穩定下來。

這裡正確的改法有兩種。

改法 A:把依賴寫全

const handleAdd = useCallback(() => {
  setCount(count + 1);
}, [count]);

改法 B:如果是基於舊 state 計算新 state,用函式式更新

const handleAdd = useCallback(() => {
  setCount(prev => prev + 1);
}, []);

第二種通常更好,因為它不再直接依賴 count,也就不容易產生舊值問題。

這條經驗特別重要:

只要你的下一個 state 依賴上一個 state,優先使用函式式更新,而不是閉包裡直接讀當前 state。

4. 給子元件傳回呼時「優化過頭」

很多人學了 React.memouseCallback 之後,容易進入一個階段: 看到函式就包一層 useCallback,好像不包就不專業。

例如:

const handleSelect = useCallback((id: string) => {
  onChange(id, filters);
}, []);

這段程式碼的問題不是 useCallback 本身,而是它閉包住了舊的 filtersonChange。 如果這兩個值會變,那這個 callback 就是錯的。

正確寫法應該是:

const handleSelect = useCallback((id: string) => {
  onChange(id, filters);
}, [onChange, filters]);

這時候又有人會說: 「那不是每次還是會變嗎?包 useCallback 有什麼意義?」

答案是:如果依賴本來就總變,那 useCallback 可能本來就沒意義。

這是 useCallback 最容易被誤解的一點:

  • 它不是「讓函式不變」的魔法。

  • 它只是「當依賴不變時,重複使用舊函式;依賴變了,就給你新函式」。

所以別把它當護身符。

四、useCallback 到底是幹什麼的?

先說最準確的一句話:

useCallback 用來快取函式參考,不是用來修復閉包,也不是預設的效能最佳化按鈕。

它主要有兩個典型用途。

用途 1:搭配 memoized 子元件,避免無意義重渲染

const Item = React.memo(function Item({
  onSelect,
  item,
}: {
  onSelect: (id: string) => void;
  item: Item;
}) {
  return <button onClick={() => onSelect(item.id)}>{item.name}</button>;
});

function List({ items }: { items: Item[] }) {
  const handleSelect = useCallback((id: string) => {
    console.log(id);
  }, []);

  return items.map(item => (
    <Item key={item.id} item={item} onSelect={handleSelect} />
  ));
}

這裡 useCallback 的意義是:

  • 如果父元件每次 render 都建立新函式,React.memo 子元件可能仍然會因為 props 參考變了而重新渲染。

  • 把函式參考穩定下來後,memo 才更容易發揮作用。

用途 2:作為其他 Hook 的依賴,避免無意義重跑

例如:

const fetchUser = useCallback(async () => {
  const res = await api.getUser(userId);
  setUser(res);
}, [userId]);

useEffect(() => {
  fetchUser();
}, [fetchUser]);

這時 fetchUser 作為 effect 的依賴,useCallback 讓它只在 userId 變時才變化。

但要注意一個更值得推薦的寫法:

useEffect(() => {
  async function fetchUser() {
    const res = await api.getUser(userId);
    setUser(res);
  }

  fetchUser();
}, [userId]);

如果函式只在 effect 裡用,把它放進 effect 通常更簡單,也比較不會糾結依賴。

五、什麼時候不該用 useCallback?

這是最實用的一節。

很多團隊的問題不是「不會用 useCallback」,而是「用太多了」。

下面這些場景,通常都不值得包:

1. 函式沒有傳給 memo 子元件,也沒有作為依賴

const handleClick = () => {
  setOpen(true);
};

這種函式完全沒必要包 useCallback

因為:

  • 它不參與效能瓶頸。

  • 它不作為依賴。

  • 包了只會增加認知負擔。

2. 包了之後依賴一大串,還是每次都變

const handleSave = useCallback(() => {
  submit(form, user, settings, permissions, locale);
}, [form, user, settings, permissions, locale]);

如果依賴本來每次都在變,這個 callback 基本等於沒快取。 這時候真正該做的,往往不是繼續包,而是重新拆狀態、瘦身依賴或重構元件邊界。

3. 把 useCallback 當成「解決閉包」的預設手段

這是最大誤區。

useCallback 不會自動拿到最新值; 它只是幫你快取一份函式,而這份函式照樣有閉包。 如果依賴寫錯,useCallback 只會把錯誤快取得更久。

所以一個非常重要的判斷是:

程式碼如果「不用 useCallback 就會錯」,那通常不是說明你該加 useCallback,而是說明你本來的資料流就有問題。

六、遇到閉包問題時,優先按這個順序排查

這裡給你一套非常適合 code review 和日常排錯的順序。

第一步:先懷疑依賴陣列是不是少寫了

只要 effect、memo、callback 裡用到了響應式值,就先檢查依賴是否完整。

不要第一反應就是:

  • 「這是不是 React 的 bug?」

  • 「是不是 useCallback 不穩定?」

  • 「是不是非同步有問題?」

大多數時候,先把依賴寫對,問題就消失了。

第二步:如果只是基於舊 state 更新新 state,改成函式式更新

例如:

setTodos(prev => [...prev, newTodo]);
setCount(prev => prev + 1);

這種寫法可以顯著減少你對閉包中當前 state 的依賴。

第三步:如果外部訂閱不想重建,用 ref 讀最新值

這是處理「計時器 / 監聽器 / 第三方訂閱」類閉包問題的通用招式。

但注意,ref 是工具,不是逃生通道。 如果只是普通 effect,本來就應該隨著依賴更新,那還是優先寫依賴。

第四步:只在真的有收益時再上 useCallback

判斷標準可以很簡單:

  • 有沒有搭配 React.memo

  • 有沒有作為其他 Hook 的依賴?

  • 有沒有實測效能問題?

如果都沒有,那大概不用。

七、一個完整重構例子:從「舊值地獄」到可維護寫法

先看壞程式碼:

function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');

  const handleSearch = useCallback(() => {
    onSearch(query);
  }, []);

  useEffect(() => {
    const id = setInterval(() => {
      handleSearch();
    }, 5000);

    return () => clearInterval(id);
  }, []);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

這裡有兩個 stale closure:

  • handleSearch 永遠拿的是初始 query。

  • 計時器 effect 永遠拿的是第一次的 handleSearch。

一種直接但會頻繁重建的修法:

function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');

  const handleSearch = useCallback(() => {
    onSearch(query);
  }, [onSearch, query]);

  useEffect(() => {
    const id = setInterval(() => {
      handleSearch();
    }, 5000);

    return () => clearInterval(id);
  }, [handleSearch]);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

這是正確的,但每次 query 變化都要重建 interval。

如果業務上希望「計時器只註冊一次,但每次都用最新 query」,那就換成 ref 模式:

function SearchPanel({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');
  const queryRef = useRef(query);
  const onSearchRef = useRef(onSearch);

  useEffect(() => {
    queryRef.current = query;
  }, [query]);

  useEffect(() => {
    onSearchRef.current = onSearch;
  }, [onSearch]);

  useEffect(() => {
    const id = setInterval(() => {
      onSearchRef.current(queryRef.current);
    }, 5000);

    return () => clearInterval(id);
  }, []);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

這類程式碼的關鍵,不是「高級」,而是你要先搞清楚目標:

  • 你是想讓副作用跟著依賴重建?

  • 還是想讓副作用只建一次,但讀取最新值?

這兩個目標不一樣,程式碼就該不一樣。

八、這一篇可以帶走的 6 條結論

  1. React 裡的「舊值」問題,本質上幾乎都是閉包問題,不是 React 神祕失靈。

  2. 依賴陣列的作用,是告訴 React 什麼時候該放棄舊閉包,建立新閉包。

  3. 只要下一次 state 依賴上一次 state,優先使用函式式更新。

  4. 外部訂閱、計時器這類「不想頻繁重建」的場景,可以用 ref 存最新值。

  5. useCallback 只快取函式參考,不會自動幫你拿最新值,也不會修復錯誤邏輯。

  6. 只有當函式穩定參考真的有價值時,再上 useCallback;否則它只是給程式碼增加雜訊。

分享