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

如果說前一篇解決的是「哪些邏輯不該放進 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.memo 和 useCallback 之後,容易進入一個階段:
看到函式就包一層 useCallback,好像不包就不專業。
例如:
const handleSelect = useCallback((id: string) => {
onChange(id, filters);
}, []);這段程式碼的問題不是 useCallback 本身,而是它閉包住了舊的 filters 和 onChange。
如果這兩個值會變,那這個 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 條結論
React 裡的「舊值」問題,本質上幾乎都是閉包問題,不是 React 神祕失靈。
依賴陣列的作用,是告訴 React 什麼時候該放棄舊閉包,建立新閉包。
只要下一次 state 依賴上一次 state,優先使用函式式更新。
外部訂閱、計時器這類「不想頻繁重建」的場景,可以用 ref 存最新值。
useCallback只快取函式參考,不會自動幫你拿最新值,也不會修復錯誤邏輯。只有當函式穩定參考真的有價值時,再上
useCallback;否則它只是給程式碼增加雜訊。
在 Google 上持續關注
把 HeyBinyang 加入 Google 首選來源
如果你希望之後在 Google 上更容易看到我的更新,可以把這個站點加入 preferred source,讓它在相關閱讀情境裡更容易被找到。
SHARE
分享
分享這篇文章。