【每日一面】React Hooks閉包陷阱
基礎(chǔ)問答
問題:談一談你對 React Hook的閉包陷阱的理解。
產(chǎn)生問題的原因:JavaScript 閉包特性 + Hooks 渲染機制
閉包的本質(zhì):函數(shù)能夠訪問其定義時所在的詞法作用域,即使函數(shù)在作用域外執(zhí)行,也可以記住定義時的詞法作用域的內(nèi)容,后續(xù)執(zhí)行時,使用這些信息。
function callback(index) {
let idx = index;
let op;
return (type) => {
op = type;
console.log(op);
switch (type) {
case 'add':
idx++;
break;
case 'sub':
idx--;
break;
}
return idx;
}
}
const fn = callback(8);
console.log(fn('add')); // 9
console.log(fn('sub')); // 8
這里的 idx 正常會在 callback 函數(shù)執(zhí)行結(jié)束后釋放,但是由于我們返回的是一個函數(shù),函數(shù)中依賴這個 idx 變量,所以未能釋放,此時這個變量被這個匿名函數(shù)持有,而在 fn 變量存續(xù)期間,idx 和 op 都是不會釋放的,這也就形成了一個閉包。
不過經(jīng)典閉包還是 for 循環(huán)
Hooks 渲染邏輯:React 組件每次渲染都是獨立的快照,可以理解為,每次重新執(zhí)行相關(guān)鉤子的時候,組件都會重新生成一個新的作用域。
閉包陷阱:根據(jù)上面兩點,React Hooks 的閉包陷阱產(chǎn)生過程應當是這樣的,React 在渲染開始前創(chuàng)建了新的狀態(tài)包(作用域),而我們寫代碼的時候無意中創(chuàng)建了一個閉包,持有了 React 的當前狀態(tài),再下次渲染開始時,React 重新創(chuàng)建了狀態(tài)包,但是我們在一開始創(chuàng)建的閉包持有的依舊是前一次 React 創(chuàng)建的狀態(tài),是舊的,這就是產(chǎn)生閉包陷阱的根源。這里我們以一個具體例子來看:
import { useEffect, useState } from "react"
const App = () => {
const [count, setCount] = useState(1);
useEffect(()=> {
const timer = setInterval(() => console.log(count), 1000);
return () => clearInterval(timer)
}, []);
const addOne = () => {
setCount(pre => pre+1);
}
return (
<div className="main" >
<p>Hello: {count}</p>
<button onClick={addOne}>+1</button>
</div>
)
}
export default App
這里在組件首次渲染的時候,useEffect 幫我們設(shè)置了一個定時器,定時器執(zhí)行的函數(shù)持有了外部作用域的 count 變量,產(chǎn)生了一個閉包。
再之后,我們在頁面上點擊按鈕時,觸發(fā)了 setCount(pre => pre+1) 狀態(tài)更新,但是由于沒有配置 useEffect 的更新依賴,所以定時器還是持有舊的狀態(tài)包。此時打印的還是 1,沒有更新。
閉包陷阱破解方式:
- 使用
useRef:useRef 在初始化后,是一個形如{ current: xxx }的不可變對象,不可變可以理解為,這個對象的地址不會發(fā)生變化,所以在淺層次的比較(===)中,更新后的前后對象是一個。所以取值的時候,總是能拿到最新的值。 - 添加 Hooks 依賴:在
useEffect鉤子的依賴列表中增加 count,當 count 發(fā)生變化的時候,會重新執(zhí)行useEffect,內(nèi)部的 timer 會重新生成,拿到最新的作用域的值。 - 修改 state 為一個對象:類似于 useRef,我們在更新 state 的時候,可以直接把內(nèi)容寫入該對象中,避免直接替換 state 對象。
擴展知識
React 官方要求我們不能將 hooks 用 if 條件判斷包裹,其原因是 React 的 Fiber 架構(gòu)中收集 Hooks 信息的時候是按順序收集的,并以鏈表的形式進行存儲的。如下示例:
function App() {
const [count, setCount] = useState(0);
const [isFirst, setIsFirst] = useState(false);
useEffect(() => {
console.log('hello init');
}, []);
useEffect(() => {
console.log('count change: ', count);
}, [count]);
const a = 1;
}
示例中存在 4 個 hooks,所以 React 收集完成后形成的鏈表應當是這樣的:

React 為鏈表節(jié)點設(shè)計了如下數(shù)據(jù)結(jié)構(gòu):
type Hook = {
memoizedState: any,
/** 省略這里不需要的內(nèi)容 */
next: Hook | null,
};
其中 next 就是鏈表節(jié)點用于指向下一個節(jié)點的指針,memoizedState 則是上一次更新后的相關(guān) state。組件更新的時候,hooks 會嚴格按照這個順序進行執(zhí)行,按順序拿到對應的 Hook 對象,所以如果我們用 if else 包裹了其中一個 hook,就會出現(xiàn)鏈表執(zhí)行過程中,Hooks 對象取值錯誤的情況。
同樣的,React 官方告訴我們,如果想在更新的時候拿到當前 state 的值,建議使用回調(diào)函數(shù)的寫法,即:setCount(pre => pre + 1) 這種寫法,這個原因,通過 Hook 的數(shù)據(jù)結(jié)構(gòu)也大致可以判斷,因為 memoizedState 存儲了前一次更新的數(shù)據(jù),使用回調(diào)時,這個 memoizedState 就可以作為參數(shù)提供給我們,并且保證總是正確的。
面試追問
- 能手寫一個閉包嗎?
參考前文代碼。
- 使用
useRef存儲值,會有什么問題?
useRef 在初始化后,是形如 { current: xxx } 的對象,這個對象地址不會變化,所以我們監(jiān)聽 ref 是不起作用的,同時,和 useState 不同,useRef 內(nèi)容的變更不會觸發(fā)組件重新渲染。
- 請談談 hooks 在 React 中的更新邏輯?
React 是以鏈表形式來組織管理 hooks 的,在收集過程中按照順序組裝成鏈表,然后每次觸發(fā)狀態(tài)更新時,會從鏈表頭開始依次判斷執(zhí)行更新。
- 那 hooks 中,useState 的更新是同步還是異步?
可以理解為異步的,展開來說,則是: state 更新函數(shù)(如觸發(fā) setCount)是同步觸發(fā)的,React 執(zhí)行更新(即 count 被更新)是異步的。這種設(shè)計主要是出于性能考慮,避免重復渲染,減少重繪重排。
- useEffect 依賴數(shù)組傳空數(shù)組和不傳依賴,二者有什么區(qū)別?
空數(shù)組:effect 僅在組件首次渲染時執(zhí)行一次,后續(xù)不會再執(zhí)行,相當于組件掛載階段。
不傳依賴:effect 會在組件首次渲染時、每次重新渲染后都執(zhí)行。這種形式隱含存在渲染循環(huán)的風險,即 effect 中存在修改 state 的操作,那么按照不傳依賴時執(zhí)行的規(guī)則,就會陷入渲染 -> 更新 -> 觸發(fā)重渲染 -> 更新 -> 觸發(fā)重渲染……這樣的循環(huán)。


浙公網(wǎng)安備 33010602011771號