[React Hooks長文總結系列一]初出茅廬,狀態與副作用

陌上兮月發表於2021-03-30

寫在開頭

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特性,每次的渲染都是獨立的,每次渲染的狀態其實都只是常量罷了。

深入本質

讓我們看深入一下本質,看看 useStatere-render 到底如何關聯起來:

  1. 函式式元件初次渲染,一個個的 useState 依次執行,生成hooks連結串列,裡面記錄了每個 state 的初始值和對應的 setter 函式
  2. 這個連結串列會掛在這個函式式元件的外面,可以被 useState 或相應 setter 訪問
  3. 當某個時刻呼叫了 setSetter,將會直接改變這個hooks連結串列
  4. hooks連結串列其實就是這個函式式元件的狀態表,它的改變等效於狀態改變,會引起函式式元件重渲染
  5. 這個函式式元件重渲染,執行到 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物件儲存在函式式元件外部,這樣的好處在於:

  1. 獨立於capture value之外儲存,不用擔心獲得過時變數的問題;
  2. 可以同步修改狀態。

我們試驗如下:

const numRef = useRef(0);

const increaseNum = () => {
    numRef.current += 1;
    console.log(numRef.current); // 能獲取最新值
}

但是要注意⚠️:由於引用沒變,上述操作並不會引起函式式元件的重渲染。 這是一個很容易引起錯誤的地方!

useEffect,生命週期與觀察者

用法及建議

useEffect 的模型十分之簡潔,如下:

useEffect(effectFn, deps);

useEffect 可以模擬舊時代的三個生命週期:componentDidMountshouldComponentUpdatecomponentWillUnmount,相當於三個生命週期合併為一個 api。

所謂shouldComponentUpdate,其實就是去除deps依賴陣列,如此一來這個副作用的 effectFn 會在首次渲染之後和每次重渲染之後執行,相當於模擬了 shouldComponentUpdate 這一生命週期,如下:

useEffect(() => {
  // xxx
});

而所謂componentDidMount,則是傳入一個空陣列作為依賴,因為當有 deps 陣列時,裡面 effectFn 是否執行取決於 deps 陣列內的資料是否變化,空陣列內無資料,所以對比自然也就無變化,使用如下:

useEffect(() => {
  // xxx
}, []);

componentWillUnmount,則是在effectFn中返回一個清除函式,如下:

useEffect(() => {
  // 執行副作用
  // ...
  return () => {
    // 清除上面的副作用
    // ...
  };
}, []);

此外我們應該始終遵循一個原則:那就是不要對 deps 依賴撒謊。否則會引發一系列 bug。當然編輯器的 linter 也不會允許我們這樣做,這一點非常關鍵。

原理

effectFn 就是當依賴變化時執行的副作用函式,這裡的副作用,並不是一個貶義詞,而是一箇中性詞。

函式內部與外部發生的任何互動都算副作用,比如列印個日誌、開啟一個定時器,發一個請求,讀取全域性變數等等等等。

好,現在這個 effectFn 可以返回一個清理函式cleanUp,用於清除這個副作用。典型的清理函式,如:clearIntervalclearTimeout,如:

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(2222); // 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陣列裡面。

相關文章