三年前我寫過一篇 React 生命週期,內文列的方法全部是 Class Component 的 componentDidMount、shouldComponentUpdate 那一套,但程式碼範例最後默默附了一個用 useState / useEffect 的 function component,內文一個字都沒解釋它。這篇是那篇的續集,重點在 useEffect 對不上 Class 生命週期的地方,以及 StrictMode 下 effect 會跑兩次這件事。
先承認那篇的問題
不是內容寫錯,是重心押在一個現在幾乎不會用的寫法上
三年前那篇在這裡。回頭看,Mount / Update / Unmount 這三個階段的描述到今天都還成立,那部分不用改。
問題在後面:我用了整整三節去列 Class 的生命週期方法,然後在最下面的 code block 裡貼了一個 function component,內文完全沒提到它。
老實說當年我根本沒意識到那段需要解釋。我大概是想「順便附一個現代寫法」,然後就這樣貼上去了 😅。
結果就是那篇文章的重心,押在一個 2026 年幾乎不會有人新寫的東西上。
useEffect 不是「三個生命週期合體」
這是從 Class 過來的人最容易帶著的錯誤直覺
很多教學會給你一張對照表,說 useEffect(fn, []) 等於 componentDidMount、回傳的清理函式等於 componentWillUnmount。這個對照大概方向對,細節全錯,而錯的地方剛好就是會咬你的地方。
我在自己的專案(React 18.2)裡開了一個測試頁,把三種 useEffect 掛在同一個元件上,實際跑一次看 console:
// 三種寫法掛在同一個元件上,看它們各自什麼時候被呼叫
React.useEffect(() => {
console.log('effect deps=[] 執行');
return () => console.log('effect deps=[] 清理');
}, []);
React.useEffect(() => {
console.log(`effect 無 deps 執行 count=${count}`);
return () => console.log(`effect 無 deps 清理 count=${count}`);
});
React.useEffect(() => {
console.log(`effect deps=[count] 執行 count=${count}`);
return () => console.log(`effect deps=[count] 清理 count=${count}`);
}, [count]);把 count 從 0 加到 1,實際輸出是這樣:
effect 無 deps 清理 count=0
effect deps=[count] 清理 count=0
effect 無 deps 執行 count=1
effect deps=[count] 執行 count=1三件事可以直接讀出來。
清理函式不是只在銷毀時跑
componentWillUnmount 一輩子只跑一次,就是元件要消失的時候。但上面那段輸出裡,元件根本沒有消失,count 只是從 0 變 1,清理函式就已經跑了兩次。
只要 effect 要重跑,它會先把上一輪清理掉。 所以正確的心智模型不是「掛載時執行、卸載時清理」,而是「每一輪 effect 自己負責收拾自己」。
清理函式看到的是舊的值
注意上面那兩行清理印的是 count=0,不是 count=1。清理函式是上一輪 render 建立的閉包,它記得的是建立當下的值,不是執行當下的值。
這件事像離職交接。你交接的是自己在職期間知道的狀況,不是公司現在的狀況。如果你在清理函式裡拿某個 state 去做判斷,你拿到的是舊的那份。
這是我自己踩過最久才想通的一個坑。
deps=[] 那個從頭到尾沒動
它在 update 時完全沒有出現在輸出裡。這部分確實接近 componentDidMount。但下一節就會看到,連這個也不完全成立。
沒有 shouldComponentUpdate 的等價物
這是對照表上最常被跳過的一格
useEffect 沒有任何辦法「阻止這次更新」。Class 的 shouldComponentUpdate 回傳 false 就整個 re-render 都不會發生,但 effect 是在 render 之後才跑的,它沒有投票權。
要擋 re-render 得換工具,而且這裡很容易搞混。真正能跳過 re-render 的只有 React.memo(對應 Class 的 shouldComponentUpdate / PureComponent)。useMemo 和 useCallback 本身不會阻止任何一次 re-render。它們只是讓傳給子元件的值或函式在每次 render 之間保持同一個參考,好讓外面包了 React.memo 的子元件有機會判斷「沒變」而跳過。
單獨用 useCallback 包一個 callback,什麼都擋不到。
而且這些是「效能優化」而不是「生命週期」,觀念上是兩件事。這也是為什麼把 hooks 硬塞進生命週期的表格裡會怪怪的。
StrictMode 下 effect 會跑兩次
React 18 開始的行為,第一次遇到會以為自己寫錯了
我在測試頁裡放了兩組一樣的元件,一組直接放、一組用 <React.StrictMode> 包起來,同一次載入的 console 輸出:
[plain] effect deps=[] 執行
[plain] effect 無 deps 執行 count=0
[plain] effect deps=[count] 執行 count=0
[strict] effect deps=[] 執行
[strict] effect 無 deps 執行 count=0
[strict] effect deps=[count] 執行 count=0
[strict] effect deps=[] 清理
[strict] effect 無 deps 清理 count=0
[strict] effect deps=[count] 清理 count=0
[strict] effect deps=[] 執行
[strict] effect 無 deps 執行 count=0
[strict] effect deps=[count] 執行 count=0沒包的那組跑一次,包了 StrictMode 的那組是執行 → 清理 → 再執行。
這是 React 18 刻意加的:它在開發模式下故意把元件掛載兩次,用來逼出「清理函式沒寫好」的 bug。如果你的 effect 發了一支 API 而沒有在清理函式裡取消,你就會看到請求送兩次。
那不是 React 壞掉,是它在告訴你這段程式碼經不起重複掛載。
只在開發模式發生,production build 不會。 但正因為只在開發模式,很容易被誤判成「我的程式碼有問題」而花時間去追一個不存在的 bug。上面那段 deps=[] 的 effect 在這裡也被清理了一次。所以連「deps=[] 等於 componentDidMount」都不能算完全成立。
那篇文章我不打算刪掉
寫錯的東西留著,比刪掉有用
Class Component 的生命週期還是有價值。維護舊專案會遇到,面試偶爾還是會問,而且理解 Class 那一套之後再看 hooks,會更清楚 hooks 到底解掉了什麼問題。
真正該補的是這篇:把當年那個沒被解釋的 function component 講完。如果你也是從 Class 那個年代過來的,希望這篇能幫你少走一點我走過的冤枉路。
