從根上理解 React Hooks 的閉包陷阱
現(xiàn)在開發(fā) React 組件基本都是用 hooks 了,hooks 很方便,但一不注意也會遇到閉包陷阱的坑。
相信很多用過 hooks 的人都遇到過這個坑,今天我們來思考下 hooks 閉包陷阱的原因和怎么解決。
首先這樣一段代碼,大家覺得有問題沒:
import { useEffect, useState } from 'react';
function Dong() {
const [count,setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 500);
}, []);
useEffect(() => {
setInterval(() => {
console.log(count);
}, 500);
}, []);
return <div>guang</div>;
}
export default Dong;
用 useState 創(chuàng)建了個 count 狀態(tài),在一個 useEffect 里定時修改它,另一個 useEffect 里定時打印最新的 count 值。
我們跑一下:

打印的并不是我們預期的 0、1、2、3,而是 0、0、0、0,這是為什么呢?
這就是所謂的閉包陷阱。
首先,我們回顧下 hooks 的原理:hooks 就是在 fiber 節(jié)點上存放了 memorizedState 鏈表,每個 hook 都從對應的鏈表元素上存取自己的值。

比如上面 useState、useEffect、useEffect 的 3 個 hook 就對應了鏈表中的 3 個 memorizedState:

然后 hook 是存取各自的那個 memorizedState 來完成自己的邏輯。
hook 鏈表有創(chuàng)建和更新兩個階段,也就是 mount 和 update,第一次走 mount 創(chuàng)建鏈表,后面都走 update。
比如 useEffect 的實現(xiàn):

特別要注意 deps 參數(shù)的處理,如果 deps 為 undefined 就被當作 null 來處理了。

那之后又怎么處理的呢?

會取出新傳入的 deps 和之前存在 memorizedState 的 deps 做對比,如果沒有變,就直接用之前傳入的那個函數(shù),否則才會用新的函數(shù)。
deps 對比的邏輯很容易看懂,如果是之前的 deps 是 null,那就返回 false 也就是不相等,否則遍歷數(shù)組依次對比:

所以:
如果 useEffect 第二個參數(shù)傳入 undefined 或者 null,那每次都會執(zhí)行。
如果傳入了一個空數(shù)組,只會執(zhí)行一次。
否則會對比數(shù)組中的每個元素有沒有改變,來決定是否執(zhí)行。
這些我們應該比較熟了,但是現(xiàn)在從源碼理清了。
同樣,useMemo、useCallback 等也是同樣的 deps 處理:


理清了 useEffect 等 hook 是在哪里存取數(shù)據(jù)的,怎么判斷是否執(zhí)行傳入的函數(shù)的之后,再回來看下那個閉包陷阱問題。
我們是這樣寫的:
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 500);
}, []);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 500);
}, []);
deps 傳入了空數(shù)組,所以只會執(zhí)行一次。
對應的源碼實現(xiàn)是這樣的:

如果是需要執(zhí)行的 effect 會打上 HasEffect 的標記,然后后面會執(zhí)行:

因為 deps 數(shù)組是空數(shù)組,所以沒有 HasEffect 的標記,就不會再執(zhí)行。
我們知道了為什么只執(zhí)行一次,那只執(zhí)行一次有什么問題呢?定時器確實只需要設置一次呀?
定時器確實只需要設置一次沒錯,但是在定時器里用到了會變化的 state,這就有問題了:
deps 設置了空數(shù)組,那多次 render,只有第一次會執(zhí)行傳入的函數(shù):

但是 state 是變化的呀,執(zhí)行的那個函數(shù)卻一直引用著最開始的 state。
怎么解決這個問題呢?
每次 state 變了重新創(chuàng)建定時器,用新的 state 變量不就行了:

也就是這樣的:
import { useEffect, useState } from 'react';
function Dong() {
const [count,setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 500);
}, [count]);
useEffect(() => {
setInterval(() => {
console.log(count);
}, 500);
}, [count]);
return <div>guang</div>;
}
export default Dong;
這樣每次 count 變了就會執(zhí)行引用了最新 count 的函數(shù)了:

現(xiàn)在確實不是全 0 了,但是這亂七八遭的打印是怎么回事?
那是因為現(xiàn)在確實是執(zhí)行傳入的 fn 來設置新定時器了,但是之前的那個沒有清楚呀,需要加入一段清除邏輯:
import { useEffect, useState } from 'react';
function Dong() {
const [count,setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 500);
return () => clearInterval(timer);
}, [count]);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 500);
return () => clearInterval(timer);
}, [count]);
return <div>guang</div>;
}
export default Dong;
加上了 clearInterval,每次執(zhí)行新的函數(shù)之前會把上次設置的定時器清掉。
再試一下:

現(xiàn)在就是符合我們預期的了,打印 0、1、2、3、4。
很多同學學了 useEffect 卻不知道要返回一個清理函數(shù),現(xiàn)在知道為啥了吧。就是為了再次執(zhí)行的時候清掉上次設置的定時器、事件監(jiān)聽器等的。
這樣我們就完美解決了 hook 閉包陷阱的問題。
總結
hooks 雖然方便,但是也存在閉包陷阱的問題。
我們過了一下 hooks 的實現(xiàn)原理:
在 fiber 節(jié)點的 memorizedState 屬性存放一個鏈表,鏈表節(jié)點和 hook 一一對應,每個 hook 都在各自對應的節(jié)點上存取數(shù)據(jù)。
useEffect、useMomo、useCallback 等都有 deps 的參數(shù),實現(xiàn)的時候會對比新舊兩次的 deps,如果變了才會重新執(zhí)行傳入的函數(shù)。所以 undefined、null 每次都會執(zhí)行,[] 只會執(zhí)行一次,[state] 在 state 變了才會再次執(zhí)行。
閉包陷阱產(chǎn)生的原因就是 useEffect 等 hook 里用到了某個 state,但是沒有加到 deps 數(shù)組里,這樣導致 state 變了卻沒有執(zhí)行新傳入的函數(shù),依然引用的之前的 state。
閉包陷阱的解決也很簡單,正確設置 deps 數(shù)組就可以了,這樣每次用到的 state 變了就會執(zhí)行新函數(shù),引用新的 state。不過還要注意要清理下上次的定時器、事件監(jiān)聽器等。
要理清 hooks 閉包陷阱的原因是要理解 hook 的原理的,什么時候會執(zhí)行新傳入的函數(shù),什么時候不會。
hooks 的原理確實也不難,就是在 memorizedState 鏈表上的各節(jié)點存取數(shù)據(jù),完成各自的邏輯的,唯一需要注意的是 deps 數(shù)組引發(fā)的這個閉包陷阱問題。















 
 
 








 
 
 
 