從根上理解 React Hooks 的閉包陷阱(續集)
??上篇文章??我們知道了什么是 hooks 的閉包陷阱,它的產生原因和解決方式,并通過一個案例做了演示。
其實那個案例的閉包陷阱的解決方式不夠完善,這篇文章我們再完善一下。
首先我們先來回顧下什么是閉包陷阱:
hooks 的閉包陷阱是指 useEffect 等 hook 中用到了某個 state,但是沒有把它加到 deps 數組里,導致 state 變了,但是執行的函數依然引用著之前的 state。
它的解決方式就是正確設置 deps 數組,把用到的 state 放到 deps 數組里,這樣每次 state 變了就能執行最新的函數,引用新的 state。同時要清理上次的定時器、事件監聽器等。
我們舉了這樣一個例子:
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;
每次打印都是 0 :
解決方式就是把 count 設置到 deps 里,并添加清理函數:
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;
這樣就能解決閉包陷阱:
但是這種解決閉包陷阱的方式用在定時器上不是很合適。
為什么呢?
因為現在每次 count 變了就會重置定時器,那之前的計時就重新計算,這樣就會導致計時不準。
所以,這種把依賴的 state 添加到 deps 里的方式是能解決閉包陷阱,但是定時器不能這樣做。
那還有什么方式能解決閉包陷阱呢?
useRef。
閉包陷阱產生的原因就是 useEffect 的函數里引用了某個 state,形成了閉包,那不直接引用不就行了?
useRef 是在 memorizedState 鏈表中放一個對象,current 保存某個值。
它的源碼是這樣的:
初始化的時候創建了一個對象放在 memorizedState 上,后面始終返回這個對象。
這樣通過 useRef 保存回調函數,然后在 useEffect 里從 ref.current 來取函數再調用,避免了直接調用,也就沒有閉包陷阱的問題了。
也就是這樣:
const fn = () => {
console.log(count);
};
const ref = useRef(fn);
useLayoutEffect(() => {
ref.current = fn;
});
useEffect(() => {
setInterval(() => ref.current(), 500);
}, []);
useEffect 里執行定時器,deps 設置為了 [],所以只會執行一次,回調函數用的是 ref.current,沒有直接依賴某個 state,所以不會有閉包陷阱。
用 useRef 創建個 ref 對象,初始值為打印 count 的回調函數,每次 render 都修改下其中的函數為新創建的函數,這個函數里引用的 count 就是最新的。
這里用了 useLayoutEffect 而不是 useEffect 是因為 useLayoutEffect 是在 render 前同步執行的,useEffect 是在 render 后異步執行的,所以用 useLayoutEffect 能保證在 useEffect 之前被調用。
這種方式避免了 useEffect 里直接對 state 的引用,從而避免了閉包問題。
另外,修改 count 的地方,可以用 setCount(count => count + 1) 代替 setCount(count + 1),這樣也就避免了閉包問題:
useEffect(() => {
setInterval(() => {
setCount(count => count + 1);
}, 500);
}, []);
現在組件的代碼是這樣的:
import { useEffect, useLayoutEffect, useState, useRef } from 'react';
function Dong() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count => count + 1);
}, 500);
}, []);
const fn = () => {
console.log(count);
};
const ref = useRef(fn);
useLayoutEffect(() => {
ref.current = fn;
});
useEffect(() => {
setInterval(() => ref.current(), 500);
}, []);
return <div>guang</div>;
}
export default Dong;
測試下:
確實,打印也是正常的,這就是解決閉包陷阱的第二種方式,通過 useRef 避免直接對 state 的引用,從而避免閉包問題。
這段邏輯用到了多個 hook,可以封裝成個自定義 hook:
function useInterval(fn, time) {
const ref = useRef(fn);
useLayoutEffect(() => {
ref.current = fn;
});
useEffect(() => {
setInterval(() => ref.current(), time);
}, []);
}
然后組件代碼就可以簡化了:
function Dong() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(count + 1);
}, 500);
useInterval(() => {
console.log(count);
}, 500);
return <div>guang</div>;
}
這樣我們就用 useRef 的方式解決了閉包陷阱問題。
總結
上篇文章我們通過把依賴的 state 添加到 deps 數組中的方式,使得每次 state 變了就執行新的函數,引用新的 state,從而解決了閉包陷阱問題。
這種方式用在定時器上是不合適的,因為定時器一旦被重置和重新計時,那計時就不準確了。
所以我們才用了避免閉包陷阱的第二種方式:使用 useRef。
useRef 能解決閉包陷阱的原因是 useEffect 等 hook 里不直接引用 state,而是引用 ref.current,這樣后面只要修改了 ref 中的值,這里取出來的就是最新的。
然后我們把這段邏輯封裝成了個自定義 hook,這樣可以方便復用。
解決 hooks 的閉包陷阱有兩種方式:
- 設置依賴的 state 到 deps 數組中并添加清理函數。
- 不直接引用 state,把 state 放到 useRef 創建的 ref 對象中再引用。
處理定時器的時候,為保證計時的準確,最好使用 useRef 的方式,其余情況兩種都可以。