手寫useState與useEffect

WindrunnerMax發表於2022-04-30

手寫useState與useEffect

useStateuseEffect是驅動React hooks執行的基礎,useState用於管理狀態,useEffect用以處理副作用,通過手寫簡單的useStateuseEffect來理解其執行原理。

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的時候只會傳遞一個初始值引數,不會傳遞名稱; 2saveState做成一個陣列,比如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 />用了saveStateindex,那其他元件用什麼,也就是說多個元件如果解決每個元件獨立的作用域,解決辦法1每個元件都建立一個saveStateindex,但是幾個元件在一個檔案中又會導致saveStateindex衝突。解決辦法2放在元件對應的虛擬節點物件上,React採用的也是這種方案,將saveStateindex變數放在元件對應的虛擬節點物件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,我們就需要手動來賦值這個標記的index0。當然在React之中同樣也是將useEffect掛載到了Fiber上來實現的,並且將所需要的依賴值儲存在當前的FibermemorizedState中,通過實現的連結串列以及判斷初次載入來實現了通過next按順序串聯所有的hooks,這樣也就能知道究竟哪個是最後一個Hooks了,另外useEffect同樣也是強依賴於定義的順序的,能夠讓React對齊多次執行元件函式時的依賴。

自定義Hooks

我在初學Hooks的時候一直有一個疑問,對於React Hooks的使用與普通的函式呼叫區別究竟在哪裡,當時我還對知乎的某個問題強答了一番。

以我學了幾天React的理解,自定義Hooks跟普通函式區別在於:

  • Hooks只應該在React函式元件內呼叫,而不應該在普通函式呼叫。
  • Hooks能夠呼叫諸如useStateuseEffectuseContext等,普通函式則不能。

由此覺得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的話,那麼通常都會需要使用useStateuseEffectHooks,就相當於自定義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-refuse-update-effect-varuseUpdateEffect時,我們會發現當重新整理頁面時使用use-update-effect-ref將不會有值列印,而use-update-effect-var則會列印count2 -> effect 0,而在點選Count1++或者Count2++的效果都是正常的,說明use-update-effect-ref是能夠我們想要的useUpdateEffect功能,而use-update-effect-var卻因為變數值共享的問題而無法正確實現功能,當然我們也可以通過類似於陣列的方式來解決這個問題,但是再具體到各個元件之間的共享上面,我們就無法在在類似於Hooks語法的基礎上來實現了,必須手動註冊一個閉包來完成類似的功能,而且類似於useStateset時重新整理本元件以及子元件的方式,就必須藉助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

相關文章