為什麼 React Hooks 會愈寫愈亂?

很多人剛上手 React Hooks 時,都會有一個類似的心路歷程:一開始覺得真香,函式元件加 useState/useEffect,寫起來比 class 清爽多了。
過幾個月回頭看以前的程式碼,發現一堆奇怪的問題——
元件裡一大坨 useEffect,改誰都怕炸。
明明狀態不多,邏輯卻分散在各種 effect、ref、回呼裡。
偶爾還會遇到那種「列印出來是舊值」「除錯時行為忽明忽暗」的閉包問題。
這篇我們先不講任何高深原理,就圍繞三個問題來拆解:
為什麼「什麼都往 useEffect 裡塞」會讓元件越來越難維護?
為什麼「能算出來的值」一旦進了 state,邏輯就會開始扭曲?
為什麼閉包在 Hooks 裡格外容易坑到你?
先把這些壞味道認清楚,後面幾篇再分別從 useEffect 邊界、閉包實戰、計算屬性等角度展開。
一、一個典型的「能跑但很難維護」元件長什麼樣?
先看一段「很多專案裡都長得差不多」的程式碼:
function ProductList({ initialKeyword }: { initialKeyword: string }) {
const [keyword, setKeyword] = useState(initialKeyword);
const [items, setItems] = useState<Product[]>([]);
const [filtered, setFiltered] = useState<Product[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
// 初始化拉数据
useEffect(() => {
setLoading(true);
fetch(`/api/products?keyword=${keyword}`)
.then(res => res.json())
.then(data => {
setItems(data.items);
setTotal(data.total);
})
.finally(() => setLoading(false));
}, [keyword]);
// 根据 keyword 过滤本地列表(有点多余,但真实项目里经常这样写)
useEffect(() => {
const next = items.filter(item =>
item.name.toLowerCase().includes(keyword.toLowerCase())
);
setFiltered(next);
}, [keyword, items]);
// 当 filtered 变化时更新总数
useEffect(() => {
setTotal(filtered.length);
}, [filtered]);
// 监听 initialKeyword 变化(比如父组件路由切换)
useEffect(() => {
setKeyword(initialKeyword);
}, [initialKeyword]);
if (loading) return <div>Loading...</div>;
return (
<div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div>共 {total} 个商品</div>
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}這段程式碼有幾個典型特徵:
狀態很多:keyword、items、filtered、total、loading。
effect 很多:初始化請求、過濾、統計、同步 props。
看起來「職責劃分清晰,每個 effect 做一件事」,實際上問題不少。
你可能會說:能跑啊,看起來也還行,有什麼問題?
問題在於:這段程式碼裡,很多狀態根本不應該存在,很多 effect 也不應該存在。
二、狀態一多,邏輯就一定複雜嗎?
我們從資料的角度,重新梳理一下這個元件「真正需要知道」的東西:
使用者目前的關鍵詞 keyword。
後端返回的原始商品列表 items。
那麼:
filtered 可以完全通過 items + keyword 計算出來。
total 可以完全通過 filtered.length(或者 items.length)算出來。
也就是說:
filtered 和 total 都是標準的「派生狀態」(derived state),本質是計算屬性,而不是原始狀態。
一旦你把這些可推導的值也塞進 state:
你就要負責「保持這些狀態之間永遠一致」。
任何一個路徑忘記同步,就會出現「顯示的總數不對」「過濾結果和資料不匹配」這種陰間 bug。
React 官方文件裡反覆強調:
當某個值可以由 props 或 state 計算出來時,不要再為它單獨維護一份 state。
現在回頭看這段程式碼,你會發現:
我們為了「方便」,給 filtered、total 都加了 state。
然後又為了讓它們「看起來同步」,寫了兩段 effect 去維護。
相當於:本來一條公式可以解決的事,被硬生生拆成了三份狀態 + 兩個副作用。
三、useEffect 被當成「邏輯垃圾桶」之後會怎樣?
上面那個元件還有另一個問題:
幾乎所有邏輯都被「拆碎」塞進了 useEffect:
拉資料用一個 effect。
過濾用一個 effect。
統計用一個 effect。
同步 props 用一個 effect。
問題在於,useEffect 本來的定位是:
用來把 React 的狀態,和某個「外部系統」同步,比如請求、訂閱、DOM 操作。
而過濾和統計,都是純計算,完全可以在 render 期間完成:
不需要操作 DOM。
不需要訪問瀏覽器 API。
不需要發網路請求。
把純計算塞進 effect 之後,會帶來這些額外成本:
性能上:
你每次更新 filtered/total,都經歷「渲染 → 跑 effect → setState → 再渲染」兩輪流程。
心智上:
邏輯被拆散在多個 effect 裡,改某個行為需要到處翻。
很難一眼看出「這個 UI 狀態到底是怎麼算出來的」。
bug 風險:
某次重構時,沒注意依賴陣列裡漏了一個變數,effect 不再按預期更新;
或者為了解決「無限循環」隨意刪依賴,靠感覺調到「不報錯就行」。
久而久之,這個元件就從「函式 + Hooks」退化成了「函式寫的 class 生命週期」:
生命週期從 componentDidMount/componentDidUpdate 變成了一堆 useEffect。
問題一樣有,甚至更隱蔽。
四、閉包為什麼在 Hooks 裡特別容易坑你?
再看一個簡化版的例子:
function SearchBox() {
const [keyword, setKeyword] = useState('');
useEffect(() => {
const id = setInterval(() => {
console.log('search with', keyword);
// fetch(`/api/search?keyword=${keyword}`)
}, 5000);
return () => clearInterval(id);
}, []); // 注意:依赖数组是 []
return (
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
);
}問題非常典型:
你以為定時器「每 5 秒用最新的 keyword 搜一次」。
實際上,effect 只在掛載時執行一次,閉包裡拿到的是「初次渲染時的 keyword」。
後面使用者怎麼輸入,列印出來的永遠是空字串。
這就是經典的「閉包 + 不老實的依賴陣列」問題:
useEffect 裡建立的回呼,會「捕獲」當時的 props 和 state。
如果依賴陣列不含 keyword,effect 不會隨 keyword 更新而重新執行。
回呼引用的永遠是舊的變數。
之所以在 Hooks 時代這個問題格外普遍,是因為:
以前 class 裡你用的是 this.state,總是當前的;
現在函式元件裡你用的是閉包變數,版本是跟著渲染快照走的。
更糟的是:
很多人為了躲 ESLint 的警告,直接把依賴陣列改成 [] 或者隨意刪減。
這就等於是顯式地告訴 React:「我知道這裡會用到 keyword,但你不要管它變不變。」
結果就是:
程式碼表面上「很安靜」,沒有 warning,沒有報錯,但行為是錯的。
這類問題除錯起來特別浪費時間——console.log 也能看到「值是對的」,
可是 effect 裡的回呼拿到的就是舊值。
五、從這個例子裡我們真正應該學到什麼?
回到我們一開始的 ProductList 示例,如果我們順著 React 官方推薦的幾個原則重寫一版,會是這樣:
只為「最小不可推導」的東西建 state:keyword、items、loading 等。
所有可推導值(filtered、total)都通過計算屬性實現,而不是再加一層 state + effect。
useEffect 只做「拉資料」這種和外部系統打交道的事。
一個更乾淨的版本是:
function ProductList({ initialKeyword }: { initialKeyword: string }) {
const [keyword, setKeyword] = useState(initialKeyword);
const [items, setItems] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/products?keyword=${keyword}`)
.then(res => res.json())
.then(data => {
if (cancelled) return;
setItems(data.items);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [keyword]);
const filtered = useMemo(
() =>
items.filter(item =>
item.name.toLowerCase().includes(keyword.toLowerCase())
),
[items, keyword]
);
const total = filtered.length;
useEffect(() => {
setKeyword(initialKeyword);
}, [initialKeyword]);
if (loading) return <div>Loading...</div>;
return (
<div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div>共 {total} 个商品</div>
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}可以看到:
useEffect 只剩兩個:一個拉資料,一個同步 initialKeyword。
元件內的「資料關係」一目了然:items → filtered → total。
狀態數量變少了,邏輯也跟著變清晰了。
你甚至可以繼續優化:如果 initialKeyword 只在掛載時用一次,而不會後續變化,可以通過路由 key 或父元件控制,徹底刪掉第二個 effect。
六、這篇,你可以帶走的「程式碼審查清單」
第 1 篇我們不急著講具體技巧,只先種下幾個判斷標準。
你可以直接拿去 review 自己專案裡的 Hooks 程式碼:
這個元件裡有多少個 state?每一個 state 是否「最小不可推導」?
如果某個欄位完全可以透過別的 state/props 算出來,那它就是冗餘的。
這個元件裡有多少個 useEffect?
它們是不是在做和「外部世界」的同步?
有沒有純計算邏輯被塞進 effect 裡?
有沒有 useEffect 的依賴陣列明顯「少寫」了東西,只是為了躲開 ESLint 提示?
有沒有那種「狀態之間相互驅動」的鏈條:A 改變觸發 effect 改 B,B 改變觸發 effect 改 C?
這種鏈式更新往往意味著:B、C 其實是可以算出來的。
有沒有「列印出來是舊值」的疑難雜症?
這類問題基本都和閉包 + 不完整依賴陣列有關。
在 Google 上持續關注
把 HeyBinyang 加入 Google 首選來源
如果你希望之後在 Google 上更容易看到我的更新,可以把這個站點加入 preferred source,讓它在相關閱讀情境裡更容易被找到。
SHARE
分享
分享這篇文章。