寫在開頭
React Hooks在我的上一個項目中得到了充分的使用,對於這個項目來說,我們跳過傳統的類組件直接過渡到函數組件,確實是一個不小的挑戰。在項目開發過程中也發現項目中的其他小伙伴(包括我自己)有時候會存在使用不當的情況,因此對官方的幾個鈎子函數做一個較為全面的總結。
函數式組件出現的原因
為什么會出現函數式組件,因為傳統的類組件確實有不少缺點:
- 類組件中的
this指向有點繞 - 通過選項去組織代碼,在組件比較大的時候會很痛苦,因為類組件天生分離,不符合內聚性原則
- 組件復用不方便,尤其是
mixin,很容易帶來數據來源指向不清楚的問題
函數式組件居然“有狀態了”
我們知道,在過去,函數式組件被稱作“傻瓜組件”,因為它並不具有自身的狀態,通常被用來做一些渲染視圖的工作,即UI = render(props)。這是一個純粹的輸入輸出模型,無任何副作用。但是React Hooks的出現,讓函數式組件擁有自身的狀態成為了可能。
函數式組件在運行過程中會被調用很多次,假如我們將狀態保存在函數體里面,毫無疑問是不可行的。因為函數是一種“用完即銷毀”的東西。
這正是是Hooks所做的事情:將一個函數組件的狀態保存在函數外面。准確來說,是這個函數組件對應的Hooks鏈表。當函數式組件需要用到該狀態的時候,通過Hooks這一鈎子將狀態從函數體外部“鈎進來”。
函數式組件其實也有“生命周期”
函數式組件的生命周期可以分為以下三部分:
初次渲染(first-render) ---> 重渲染 (re-render) ---> 銷毀(destroy)
當我們第一次使用函數式組件的時候,會觸發初次渲染(first-render);若其 props 改變,就會調用該 render 函數,觸發重渲染(re-render)。
每一次的渲染,都是獨立的。這正是函數式組件的美妙之處。
那么react如何決定要不要調用 render 函數來更新 UI 視圖呢?這取決於data有沒有更新。從整個組件樹來看,data指的是整個組件的state;從具體到某個功能組件來看,data也可以被認為是props和自身state的結合體。
render 的執行取決於 data 變化,而 data 中的 state 數據是保存在鏈表中的。
鏈表的特性是啥?就是每個元素都有一個
next指針指向下一個元素,一環扣一環關聯起來。所以為什么 hooks 不能用在條件判斷/循環/嵌套中,因為這些都不能保證每次渲染時讀取 hooks 鏈表的順序是完全一致的。尤其對於狀態讀取來說,讀取順序和初次渲染鏈表記錄的順序不一致,會直接導致一些useState鈎子讀取到錯誤的狀態值。
useSate,狀態保存之處
用法
const [count, setCount] = useState(0);
原理
首先,useState 會生成一個狀態和修改狀態的函數。這個狀態會保存在函數式組件外面,每次重渲染時,這一次渲染都會去外面把這個狀態鈎回來,讀取成常量並寫進該次渲染中。
通過調用修改狀態的函數,會觸發重渲染。到這里我們總結:props 的改變和 setState 的調用,都會觸發 re-render。
由於每次渲染都是獨立的,所以每次渲染都會讀到一個獨立的狀態值,這個狀態值,就是通過鈎子鈎到的 state 並讀取到的常量。
這就是所謂的capture value特性,每次的渲染都是獨立的,每次渲染的狀態其實都只是常量罷了。
深入本質
讓我們看深入一下本質,看看 useState 和 re-render 到底如何關聯起來:
- 函數式組件初次渲染,一個個的
useState依次執行,生成hooks鏈表,里面記錄了每個state的初始值和對應的setter函數 - 這個鏈表會掛在這個函數式組件的外面,可以被
useState或相應setter訪問 - 當某個時刻調用了
setSetter,將會直接改變這個hooks鏈表 - hooks鏈表其實就是這個函數式組件的狀態表,它的改變等效於狀態改變,會引起函數式組件重渲染
- 這個函數式組件重渲染,執行到
useState時,因為初次執行已經掛載過一個 hooks 鏈表了,這個時候就會直接讀取鏈表的相應值
這也就是為什么叫useState,而不是createState。
useRef,DOM訪問與外部狀態保存
useRef有啥用
useRef主要有兩個作用:
- 用來訪問DOM;
- 用來保存變量到當前函數式組件外部。
訪問DOM
我們先來看看前者怎么用吧:
const inputRef = useRef(null);
const handleClick = () => {
inputRef.current?.focus();
}
return (
<input ref={inputRef} />
<button onClick={handleClick}>點擊</button>
)
這樣就可以方便地訪問DOM節點。
保存可變值
前面我們提到,useState可以方便地保存狀態值,但是由於函數式組件的capture value特性,使得我們並不能以一種比較方便的形式獲取到更改后的狀態值。
const [num, setNum] = useState(0);
const increaseNum = () => {
setNum(prev => prev + 1);
console.log(num); // 打印的仍然是舊值,因為num在這一幀被常量化了
}
而useRef將會創建一個ref對象,並把這個ref對象保存在函數式組件外部,這樣的好處在於:
- 獨立於
capture value之外存儲,不用擔心獲得過時變量的問題; - 可以同步修改狀態。
我們試驗如下:
const numRef = useRef(0);
const increaseNum = () => {
numRef.current += 1;
console.log(numRef.current); // 能獲取最新值
}
但是要注意⚠️:由於引用沒變,上述操作並不會引起函數式組件的重渲染。 這是一個很容易引起錯誤的地方!
useEffect,生命周期與觀察者
用法及建議
useEffect 的模型十分之簡潔,如下:
useEffect(effectFn, deps);
useEffect 可以模擬舊時代的三個生命周期:componentDidMount、shouldComponentUpdate、componentWillUnmount,相當於三個生命周期合並為一個 api。
所謂shouldComponentUpdate,其實就是去除deps依賴數組,如此一來這個副作用的 effectFn 會在首次渲染之后和每次重渲染之后執行,相當於模擬了 shouldComponentUpdate 這一生命周期,如下:
useEffect(() => {
// xxx
});
而所謂componentDidMount,則是傳入一個空數組作為依賴,因為當有 deps 數組時,里面 effectFn 是否執行取決於 deps 數組內的數據是否變化,空數組內無數據,所以對比自然也就無變化,使用如下:
useEffect(() => {
// xxx
}, []);
而componentWillUnmount,則是在effectFn中返回一個清除函數,如下:
useEffect(() => {
// 執行副作用
// ...
return () => {
// 清除上面的副作用
// ...
};
}, []);
此外我們應該始終遵循一個原則:那就是不要對 deps 依賴撒謊。否則會引發一系列 bug。當然編輯器的 linter 也不會允許我們這樣做,這一點非常關鍵。
原理
effectFn 就是當依賴變化時執行的副作用函數,這里的副作用,並不是一個貶義詞,而是一個中性詞。
函數內部與外部發生的任何交互都算副作用,比如打印個日志、開啟一個定時器,發一個請求,讀取全局變量等等等等。
好,現在這個 effectFn 可以返回一個清理函數cleanUp,用於清除這個副作用。典型的清理函數,如:clearInterval、clearTimeout,如:
useEffect(() => {
const timer = setTimeout(() => console.log("over"), 1000);
return () => clearTimout(timer);
});
useEffect 其實是每次渲染完成后都會執行,但是 effectFn 是否執行,就要看依賴有沒有變化了。執行 useEffect 的時候,會拿這次渲染的依賴跟上次渲染的對應依賴對比,如果沒變化,就不執行 effectFn,如果有變化,才執行 effectFn。
如果連依賴都沒有,那 react 就認為每次都有變化,每次運行 useEffect 必運行 effectFn。
useEffect 有典型的三大特點:
- 會在每次渲染完成后才執行,不會阻塞渲染,從而提高性能
- 在每次運行
effectFn之前,要把前一次運行effectFn遺留的cleanUp函數執行掉(如果有的話) - 在組件銷毀時,會把最后一次運行
effectFn遺留的cleanUp函數執行掉。
deps 數組里面的各個依賴與上次的依賴是否相同,需要通過Object.is來比較,比如:
Object.is(22, 22); // true
Object.is([], []); // false
這樣就會有一個隱患,當 deps 數組里面的子元素為引用類型的時候,每次對比都會是false,從而執行effectFn。因為 Object.is 對比引用類型的時候,比較的是兩個指針是否指向堆內存中的同一個地址。
useEffect 的執行機制,是在初次渲染時,執行到 useEffect 就將內部的 effectFn 放到兩個地方:一個是 hooks 鏈表中,另外一個則是EffectList 隊列中。在渲染完成后,會依次執行 EffectList 里面的 effectFn 集合。
所以,說白了,要不要 re-render,完全取決於鏈表里面的東西有沒有變化。
細節
不同於 vue 里面有async mounted,在 useEffect 里面的 effectFn,應該始終堅持一個原則:要么不返回,要么返回一個 cleanUp 清除函數。像下面這樣寫是不行的:
// 錯誤的用法❌
useEffect(async () => {
const response = await fetch("...");
// ...
});
另外我們很容易發現:我們並不需要把 useState 返回的第二個 Setter 函數作為useEffect 的依賴。實際上,React 內部已經對 Setter 函數做了 Memoization 處理,因此每次渲染拿到的 Setter 函數都是完全一樣的,不需要把這個Setter函數放到deps數組里面。
