技術0 閱讀

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

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

你大概見過這些場景:

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

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

先看一個最經典的例子:

tsx
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>
  );
}

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

但實際情況是:

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

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

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

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

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

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

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

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

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

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

這時候行為就變了:

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

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

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

1. 計時器 / 輪詢

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

錯誤寫法:

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

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

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

常見解法有兩類:

解法 A:老實寫依賴

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

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

優點:

缺點:

解法 B:用 ref 儲存最新值

tsx
const queryRef = useRef(query);

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

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

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

這個模式的本質是:

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

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

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

tsx
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」,而是確定性錯誤。

正確寫法通常是:

tsx
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

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

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

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

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

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

這裡正確的改法有兩種。

改法 A:把依賴寫全

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

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

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

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

這條經驗特別重要:

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

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

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

例如:

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

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

正確寫法應該是:

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

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

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

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

所以別把它當護身符。

四、useCallback 到底是幹什麼的?

先說最準確的一句話:

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

它主要有兩個典型用途。

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

tsx
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 的意義是:

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

例如:

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

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

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

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

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

  fetchUser();
}, [userId]);

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

五、什麼時候不該用 useCallback?

這是最實用的一節。

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

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

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

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

這種函式完全沒必要包 useCallback

因為:

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

tsx
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 裡用到了響應式值,就先檢查依賴是否完整。

不要第一反應就是:

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

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

例如:

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

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

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

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

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

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

判斷標準可以很簡單:

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

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

先看壞程式碼:

tsx
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:

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

tsx
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 模式:

tsx
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;否則它只是給程式碼增加雜訊。

SHARE

分享

分享這篇文章。