手寫useState與useEffect
useState
與useEffect
是驅動React hooks
執行的基礎,useState
用於管理狀態,useEffect
用以處理副作用,通過手寫簡單的useState
與useEffect
來理解其執行原理。
useState
一個簡單的useState
的使用如下。
// App.tsx
import { useState } from "react";
import "./styles.css";
export default function App() {
const [count, setCount] = useState(0);
console.log("refresh");
const addCount = () => setCount(count + 1);
return (
<>
<div>{count}</div>
<button onClick={addCount}>Count++</button>
</>
);
}
當頁面在首次渲染時會render
渲染<App />
函式元件,其實際上是呼叫App()
方法,得到虛擬DOM
元素,並將其渲染到瀏覽器頁面上,當使用者點選button
按鈕時會呼叫addCount
方法,然後再進行一次render
渲染<App />
函式元件,其實際上還是呼叫了App()
方法,得到一個新的虛擬DOM
元素,然後React
會執行DOM diff
演算法,將改變的部分更新到瀏覽器的頁面上。也就是說,實際上每次setCount
都會重新執行這個App()
函式,這個可以通過console.log("refresh")
那一行看到效果,每次點選按鈕控制檯都會列印refresh
。
那麼問題來了,頁面首次渲染和進行+1
操作,都會呼叫App()
函式去執行const [count, setCount] = useState(0);
這行程式碼,那它是怎麼做到在+ +
操作後,第二次渲染時執行同樣的程式碼,卻不對變數n
進行初始化也就是一直為0
,而是拿到n
的最新值。
考慮到上邊這個問題,我們可以簡單實現一個useMyState
函式,上邊在Hooks
為什麼稱為Hooks
這個問題上提到了可以勾過來一個函式作用域的問題,那麼我們也完全可以實現一個Hooks
去勾過來一個作用域,簡單來說就是在useMyState
裡邊儲存一個變數,也就是一個閉包裡邊儲存了這個變數,然後這個變數儲存了上次的值,再次呼叫的時候直接取出這個之前儲存的值即可,https://codesandbox.io/s/fancy-dust-kbd1i?file=/src/use-my-state-version-1.ts
。
// index.tsx
import { render } from "react-dom";
import App from "./App";
// 改造一下讓其匯出 讓我們能夠強行重新整理`<App />`
export const forceRefresh = () => {
console.log("Force fresh <App />");
const rootElement = document.getElementById("root");
render(<App />, rootElement);
};
forceRefresh();
// use-my-state-version-1.ts
import { forceRefresh } from "./index";
let saveState: any = null;
export function useMyState<T>(state: T): [T, (newState: T) => void] {
saveState = saveState || state;
const rtnState: T = saveState;
const setState = (newState: T): void => {
saveState = newState;
forceRefresh();
};
return [rtnState, setState];
}
// App.tsx
import { useMyState } from "./use-my-state-version-1";
import "./styles.css";
export default function App() {
const [count, setCount] = useMyState(0);
console.log("refresh");
const addCount = () => setCount(count + 1);
return (
<>
<div>{count}</div>
<button onClick={addCount}>Count++</button>
</>
);
}
可以在code sandbox
中看到現在已經可以實現點選按鈕進行++
操作了,而不是無論怎麼點選都是0
,但是上邊的情況太過於簡單,因為只有一個state
,如果使用多個變數,那就需要呼叫兩次useState
,我們就需要對其進行一下改進了,不然會造成多個變數存在一個saveState
中,這樣會產生衝突覆蓋的問題,改進思路有兩種:1
把做成一個物件,比如saveState = { n:0, m:0 }
,這種方式不太符合需求,因為在使用useState
的時候只會傳遞一個初始值引數,不會傳遞名稱; 2
把saveState
做成一個陣列,比如saveState:[0, 0]
。實際上React
中是通過類似單連結串列的形式來代替陣列的,通過next
按順序串聯所有的hook
,使用陣列也是一種類似的操作,因為兩者都依賴於定義Hooks
的順序,https://codesandbox.io/s/fancy-dust-kbd1i?file=/src/use-my-state-version-2.ts
。
// index.tsx
import { render } from "react-dom";
import App from "./App";
// 改造一下讓其匯出 讓我們能夠強行重新整理`<App />`
export const forceRefresh = () => {
console.log("Force fresh <App />");
const rootElement = document.getElementById("root");
render(<App />, rootElement);
};
forceRefresh();
// use-my-state-version-2.ts
import { forceRefresh } from "./index";
let saveState: any[] = [];
let index: number = 0;
export function useMyState<T>(state: T): [T, (newState: T) => void] {
const curIndex = index;
index++;
saveState[curIndex] = saveState[curIndex] || state;
const rtnState: T = saveState[curIndex];
const setState = (newState: T): void => {
saveState[curIndex] = newState;
index = 0; // 必須在渲染前後將`index`值重置為`0` 不然就無法藉助呼叫順序確定`Hooks`了
forceRefresh();
};
return [rtnState, setState];
}
// App.tsx
import { useMyState } from "./use-my-state-version-2";
import "./styles.css";
export default function App() {
const [count1, setCount1] = useMyState(0);
const [count2, setCount2] = useMyState(0);
console.log("refresh");
const addCount1 = () => setCount1(count1 + 1);
const addCount2 = () => setCount2(count2 + 1);
return (
<>
<div>{count1}</div>
<button onClick={addCount1}>Count1++</button>
<div>{count2}</div>
<button onClick={addCount2}>Count2++</button>
</>
);
}
可以看到已經可以實現在多個State
下的獨立的狀態更新了,那麼問題又又來了,<App />
用了saveState
和index
,那其他元件用什麼,也就是說多個元件如果解決每個元件獨立的作用域,解決辦法1
每個元件都建立一個saveState
和index
,但是幾個元件在一個檔案中又會導致saveState
、index
衝突。解決辦法2
放在元件對應的虛擬節點物件上,React
採用的也是這種方案,將saveState
和index
變數放在元件對應的虛擬節點物件FiberNode
上,在React
中具體實現saveState
叫做memoizedState
,實際上React
中是通過類似單連結串列的形式來代替陣列的,通過next
按順序串聯所有的hook
。
可以看出useState
是強依賴於定義的順序的,useState
陣列中儲存的順序非常重要在執行函式元件的時候可以通過下標的自增獲取對應的state
值,由於是通過順序獲取的,這將會強制要求你不允許更改useState
的順序,例如使用條件判斷是否執行useState
這樣會導致按順序獲取到的值與預期的值不同,這個問題也出現在了React.useState
自己身上,因此React
是不允許你使用條件判斷去控制函式元件中的useState
的順序的,這會導致獲取到的值混亂,類似於下邊的程式碼則會丟擲異常。
const App = () => {
let state;
if(true){
[state, setState] = React.useState(0);
}
return (
<div>{state}</div>
)
}
<!-- React Hook "React.useState" is called conditionally. React Hooks must be called in the exact same order in every component render react-hooks/rules-of-hooks-->
這裡當然只是對於useState
的簡單實現,對於React
真正的實現可以參考packages/react-reconciler/src/ReactFiberHooks.js
,當前的React
版本是16.10.2
,也可以簡略看一下相關的type
。
type Hooks = {
memoizedState: any, // 指向當前渲染節點`Fiber` 上一次完整更新之後的最終狀態值
baseState: any, // 初始化`initialState` 已經每次`dispatch`之後`newState`
baseUpdate: Update<any> | null, // 當前需要更新的`Update` 每次更新完之後會賦值上一個`update` 方便`react`在渲染錯誤的邊緣資料回溯
queue: UpdateQueue<any> | null, // 快取的更新佇列 儲存多次更新行為
next: Hook | null, // `link`到下一個`hooks` 通過`next`串聯所有`hooks`
}
useEffect
一個簡單的useEffect
的使用如下。
import { useEffect, useState } from "react";
import "./styles.css";
export default function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
console.log("refresh");
const addCount1 = () => setCount1(count1 + 1);
const addCount2 = () => setCount2(count2 + 1);
useEffect(() => {
console.log("count1 -> effect", count1);
}, [count1]);
return (
<>
<div>{count1}</div>
<button onClick={addCount1}>Count1++</button>
<div>{count2}</div>
<button onClick={addCount2}>Count2++</button>
</>
);
}
同樣,每次addCount1
都會重新執行這個App()
函式,每次點選按鈕控制檯都會列印refresh
,在這裡還通過count1
變動的副作用來列印了count1 -> effect ${count1}
,而點選addCount2
卻不會處罰副作用的列印,原因明顯是我們只指定了count1
的副作用,由此可見可以通過useEffect
來實現更細粒度的副作用處理。
在這裡我們依舊延續上邊useState
的實現思路,將之前的資料儲存起來,之後當函式執行的時候我們對比這其中的資料是否發生了變動,如果發生了變動,那麼我們便執行該函式,當然我們還需要完成副作用清除的功能,https://codesandbox.io/s/react-usestate-8v0li9?file=/src/use-my-effect.ts
。
// use-my-effect.ts
const dependencyList: unknown[][] = [];
const clearCallbacks: (void | (() => void))[] = [];
let index: number = 0;
export function useMyEffect(
callback: () => void | (() => void),
deps: unknown[]
): void {
const curIndex = index;
index++;
const lastDeps = dependencyList[curIndex];
const changed =
!lastDeps || !deps || deps.some((dep, i) => dep !== lastDeps[i]);
if (changed) {
dependencyList[curIndex] = deps;
const clearCallback = clearCallbacks[curIndex];
if (clearCallback) clearCallback();
clearCallbacks[curIndex] = callback();
}
}
export function clearEffectIndex() {
index = 0;
}
// App.tsx
import { useState } from "react";
import { useMyEffect, clearEffectIndex } from "./use-my-effect";
import "./styles.css";
export default function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
console.log("refresh");
const addCount1 = () => setCount1(count1 + 1);
const addCount2 = () => setCount2(count2 + 1);
useMyEffect(() => {
console.log("count1 -> effect", count1);
console.log("setTimeout", count1);
return () => console.log("clear setTimeout", count1);
}, [count1]);
useMyEffect(() => {
console.log("count2 -> effect", count2);
}, [count2]);
clearEffectIndex();
return (
<>
<div>{count1}</div>
<button onClick={addCount1}>Count1++</button>
<div>{count2}</div>
<button onClick={addCount2}>Count2++</button>
</>
);
}
通過上邊的實現,我們也可以通過將依賴與副作用清除函式存起來的方式,來實現useEffect
,通過對比上一次傳遞的依賴值與當前傳遞的依賴值是否相同,來決定是否執行傳遞過來的函式,在這裡由於我們無法得知這個React.Fc
元件函式是在什麼時候完成最後一個Effect
,我們就需要手動來賦值這個標記的index
為0
。當然在React
之中同樣也是將useEffect
掛載到了Fiber
上來實現的,並且將所需要的依賴值儲存在當前的Fiber
的memorizedState
中,通過實現的連結串列以及判斷初次載入來實現了通過next
按順序串聯所有的hooks
,這樣也就能知道究竟哪個是最後一個Hooks
了,另外useEffect
同樣也是強依賴於定義的順序的,能夠讓React
對齊多次執行元件函式時的依賴。
自定義Hooks
我在初學Hooks
的時候一直有一個疑問,對於React Hooks
的使用與普通的函式呼叫區別究竟在哪裡,當時我還對知乎的某個問題強答了一番。
以我學了幾天React
的理解,自定義Hooks
跟普通函式區別在於:
Hooks
只應該在React
函式元件內呼叫,而不應該在普通函式呼叫。Hooks
能夠呼叫諸如useState
、useEffect
、useContext
等,普通函式則不能。
由此覺得Hooks
就像mixin
,是在元件之間共享有狀態和副作用的方式,所以應該是應該在函式元件中用到的與元件生命週期等相關的函式才能稱為Hooks
,而不僅僅是普通的utils
函式。
對於第一個問題,如果將其宣告為Hooks
但是並沒有起到作為Hooks的功能,那麼私認為不能稱為Hooks
,為避免混淆,還是建議在呼叫其他Hooks
的時候再使用use
標識。當然,諸如自己實現一個useState
功能這種雖然並沒有呼叫其他的Hooks
,但是他與函式元件的功能強相關,肯定是屬於Hooks
的。
對於第二個問題的話,其實必須使用use
開頭並不是一個語法或者一個強制性的方案, 以use
開頭其實更像是一個約定,就像是GET
請求約定語義不攜帶Body
一樣, 其主要目的還是為了約束語法,如果你自己實現一個類似useState
簡單功能的話,就會了解到為什麼不能夠出現類似於if (xxx) const [a, setA] = useState(0);
這樣的程式碼了,React
文件中明確說明了使用Hooks
的規則,使用use
開頭的目的就是讓React
識別出來這是個Hooks
,從而檢查這些規則約束,通常也會使用ESlint
配合eslint-plugin-react-hooks
檢查這些規則。
後來對於這個問題有了新的理解,如果定義一個真正的自定義Hooks
的話,那麼通常都會需要使用useState
、useEffect
等Hooks
,就相當於自定義Hooks
是由官方的Hooks
組合而成的,而通過官方的這些Hooks
來組合的話,就可以實現將資料掛載到節點上,也就是上邊的實現提到的實際memorizedState
都是在Fiber
中的,而自行實現的函式例如上邊的Hooks
實現,是無法做到這一點的。也就是說我們通過自定義Hooks
是通過來組合官方Hooks
以及自己的邏輯來實現的對於節點內的一些狀態或者其他方面的邏輯封裝,而使用普通函式且採用類似於Hooks
的語法的話則只能實現在全域性的狀態和邏輯的封裝,簡單來說就是提供了介面來讓我們可以在節點上做邏輯的封裝。
有一個簡單的例子,例如我們要封裝一個useUpdateEffect
來避免在函式元件在第一次掛載的時候就執行effect
,在這裡我們就應該採用useRef
或者是useState
而不是僅僅定義一個變數來儲存狀態值,https://codesandbox.io/s/flamboyant-tu-21po2l?file=/src/App.tsx
。
// use-update-effect-ref.ts
import { DependencyList, EffectCallback, useEffect, useRef } from "react";
export const useUpdateEffect = (
effect: EffectCallback,
deps?: DependencyList
) => {
const isMounted = useRef(false);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
} else {
return effect();
}
}, deps);
};
// use-update-effect-var.ts
import { DependencyList, EffectCallback, useEffect } from "react";
let isMounted = false;
export const useUpdateEffect = (
effect: EffectCallback,
deps?: DependencyList
) => {
useEffect(() => {
if (!isMounted) {
isMounted = true;
} else {
return effect();
}
}, deps);
};
// App.tsx
import { useState, useEffect } from "react";
import { useUpdateEffect } from "./use-update-effect-ref";
// import { useUpdateEffect } from "./use-update-effect-var";
import "./styles.css";
export default function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const addCount1 = () => setCount1(count1 + 1);
const addCount2 = () => setCount2(count2 + 1);
useUpdateEffect(() => {
console.log("count1 -> effect", count1);
}, [count1]);
useUpdateEffect(() => {
console.log("count2 -> effect", count2);
}, [count2]);
return (
<>
<div>{count1}</div>
<button onClick={addCount1}>Count1++</button>
<div>{count2}</div>
<button onClick={addCount2}>Count2++</button>
</>
);
}
當我們切換use-update-effect-ref
與use-update-effect-var
的useUpdateEffect
時,我們會發現當重新整理頁面時使用use-update-effect-ref
將不會有值列印,而use-update-effect-var
則會列印count2 -> effect 0
,而在點選Count1++
或者Count2++
的效果都是正常的,說明use-update-effect-ref
是能夠我們想要的useUpdateEffect
功能,而use-update-effect-var
卻因為變數值共享的問題而無法正確實現功能,當然我們也可以通過類似於陣列的方式來解決這個問題,但是再具體到各個元件之間的共享上面,我們就無法在在類似於Hooks
語法的基礎上來實現了,必須手動註冊一個閉包來完成類似的功能,而且類似於useState
在set
時重新整理本元件以及子元件的方式,就必須藉助useState
來實現了。
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
https://zhuanlan.zhihu.com/p/265662126
https://juejin.cn/post/6927698033798807560
https://segmentfault.com/a/1190000037608813
https://github.com/brickspert/blog/issues/26
https://codesandbox.io/s/flamboyant-tu-21po2l
https://codesandbox.io/s/react-usestate-kbd1i
https://codesandbox.io/s/react-usestate-8v0li9
https://stackoverflow.com/questions/60133412/react-custom-hooks-vs-normal-functions-what-is-the-difference