前言
這篇文章旨在總結(jié) React Hooks 的使用技巧以及在使用過程中需要注意的問題,其中會(huì)附加一些問題產(chǎn)生的原因以及解決方式。但是請注意,文章中所給出的解決方式并不一定完全適用,解決問題的方案有很多種,也許你所在的團(tuán)隊(duì)針對這些問題已經(jīng)給出了對應(yīng)的規(guī)范,亦或是你已經(jīng)對這些問題的解決方式形成了更好的認(rèn)知。所以你的著重點(diǎn)應(yīng)該放在 你是否在使用 React hooks 過程中意識(shí)到了這些問題 以及 你對這些問題的思考
useState hook
初始化狀態(tài)
如果組件的某個(gè)狀態(tài)需要依靠大量計(jì)算得到初始值,一般我們會(huì)定義一個(gè)函數(shù)來初始化狀態(tài)
- 在 class 組件中
state = {
state1: calcInitialState()
}
沒什么問題,在組件不被重新掛載的情況下,即使組件多次重新渲染,calcInitialState 也只會(huì)被執(zhí)行一次
- 而在函數(shù)組件中, 有兩種方式
const state1 = useState(calcInitialState) // 組件多次渲染時(shí),calcInitialState 僅會(huì)被執(zhí)行一次
const state1 = useState(calcInitialState()) // 組件每次渲染時(shí),calcInitialState 都會(huì)被執(zhí)行
函數(shù)組件每次重新渲染,都會(huì)執(zhí)行函數(shù)組件本身,在第一次渲染時(shí),useState 會(huì)讀取初始值,如果是初始值函數(shù),則會(huì)被執(zhí)行,并且函數(shù)的返回值被作為初始狀態(tài),此時(shí),這兩種寫法表現(xiàn)相同。但是在后續(xù)重新渲染過程,useState 雖然不會(huì)讀取默認(rèn)狀態(tài)值,也不會(huì)對默認(rèn)狀態(tài)值做任何處理,但是第二種寫法中的 calcInitialState 仍然會(huì)被執(zhí)行,且是毫無意義的。內(nèi)部運(yùn)行流程見源碼 mountState 和 updateState
狀態(tài)的捕獲方式 (this & 閉包)
前段時(shí)間,我在團(tuán)隊(duì)內(nèi)部分享了常用的 React Hooks 原理以及源碼,當(dāng)時(shí)我提到了,不論是 class 組件還是函數(shù)組件, 他們的狀態(tài)都存儲(chǔ)在組件對應(yīng)的 fiber 上。函數(shù)組件 和 class 組件狀態(tài)更新的流程如下圖所示:

