在上一篇關於 useEffect
的深度解析中,我們已經了解到了 dependencies 是一種效能的最佳化手段,而不是用來控制生命週期或是商業邏輯。對於 useEffect
的 dependencies 撒謊通常會導致我們的應用程式出現一些難以察覺與追蹤的 bug,因為這會導致 component 錯誤的忽略某些依賴的原始資料變化時,應該要連動的同步動作。
幾乎任何有 class component 經驗的 React 開發者,可能都曾經嘗試要欺騙 dependencies(包含筆者我自己也曾這樣做過)。你可能會覺得「我只想要在 mount 時執行一次這個 effect!」
當你的 effect 重複執行時會造成一些「疊加」而非「覆蓋」的影響時,多次執行這個 effect 確實有可能對你的應用程式造成問題。然而,這個問題的解法並不是給空陣列來欺騙 dependencies 以嘗試「模擬 componentDidMount
的效果」,而是有更理想的解決手段,我們稍後就會深入這個部分。
欺騙 dependencies 會導致 component 錯誤的忽略某些依賴的原始資料變化時,應該要連動的同步動作。我們先來以一個範例來幫助我們更深入的內化這個概念,它嘗試想要讓畫面上的數字每經過一秒就會自動 +1:
function Counter() {
const [count, setCount] = useState(0);
useEffect(
() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
},
[]
);
return <h1>{count}</h1>;
}
不過實際上這個範例是有問題的,它只會增加一次之後數字就不動了。你看出問題是出在哪裡了嗎?
在這個範例中,你可能會有一種思考脈絡:「我想要定時自動重複執行更新資料的動作,為此我們需要設置一個 setInterval
,所以希望只會觸發這個 effect 一次,並且也只會在 unmount 時做一次 clearInterval
。如果給 dependencies 一個空陣列 []
的話,就能讓它只會在 mount 以及 unmount 時才執行 effect 跟 cleanup了! 」
即使這個 effect 明明就依賴了 count
這個資料,但我們卻以 []
欺騙了 React,這樣自作聰明的行為讓我們付出了代價。
讓我們來看看問題到底出在哪裡:
// 第一次 render,count state 是 0
function Counter() {
// ...
useEffect(
// 第一次 render 的 effect
() => {
const id = setInterval(() => {
setCount(0 + 1); // 永遠都會是 setCount(1)
}, 1000);
return () => clearInterval(id);
},
[] // 永遠不會重新執行
);
// ...
}
// 接下來的每次 re-render,count state 都是 1
function Counter() {
// ...
useEffect(
// 從第二次 render 開始,
// 這個 effect function 雖然有產生卻會一直被跳過而沒有執行,
// 因為我們欺騙 React 說 dependencies 是空的
() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
},
[]
);
// ...
}
當這個 component 第一次 render 時,count
的值是 0
,因此第一次 render 對應的 effect 中的 setCount(count + 1)
會是 setCount(0 + 1)
。由於 dependencies 是 []
,所以 effect 再也沒被重新執行。而每一次 render 中的 state 是永遠不變的,所以 effect 中的 setInterval
裡所依賴的 count
的值是永遠不變的。因此,這個 setInterval
每秒都會固定重複呼叫一次 setCount(0 + 1)
。
我們欺騙了 React 這個 effect 沒有依賴任何 component 中的值,但事實上它依賴了 count
。像這樣子因為欺騙 dependencies 所連帶導致的問題有時候是很難想像並察覺的。面對這種情況時,更好的處理方式應該是永遠誠實的填寫 dependencies,然後以其它方式排除那些 effect 重複執行時會有的問題。
我們看一下如何透過誠實的填寫 dependencies 來修正上面這個範例的問題:
function Counter() {
const [count, setCount] = useState(0);
useEffect(
() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
},
[count] // 正確地填寫了 deps
);
return <h1>{count}</h1>;
}
在誠實地填寫 dependencies 之後,你會發現結果的行為修好了。讓我們來模擬 render 的情況:
// 第一次 render,count state 是 0
function Counter() {
// ...
useEffect(
// 第一次 render 的 effect
() => {
const id = setInterval(() => {
setCount(0 + 1); // setCount(count + 1)
}, 1000);
return () => clearInterval(id);
},
[0] // [count]
);
// ...
}
// 第二次 render,count state 是 1
function Counter() {
// ...
useEffect(
// 第二次 render 的 effect
() => {
const id = setInterval(() => {
setCount(1 + 1); // setCount(count + 1)
}, 1000);
return () => clearInterval(id);
},
[1] // [count]
);
// ...
}
由於每次 render 時的 count
值並不相同,所以每次 render 時 effect 都會正確的被重新觸發。並且,由於我們有適當的做好 cleanup 的設計,所以每次 effect 被重新執行之前,都會先執行上一次 render 的 cleanup:
// --- 第一次 render 時,count 是 0 ---
// 第一次 render 的 effect
const id = setInterval(() => {
setCount(0 + 1); // setCount(count + 1)
}, 1000);
// --- 第二次 render 時,count 是 1 ---
// 檢查 dependencies 中的 count 與上一次 render 時的版本是否值相同:
// 0 !== 1,所以本次 effect 不需略過,正常執行
// 先清除第一次 render 的 effect
clearInterval(id); // 第一次 render 時的 setInterval 所回傳的 id
// 第二次 render 的 effect
const id = setInterval(() => {
setCount(1 + 1); // setCount(count + 1)
}, 1000);
// ...後面以此類推
你會發現雖然這樣可以修正問題,並且也沒有欺騙 dependencies,但每當 count
因為 setState
而在 re-render 中更新時,我們的 interval 就會被清掉再重設,所以每個 interval 其實都只存活了一秒後就會被清除並重新產生新的。這可能不是我們想要的理想效果。接下來就讓我們來延伸探討更好的解決方法。
我們應該永遠對 dependencies 保持誠實,這是一個無論在任何情況下都應該保持的原則,沒有例外。然而如果我們遇到了像上面的範例那種情況時,我們應該嘗試調整 effect 的寫法,讓它不再需要依賴一個會頻繁更新的值 — 我們仍然要永遠保持對 dependencies 誠實,但可以盡量減少依賴的項目,意思就像是「讓 effect 對於需要的依賴自給自足」。
讓我們繼續延伸上面的 counter 範例。我們想要讓這個 effect 不再依賴 count
,以避免 setInterval
頻繁的重新建立與清除,這樣我們就能在保持誠實的前提之下安全地將 count
從 dependencies 中移除。
為此,我們首先得先觀察,這個 effect 為什麼會需要依賴 count
?
const [count, setCount] = useState(0);
useEffect(
() => {
const id = setInterval(() => {
setCount(count + 1); // 在這裡依賴了 count
}, 1000);
return () => clearInterval(id);
},
[count]
);
看起來我們只是為了呼叫 setCount
時做到「以當前值做延伸計算」的行為而已。但其實在這樣的情況下,我們並不真的需要用到 count
變數。如果你還記得的話,我們曾在前面的篇章「[Day 14] 以 functional updater 來呼叫 setState」中介紹過:當我們想要根據舊的 state 值來更新一個 state 時,我們其實可以使用一個 updater function 來呼叫 setState
:
const [count, setCount] = useState(0);
useEffect(
() => {
const id = setInterval(() => {
// 以 updater 來更新計算新的 state,不需要依賴 count 變數
setCount(prevCount => prevCount + 1);
}, 1000);
return () => clearInterval(id);
},
[] // 現在我們不再依賴 count 變數了,可以安全地將它從 deps 中移除!
);
在替換成 updater function 之後,我們的 effect 就不再依賴 count
變數了,可以安全地將它從 dependencies 中移除!你可以在這個 CodeSandbox 觀察實際效果。
在原本的寫法中, count
確實是必要的依賴,但我們其實只是為了算出 count + 1
是多少之後再丟回給 setCount
方法而已。然而,React 內部其實一直都知道這個 state 目前的值是多少,因此我們其實只需要告訴 React「我想要讓目前的值增加 1」這個動作就好。這其實就是 setCount(prevCount => prevCount + 1)
在做的事情,你可以想像它傳給了 React 一份「更新資料的操作指南」,然後請 React 內部自己按照這個指南去更新資料。
透過 updater function,我們就能安全的移除 effect 對於 state 當前值的依賴,達到「自己自足」的效果 — 而我們仍舊保持了對 dependencies 的誠實。
在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~
《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》
目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:
天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695
博客來(平裝版):
https://www.books.com.tw/products/0010982322
momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845
關於最後一個例子,我想請教有關COUNT值的問題。
當計時器啟動,count值被加成1之後,該組件要重新渲染,因為count值被改變了。
但useEffect的依賴項是一個空數組,代表useEffect在count值變成1之後都不會執行了,所以我的理解是認為count值會永遠是1。
但實際COUNT值會一直遞增,想請問我的思路那邊出現問題了,謝謝
JavaScript 的內建 API setInterval()
是用來註冊
會定時執行傳入的 callback function,註冊完就會固定每隔一段時間自動執行傳入的 callback function。
所以即使這個 effect 函式只被執行了一次,但有成功執行到 setInterval 一次,所以以下這個 callback function 每秒還是會被自動執行:
() => {
setCount(prevCount => prevCount + 1);
}
可是當count值從0變成1的時候,應該會觸發清除函數取消掉setInterval吧?
先謝謝大大的回覆。
cleanup 函式是在「每新一次 effect 函式被觸發前或 unmount 時」才會執行。所以這例子一般來說會執到 unmount 時才執行到 clearInterval()
感謝大大,我找到我的問題點了。
我一直以為rerender算是一種unmount,所以會直接觸發清除函式。
但其實rerender觸發清除函式的時機點是在新的useEffect被執行前,這樣的理解才是正確的嗎