前端知識 - React的生命週期:三年後,那篇文章有一半該重寫。

image

三年前我寫過一篇 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 那個年代過來的,希望這篇能幫你少走一點我走過的冤枉路。