詳細(xì)可見源碼 updateClassInstance 和 updateReducer
對于渲染的那一部分(JSX)來說,可以一直拿到最新的的狀態(tài)。只是獲取狀態(tài)的方式不同,class 組件是通過 this.state 指向 fiber 節(jié)點(diǎn)上存儲(chǔ)的狀態(tài),函數(shù)組件則是通過 useState 這個(gè)函數(shù)的返回值獲取,那么問題在于函數(shù)組件拿到的狀態(tài)是存儲(chǔ)在閉包中的,這個(gè)閉包由 useState 執(zhí)行產(chǎn)生。
換個(gè)角度來說,對于函數(shù)組件,我們需要特別重視“渲染”這個(gè)概念。函數(shù)組件每次渲染,其內(nèi)部聲明的函數(shù)或者是返回的 UI(JSX) 都只能捕獲到當(dāng)前這次渲染的 props 和 state,這對于 UI(JSX) 來說完全沒有問題。 但是對于函數(shù)組件內(nèi)部的函數(shù),特別是延遲回調(diào)的函數(shù),需要特別注意等到回調(diào)函數(shù)執(zhí)行時(shí),回調(diào)函數(shù)中捕獲的 state 和 props 是否是你所期望的。
想要避免函數(shù)組件中閉包問題帶來的困擾,需要理解并記住下面兩句話
- 函數(shù)組件每一次渲染都有它自己的 props 和 state
- 函數(shù)組件每一次渲染都有它自己的事件處理函數(shù)
狀態(tài)粒度
狀態(tài)粒度過細(xì)
在編寫 class 組件時(shí),幾乎不用考慮狀態(tài)粒度的問題,因?yàn)殚_發(fā)者總是可以一次性聲明所有狀態(tài)或者一次性更新所有狀態(tài),就像這樣
handleClick = () => {
this.setState({
currentPage: 2,
pageSize: 20,
total: 100
})
}
大多數(shù)開發(fā)者并不會(huì)蠢到使用三次 setState 去更新這三個(gè)狀態(tài),但是在函數(shù)組件中只能這樣
const handleClick = () => {
setCurrent(2)
setPageSize(20)
setTotal(100)
}
看到這段代碼,嗯,可能會(huì)讓人感覺到有點(diǎn)不舒服,問題就出在更新粒度過細(xì),事實(shí)上一個(gè)分頁組件的 currentPage,pageSize,total 經(jīng)常會(huì)需要同時(shí)被更新,但是多次觸發(fā) setXXX 還是會(huì)讓人感到隱隱的不安,即使多次觸發(fā)更新可能會(huì)被 React 的 batchUpdate 機(jī)制合并為一次,但是當(dāng) setXXX 方法執(zhí)行脫離了 React 的上下文時(shí)會(huì)觸發(fā)多次更新,例如異步結(jié)束時(shí)的回調(diào)中。
此時(shí),我們可以在 useState 中存儲(chǔ)一個(gè)對象,將相關(guān)聯(lián)的狀態(tài)放在一起。也可以使用 useReducer 來管理多個(gè)狀態(tài)。
狀態(tài)粒度過粗
當(dāng)一個(gè) state 有一定的復(fù)雜度的時(shí)候,我并不推薦暴力的將 class 組件聲明 state 的方式硬生生塞到 useState 中,因?yàn)檫@也許會(huì)將 class 中 state 的粒度過粗的缺陷引入進(jìn)來
問題一:難以發(fā)現(xiàn)可以被復(fù)用的狀態(tài)邏輯
當(dāng)一個(gè)組件的狀態(tài)越來越多,組件的可讀性和可維護(hù)性就會(huì)越來越差,不少人應(yīng)該都深有體會(huì), 就像這樣:
ps :截取自真實(shí)的業(yè)務(wù)代碼
class XXX extends React.Component{
constructor(props: any) {
super(props);
this.state = {
tableListMap: {},
showPreview: false,
showRegModal: false,
dataSource: [],
columns: [],
tablePartitionList: [],
incrementColumns: [],
loading: false,
isChecked: {},
isShowImpala: false,
tableListSearch: {},
schemaList: [],
fetching: false,
tableListLoading: false,
bucketList: [],
showPreviewPath: false,
previewPath: '',
currentObject: { object: [''], index: 0, bucket: '' },
isCompressed: false,
matchType: null
};
}
}
試想一下,在函數(shù)組件中,一個(gè) useState 里面被塞進(jìn)如此多的狀態(tài),且不談能否發(fā)現(xiàn)其中可復(fù)用的狀態(tài)和邏輯,即便你慧眼如炬,發(fā)現(xiàn)了它跟其他組件之間有可以復(fù)用的狀態(tài)和邏輯。大概率,也很難在保證在當(dāng)前組件(歷史代碼)不會(huì)出問題的情況下將可以復(fù)用的狀態(tài)邏輯提取出來。
問題二:將無關(guān)的狀態(tài)放在同一個(gè) useState 中可能讓狀態(tài)更新變得不好控制
舉個(gè)例子,假如頁面上有個(gè)按鈕,當(dāng)點(diǎn)擊這個(gè)按鈕時(shí),需要同時(shí)從不同的接口中拿到兩份數(shù)據(jù)并渲染到頁面上,此時(shí)如果這兩份數(shù)據(jù)被存放在同一個(gè) useState 中
function DataViewer (props) {
const [dataMap, setDataMap] = useState({ data1: undefined, data2: undefined })
const loadData1 = async () => {
if (visible) {
const data1 = await fetchData1()
setDataMap({ ...dataMap, data1 })
}
}
const loadData2 = async () => {
if (visible) {
const data2 = await fetchData2()
setDataMap({ ...dataMap, data2 })
}
}
const handleClick = () => {
loadData1()
loadData2()
}
// ...
}
問題很明顯,只要兩個(gè)請求都完成,無論成功還是失敗,dataMap 中都不會(huì)有先完成的那個(gè)請求返回的數(shù)據(jù)。
在 class 組件中,基本上是不會(huì)出現(xiàn)這種問題的,因?yàn)榭偸强梢酝ㄟ^ this 拿到當(dāng)前的最新的狀態(tài),不會(huì)出現(xiàn)多次更新中狀態(tài)覆蓋的問題。當(dāng)然在函數(shù)組件中,也可以使用 useRef 暫存接口數(shù)據(jù),然后一起更新狀態(tài),但需要額外寫一些邏輯,這里就不介紹這種黑科技了。
此時(shí)第一個(gè)解決辦法是,將第一個(gè)接口返回的數(shù)據(jù)用變量暫存起來等到第二個(gè)接口完成再去更新 dataMap,就像這樣
const loadDataMap = async () => {
if (visible) {
const data1 = await fetchData1()
const data2 = await fetchData2()
setDataMap({ data1, data2 })
}
}
這樣做帶來的問題是,一定要等第一個(gè)請求完成,才能去發(fā)起第二個(gè)請求,對于用戶體驗(yàn)來說并不友好。
好吧,想要兩個(gè)接口并行,還可以使用 Promise.allSettled 并行的處理兩個(gè)請求
const loadDataMap = () => {
if (visible) {
Promise.allSettled([ fetchData1(), fetchData2() ])
.then(results => {
//...
})
}
}
看起來好像沒什么問題了。但是,如果對接口的狀態(tài)、返回值等有額外的處理邏輯時(shí),你就需要將所有的接口的處理邏輯都塞到 .then 的回調(diào)中,并且這種方法一定要兩個(gè)接口都完成才能更新狀態(tài)然后在頁面中展示數(shù)據(jù),也無法單獨(dú)的檢測到其中的某一個(gè)接口是否處于 pending 狀態(tài),這種方式似乎也不是那么友好。
這樣看來比較完美的解決方式只有兩種
- 避開閉包, 你只需要在更新狀態(tài)時(shí)傳入一個(gè)函數(shù)就可以了, 就像這樣
setDataMap(dataMap => ({ ...dataMap, data2 }))
- 狀態(tài)切分
const [ data1, setData1 ] = useState()
const [ data2, setData2 ] = useState()
解決問題的方式往往不止一種,你需要根據(jù)實(shí)際的業(yè)務(wù)情況自行去選擇你認(rèn)為更加合適的方式。
如何設(shè)計(jì)狀態(tài)粒度
官方文檔的 QA 中如是說道
把所有 state 都放在同一個(gè)
useState調(diào)用中,或是每一個(gè)字段都對應(yīng)一個(gè)useState調(diào)用,這兩方式都能跑通。當(dāng)你在這兩個(gè)極端之間找到平衡,然后把相關(guān) state 組合到幾個(gè)獨(dú)立的 state 變量時(shí),組件就會(huì)更加的可讀。如果 state 的邏輯開始變得復(fù)雜,我們推薦 用 reducer 來管理它,或使用自定義 Hook。
個(gè)人認(rèn)為,聚合相關(guān)的狀態(tài),拆分無關(guān)的狀態(tài),是一種比較好的實(shí)踐方式。比如將分頁器組件的 currentPage、pageSize、total 三個(gè)狀態(tài)放在同一個(gè) useState 中,將不同請求的返回的數(shù)據(jù)拆分到不同的 useState 中。另外還有一些情況是,狀態(tài)的邏輯比較復(fù)雜,這個(gè)時(shí)候也可以使用 useReducer 來管理狀態(tài),這樣就可以將一些復(fù)雜的邏輯抽離到 reducer 中。
狀態(tài)更新的兩種方式
不論是函數(shù)組件還是 class 組件,更新狀態(tài)的方式都有兩種: setState(newState) 和 setState(oldState => newState),它們之間的差異在于,一個(gè)注重結(jié)果,一個(gè)注重目的。 setState(newState) 用于描述新的狀態(tài),而 setState(oldState => newState) 用于描述新的狀態(tài)與舊的狀態(tài)相比應(yīng)當(dāng)做出什么樣的改變。
這樣說可能有點(diǎn)抽象,簡單來說 setState(newState) 是用新的狀態(tài)替換掉舊的狀態(tài), setState(oldState => newState) 是用來通過舊的狀態(tài)計(jì)算出新的狀態(tài)
useEffect hook
為什么需要 useEffect
從理論上講函數(shù)組件就是單純的用來渲染的,也就是所謂的純函數(shù),事實(shí)上沒有 React Hooks 之前也確實(shí)是這樣的。而其他的操作如數(shù)據(jù)獲取,設(shè)置定時(shí)器,修改 DOM 等都被稱作副作用。
為什么不能直接在函數(shù)組件內(nèi)直接執(zhí)行副作用?
- 有一些副作用操作可能會(huì)影響到渲染,如修改 DOM
- 有一些副作用操作是需要清除的,如定時(shí)器
- 如果直接在函數(shù)組件內(nèi)部直接進(jìn)行副作用操作,那么函數(shù)組件每次重新渲染時(shí)都會(huì)執(zhí)行這些操作,沒法控制這些操作何時(shí)執(zhí)行何時(shí)不執(zhí)行
useEffect 怎樣解決這些問題?
- useEffect 包裹的函數(shù)會(huì)在瀏覽器渲染完成之后執(zhí)行,保證不會(huì)影響到組件的渲染。另外被 useEffect 包裹的函數(shù)執(zhí)行脫離了函數(shù)組件本身的執(zhí)行上下文,所以不會(huì)對函數(shù)組件本身的執(zhí)行造成影響
- useEffect 包裹的函數(shù)可以 return 一個(gè)函數(shù),用于清除副作用
- useEffect 可以傳入依賴項(xiàng)數(shù)組,當(dāng)依賴項(xiàng)變化時(shí)才去執(zhí)行副作用操作
useEffect 的這些特性有點(diǎn)像事件回調(diào),只不過事件回調(diào)函數(shù)的觸發(fā)依靠 dom 事件如點(diǎn)擊、輸入等,而 useEffect 包裹的函數(shù)出發(fā)依靠依賴項(xiàng)的變化。很多時(shí)候,將一些副作用操作放到事件回調(diào)函數(shù)中去執(zhí)行是更好的選擇,這樣就可以不用考慮 useEffect 依賴項(xiàng)的問題了。
useEffect 是如何捕獲 props 和 state 的
useEffect 包裹的函數(shù)中,捕獲 props 和 state 的方式跟普通函數(shù)沒兩樣,依賴于函數(shù)組件本身的執(zhí)行上下文。useEffect 內(nèi)部并沒有做什么如數(shù)據(jù)綁定、依賴 fiber 等特別的事情。因此,函數(shù)組件每一次渲染都有它自己的 effects。在函數(shù)組件渲染完成后,產(chǎn)生的 effects 會(huì)被存儲(chǔ)到組件對應(yīng)的 fiber 上,等待特定的時(shí)機(jī)執(zhí)行這些 effects (副作用)。 即便是 effects 中某些異步回調(diào)執(zhí)行時(shí),頁面已經(jīng)重新渲染了很多次了,這些異步的回調(diào)函數(shù)中捕獲的 props 和 state 還是產(chǎn)生這些 effects 的那次渲染中組件的 state 和 props。
useEffect 的依賴項(xiàng)
哪些應(yīng)該被放在 useEffect 的依賴項(xiàng)中
理論上來說。useEffect 的心智模型更接近于 effect 在某些值變化時(shí)去執(zhí)行,但是有的時(shí)候?yàn)榱吮WC effect 中捕獲的 props 和 state 是你所期望的,你不得不將 effect 中用到的所有的組件內(nèi)的變量都放到依賴項(xiàng)中。如果你在項(xiàng)目中設(shè)置了對應(yīng)的 lint 規(guī)則,lint 工具也會(huì)告訴你應(yīng)該這樣做,但是這好像與 useEffect 的心智模型產(chǎn)生了一些沖突。
這種沖突帶來的后果是,effect 可能會(huì)頻繁的執(zhí)行,如下例是一個(gè)每秒遞增的計(jì)數(shù)器
function Counter () {
const [count, setCount] = useState(1)
useEffect(() => {
const timerId = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timerId);
}, [count]);
return (
<>{count}</>
)
}
如果移除了這個(gè) useEffect 的依賴項(xiàng)中的 count,那么定時(shí)器中的回調(diào)函數(shù)就會(huì)一直執(zhí)行 setCount(1 + 1),這不是我們所期望的。好吧,老實(shí)一點(diǎn),將 count 放到依賴項(xiàng)中,但是此時(shí)定時(shí)器會(huì)被頻繁的清除和創(chuàng)建,這可能會(huì)影響定時(shí)器回調(diào)的觸發(fā)頻率,這也不是我們所期望的。
到現(xiàn)在為止,問題還是沒有得到解決,我還是傾向于 useEffect 的依賴項(xiàng)是用來觸發(fā) effect 的,而不是用來解決閉包問題的,那么只能想辦法移除掉 useEffect 對 count 的依賴。
如何減少 useEffect 的依賴項(xiàng)
- 消除
useEffect中不必要的捕獲
如上例中的 useEffect 可以寫成
useEffect(() => {
const timerId = setInterval(() => {
setCount(count => count + 1);
}, 1000);
return () => clearInterval(timerId);
}, []);
- 將依賴從 effect 中解耦
還是這個(gè)定時(shí)計(jì)數(shù)器的例子,如果我們想要通過 props 傳遞一個(gè) step 屬性給這個(gè)組件,用來告訴這個(gè)組件每秒遞增的值的大小
<Counter step={2}/>
于是 Counter 組件就變成了這樣
function Counter ({step}) {
const [count, setCount] = useState(1)
useEffect(() => {
const timerId = setInterval(() => {
setCount(count => count + step);
}, 1000);
return () => clearInterval(timerId);
}, [step]);
return (
<>{count}</>
)
}
現(xiàn)在的問題是,當(dāng) step 的值變化時(shí),仍然會(huì)重啟定時(shí)器。現(xiàn)在該 useReducer 上場了。
function Counter ({step}) {
const [count, dispatch] = useReducer(reducer, 0);
function reducer(state, action) {
if (action.type === 'tick') {
return state + step;
} else {
throw new Error('type in action is not true');
}
}
useEffect(() => {
const timerId = setInterval(() => {
dispatch({ type: 'tick' });
}, 1000);
return () => clearInterval(timerId);
}, []);
return (
<>{count}</>
)
}
那么現(xiàn)在問題來了:
Q:為什么我們可以不在依賴項(xiàng)中加入 _dispatch_ ?
A:因?yàn)?React 向我們保證了 _dispatch_ (來源于useReducer)、setState (來源于useState)以及ref.current (來源于useRef)即使組件多次渲染,它們的引用地址也不會(huì)改變
Q:為什么 reducer 中捕獲到的 step 值是最新的?
A:對于 useReducer 來說,React 只會(huì)記住它的 action,并不會(huì)記住它的 reducer。也就是說每次組件重新渲染執(zhí)行 useReducer 時(shí),它都會(huì)重新讀取 reducer
上文中說過 useState 和 useReducer 在源碼中是同一個(gè)東西。在實(shí)際使用過程中,相比于 useState ,useReducer 可以讓我們把 更新邏輯(reducer)和 描述發(fā)生了什么(action)分開 ,而這一點(diǎn)正好可以用來移除不必要的依賴。
當(dāng)然實(shí)際業(yè)務(wù)中,很少會(huì)碰到上述例子中的場景,絕大部分情況下,都只用將想要用來觸發(fā)執(zhí)行 effect 的值放到 useEffect 的依賴項(xiàng)中。對于無法用上面講的兩種方法解決的場景,也可以通過 useRef 來繞過煩人的閉包問題。
函數(shù)應(yīng)當(dāng)作為 useEffect 的依賴項(xiàng)嗎
個(gè)人觀點(diǎn)是:絕大多數(shù)情況下,不應(yīng)該將函數(shù)作為 useEffect 的依賴項(xiàng)。至于能否安心的將函數(shù)從依賴項(xiàng)中移除主要看
-
函數(shù)是否參與 React 的數(shù)據(jù)流
簡單來說,就是看這個(gè)函數(shù)中是否用到了函數(shù)組件內(nèi)部的變量(useRef 除外)。如果一個(gè)函數(shù)并沒有參與 React 數(shù)據(jù)流,但是在useEffect中用到了,此時(shí)你應(yīng)該將這個(gè)函數(shù)提取到組件外部,這樣你就可以在useEffect的依賴項(xiàng)中無腦移除掉這個(gè)函數(shù)。 -
函數(shù)是否被異步延遲調(diào)用
函數(shù)被延遲調(diào)用的情況下很容易產(chǎn)生閉包問題,這時(shí)即使將函數(shù)作為 useEffect 的依賴項(xiàng),也無法解決閉包問題,反而可能增加 effect 的觸發(fā)頻率,下文中的 使用可變數(shù)據(jù)替代不可變數(shù)據(jù) 一節(jié)會(huì)介紹一種方法能夠保證在函數(shù)引用地址不變的情況下,使函數(shù)自動(dòng)捕組件內(nèi)變量最新的值的方法。
大多數(shù)情況下都可以不用將函數(shù)放在 useEffect 的依賴項(xiàng)中。也許有一些極端特殊的業(yè)務(wù)場景,這時(shí)只能將函數(shù)用 useCallback 包裹,然后放到 useEffect 的 依賴項(xiàng)中 (我目前沒碰到過這種情況) 。
useRef hook
按照我個(gè)人的理解,useRef 更像是 class 組件的實(shí)例屬性,即 this.xxx。在函數(shù)組件中,useRef 可以看做是一個(gè)容器,你可以任意操作這個(gè)容器中的數(shù)據(jù),并且這個(gè)容器中的引用地址不會(huì)因?yàn)榻M件多次重新渲染而改變。在我看來它就是函數(shù)組件的作弊器同時(shí)也是解決函數(shù)組件中閉包問題的絕世利器。
useRef 的特征
- 當(dāng) useRef 存儲(chǔ)的值變化時(shí),并不會(huì)引起組件重新渲染
- 可以用來存放可變數(shù)據(jù),在組件多次渲染時(shí),能保持 ref (useRef 返回值)本身的引用地址不變
_ps: useRef 能保證返回值引用地址不變的原因是,即使組件多次渲染,useRef 返回的 ref 還是第一次執(zhí)行時(shí)返回的那個(gè) ref。在函數(shù)組件第一次渲染時(shí), React 內(nèi)部會(huì)將 useRef 的返回值(ref)存儲(chǔ)在組件對應(yīng)的 fiber 節(jié)點(diǎn)上,后續(xù)組件重新渲染時(shí),React 內(nèi)部不會(huì)對 useRef 做任何處理,直接返回 fiber 節(jié)點(diǎn)上存儲(chǔ)的 ref。詳情可見源碼 mountRef 和 _updateRef
基于 useRef 的特征可以做什么
- 實(shí)現(xiàn)一個(gè)自定義 hook 用來統(tǒng)計(jì)組件的渲染次數(shù)
const useRenderTimes = () => {
const ref = useRef(0)
ref.current += 1
return ref.current
}
- 記錄上一次組件渲染時(shí)的某個(gè)值
const usePreValue = (value) => {
const ref = useRef(undefined)
const preValue = ref.current
ref.current = value
return preValue
}
- 避開不必要的重新渲染
如果某一個(gè)狀態(tài)與渲染無關(guān),那么你可以使用 useRef 代替 useState 。還記得上述 useEffect 那一節(jié)中的 Counter 組件嗎,如果將 count 作為 useEffect 的依賴項(xiàng),那么定時(shí)器會(huì)不停的創(chuàng)建/銷毀,上面給出了兩種解決方法,現(xiàn)在我們來說說另一種方式。思路是,只要在 count 變化時(shí),不重新渲染組件就好了, 那么可以使用 useRef 存儲(chǔ) count 值,當(dāng)然,這種方式的僅限于 count 不參與渲染的情況,或者也可以在 useRef 中存儲(chǔ)值改變的同時(shí)去觸發(fā)組件重新渲染。
這樣的場景其實(shí)很常見,比如有一個(gè)表單,當(dāng)用戶在表單中填寫完成后,點(diǎn)擊 submit 按鈕,將數(shù)據(jù)通過接口發(fā)送給后端。如果這個(gè)表單不是一個(gè)受控組件,那么相比于 useState 用 useRef 存儲(chǔ)表單數(shù)據(jù)是個(gè)更好的選擇,因?yàn)?strong>它不會(huì)導(dǎo)致不必要的重新渲染。
function Counter () {
const count = useRef(1)
useEffect(() => {
const timerId = setInterval(() => {
count.current += 1;
}, 1000);
return () => {
clearInterval(timerId)
console.log(count.current)
};
}, []);
}
有時(shí)在函數(shù)組件中使用 useState 是為了在組件重新渲染之后仍然能拿到某個(gè)值,但我們希望讓這個(gè)值變化時(shí)不要觸發(fā)組件更新,亦或是想避免 useState 的不可變數(shù)據(jù)導(dǎo)致的閉包問題,那么這個(gè)時(shí)候就是使用 useRef 的時(shí)機(jī)。
如何看待 useRef
在我看來在 React Hooks 中 useRef 最起碼與 useState是同等重要的,知乎有篇文章中的一句話這樣說,
每一個(gè)希望深入 hook 實(shí)踐的開發(fā)者都必須記住這個(gè)結(jié)論,無法自如地使用 useRef 會(huì)讓你失去 hook 將近一半的能力。
表示認(rèn)同。
useCallback Hook
關(guān)于 useCallback,官網(wǎng)上的介紹是
把內(nèi)聯(lián)回調(diào)函數(shù)及依賴項(xiàng)數(shù)組作為參數(shù)傳入
useCallback,它將返回該回調(diào)函數(shù)的 memoized 版本,該回調(diào)函數(shù)僅在某個(gè)依賴項(xiàng)改變時(shí)才會(huì)更新。當(dāng)你把回調(diào)函數(shù)傳遞給經(jīng)過優(yōu)化的并使用引用相等性去避免非必要渲染(例如shouldComponentUpdate)的子組件時(shí),它將非常有用。
又看到了熟悉的詞匯-“依賴項(xiàng)”,要想保證在 useCallback 中包裹的函數(shù)捕獲到當(dāng)前渲染時(shí)函數(shù)組件內(nèi)部的值,必須將 useCallback 包裹的函數(shù)中所有引用到的函數(shù)組件內(nèi)部的值都放到依賴項(xiàng)中。另外,請注意官網(wǎng)介紹的 useCallback 的作用是- “性能優(yōu)化”。
你真的需要為函數(shù)組件中的每一個(gè)函數(shù)都包裹上 useCallback 嗎? 就拿官方文檔中的 shouldComponentUpdate 舉例,我們在函數(shù)組件中定義了一個(gè)函數(shù) handleClick 并用 useCallback 包裹,然后通過 props 傳遞給子組件,子組件中通過shouldComponentUpdate 對比 handleClick ,決定是否需要更新。
function Parent () {
const handleClick = useCallback(()=>{
//...
},[...])
return (<Child handleClick={handleClick}/>)
}
class Child extends React.Component {
shouldComponentUpdate(nextProps) {
return this.props.handleClick !== nextProps.handleClick
}
// ...
}
那么此時(shí)應(yīng)該有一個(gè)疑問,性能提升到底有多大,如果你感興趣的話,不妨動(dòng)起手來,寫個(gè)示例,對比一下 performance 性能面板,你應(yīng)該看到 useCallback 對性能提升到底有多大,同時(shí)根據(jù)測試結(jié)果,可以大概得到什么時(shí)候應(yīng)該用 useCallback 來提升性能。useCallback 的另一個(gè)作用是可以維持函數(shù)引用地址不變。但是它仍然會(huì)在依賴項(xiàng)變化時(shí)重新生成函數(shù),想要維持函數(shù)引用地址一直不變還要是要使用 useRef
我抵觸 useCallback 的原因是,在我看來它本身的作用比較雞肋,而且使用 useCallback,必須注意依賴項(xiàng),這又還會(huì)帶來額外的心智負(fù)擔(dān)。
useMemo Hook
使用 useMemo 時(shí)需要注意的點(diǎn)不多,官方文檔也寫的非常明白了
useMemo 的作用是
把“創(chuàng)建”函數(shù)和依賴項(xiàng)數(shù)組作為參數(shù)傳入
useMemo,它僅會(huì)在某個(gè)依賴項(xiàng)改變時(shí)才重新計(jì)算 memoized 值。這種優(yōu)化有助于避免在每次渲染時(shí)都進(jìn)行高開銷的計(jì)算。
請把著重點(diǎn)放在 “高開銷的計(jì)算” 上,有的時(shí)候,可能也并不需要 useMemo
使用 useMemo 時(shí)需要注意的是
你可以把
**useMemo**作為性能優(yōu)化的手段,但不要把它當(dāng)成語義上的保證。將來,React 可能會(huì)選擇“遺忘”以前的一些 memoized 值,并在下次渲染時(shí)重新計(jì)算它們,比如為離屏組件釋放內(nèi)存。先編寫在沒有useMemo的情況下也可以執(zhí)行的代碼 —— 之后再在你的代碼中添加useMemo,以達(dá)到優(yōu)化性能的目的。
函數(shù)組件中的閉包問題
結(jié)合上文,可以總結(jié)出在函數(shù)組件中,閉包問題主要是因?yàn)楹瘮?shù)的延遲調(diào)用,不論是 useEffect 包裹的函數(shù)還是定時(shí)器回調(diào)函數(shù)亦或者是異步請求的回調(diào)函數(shù),它們內(nèi)部捕獲到的變量都是存在外部函數(shù)組件執(zhí)行時(shí)產(chǎn)生的閉包中,那么想要規(guī)避閉包帶來的困擾,思路有兩個(gè)
減少函數(shù)內(nèi)部對外部變量的依賴
比如上述定時(shí)計(jì)數(shù)器例子中
setCount(count + 1)
// 替換為
setCount(count => count + 1)
使用可變數(shù)據(jù)替代不可變數(shù)據(jù)
在 class 組件中很少遇到閉包的困擾是因?yàn)樵?class 組件中訪問組件的 state 和 props 都是通過 this,雖然 this.state 和 this.props 指向的是是不可變數(shù)據(jù),但是 this 內(nèi)部存儲(chǔ)的數(shù)據(jù)是可變的并且 this 的引用地址不會(huì)發(fā)生改變。那么函數(shù)組件中有沒有類似 this 的東西呢?有, useRef。
-
對于非函數(shù)類型,可以使用 useRef 替代 useState
這樣即使是延遲調(diào)用的函數(shù),也可以通過 ref.current 取到最新的值,因?yàn)檠舆t調(diào)用的函數(shù)里面取的是 useRef 返回值的引用地址。上文中的例子中也這樣用過了。需要注意的是,如果 useRef 中存儲(chǔ)的值參與了渲染,比如
function demo () {
const text = useRef("")
return <>{text.current}</>
}
這時(shí),更新 useRef 中存儲(chǔ)的值,并不會(huì)引起視圖重新渲染。但是我們可以通過更新另一個(gè)狀態(tài) (useState) 來使視圖同步。如果在組件中實(shí)在找不到一個(gè)可以在 useRef 內(nèi)部的值變化時(shí)去觸發(fā)更新的狀態(tài),那么也可以寫一個(gè)自定義 hook 去強(qiáng)制觸發(fā)更新
function useForceUpdate () {
const [, forceUpdate] = useReducer(x => x + 1, 0)
return forceUpdate
}
封裝一下,就可以得到一個(gè)存儲(chǔ)可變數(shù)據(jù)的 useState
function useMutableState (init) {
const stateRef = useRef(init)
const [, updateState] = useReducer((preState, action) => {
stateRef.current = typeof action === 'function'
? action(preState)
: action
return stateRef.current
}, init)
return [stateRef, updateState]
}
- 對于函數(shù)類型,也可以通過 useRef 保持函數(shù)引用地址不變,函數(shù)內(nèi)部自動(dòng)捕獲最新的值
function useStableFn(fn, deps) {
const fnRef = useRef();
fnRef.current = fn;
return useCallback(() => {
return fnRef.current();
}, []);
}
結(jié)語
在 React Hooks 中很難總結(jié)出真正完美的最佳實(shí)踐,就連官方文檔和博客上也只是描述了 React Hooks 的心智模型。上文中的有些觀點(diǎn)或者示例違背了官方給出的心智模型,不得不承認(rèn)我是 useRef 的愛好者。但是對于 React Hooks 的實(shí)踐來說,沒有銀彈。重要的是,你是否理解 hooks 是如何工作的,以及你有沒有自己的避坑指南。
參考鏈接
這篇文章旨在總結(jié) React Hooks 的使用技巧以及在使用過程中需要注意的問題,其中會(huì)附加一些問題產(chǎn)生的原因以及解決方式。但是請注意,文章中所給出的解決方式并不一定完全適用,解決問題的方案有很多種,也許你所在的團(tuán)隊(duì)針對這些問題已經(jīng)給出了對應(yīng)的規(guī)范,亦或是你已經(jīng)對這些問題的解決方式形成了更好的認(rèn)知。所以你的著重點(diǎn)應(yīng)該放在你是否在使用 React hooks 過程中意識(shí)到了這些問題以及你對這些問題的思考