帶你深入瞭解 useState

爲什麼 react 16 之前的函數組件沒有狀態?

衆所周知,函數組件在 react 16 之前是沒有狀態的,組件狀態只能通過 props 進行傳遞。

寫兩個簡單的組件,一個類組件和一個函數組件:

const App = () =><span>123</span>;

class App1 extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      a: 1,
    }
  }
  render() {
    return (<p>312</p>)
  }
}

用 babel 編譯 App1App1 編譯之後就是一個函數組件。

// 僞代碼
var App1 = /*#__PURE__*/function (_React$Component) {
  _inherits(App1, _React$Component);

  var _super = _createSuper(App1);

  function App1(props) {
    var _this;

    _classCallCheck(this, App1);

    _this = _super.call(this, props);
    _this.state = {
      a: 1
    };
    return _this;
  }

  _createClass(App1, [{
    key: "render",
    value: function render() {
      return/*#__PURE__*/(0, _jsxRuntime.jsx)("p", {
        children: "312"
      });
    }
  }]);

  return App1;
}(React.Component);

那爲什麼函數組件沒有狀態呢?函數組件和類組件的區別在於原型上是否有 render 這一方法。react 渲染時,調用類組件的 render 方法。而函數組件的 render 就是函數本身,執行完之後,內部的變量就會被銷燬,當組件重新渲染時,無法獲取到之前的狀態。而類組件與函數組件不同,在第一次渲染時,會生成一個類組件的實例,渲染調用的是 render 方法。重新渲染時,會獲取到類組件的實例引用,在不同的生命週期調用類組件對應的方法。

通過類組件和函數組件的渲染之後的數據結構來看,兩者之間也沒有區別。

爲什麼 react 16 之後函數組件有狀態?

衆所周知,react 16 做的最大改動就是 fiber。爲了適配 fiber,節點(fiber node)的數據結構做了很大的改動。修改一下 App 這個組件,在頁面渲染,得到下圖的 fiber node 數據結構:

const App = () => {
  const [a, setA] = React.useState(0);
  const [b, setB] = React.useState(1);
  return<span>123</span>
};

(左邊是函數組件,右邊是類組件)

react 如何知道當前的狀態屬於哪個組件?

所有的函數組件狀態都是通過 useState 進行注入,是如何做到識別到對應組件的呢?

在 react 的 render 流程中打個斷點,可以看到函數組件有一個特殊的 render 方法 renderWithHooks。方法有 6 個參數:currentworkInProgresscomponent、 propssecondArgnextRenderExpirationTime

current: 當前正在頁面渲染的node,如果是第一次渲染,則爲空
workInProgress: 新的node,用於下一次頁面的渲染更新
component: node對應的組件
props: 組件的props
secondArg: 不清楚...,不影響後續文章閱讀
nextRenderExpirationTime: fiber渲染的過期時間

在執行 renderWithHooks 的時候,會用變量 currentlyRenderingFiber$1 記錄當前的 fiber node。於是在執行函數組件的時候,useState 方法就能拿到到當前 node 的狀態。將狀態插入到對應 node 的 memoizedState 字段中。同時返回的觸發 state 改變的方法因爲閉包,在執行變更時,也知道是哪個 fiber node。相應源碼:

function mountState(initialState) {
  // 獲取hook狀態
  var hook = mountWorkInProgressHook();

  if (typeof initialState === 'function') {
    // $FlowFixMe: Flow doesn't like mixed types
    initialState = initialState();
  }

  hook.memoizedState = hook.baseState = initialState;
  var queue = hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState
  };
  // 綁定當前node和更新隊列
  var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);
  return [hook.memoizedState, dispatch];
}

renderWithHooks 只用於函數組件的渲染。

從 memoizeState 字段的值看出,函數組件和類組件的 state 存儲的數據結構不一樣了。類組件是簡單的數據對象,而函數組件是單向鏈表。

interface State {
    memoizedState: state數據,和baseState值相同,
  baseState: state數據,
  baseQueue: 本次更新之前沒執行完的queue,
  next: 下一個state,
  queue: {
    pending: 更新state數據(這個數據是一個對象,裏面有數據,還有其他key用於做其他事情。),
    dispatch: setState方法本身,
    lastRenderedReducer: useReducer用得上,
    lastRenderedState: 上次渲染的State.memoizedState數據,
  }
}

調用 setA 方法,發生了什麼?

在說更新組件 state 之前,先看下組件掛載的流程。

調用 useState 的時候,會利用 currentlyRenderingFiber$1 拿到當前組件的 fiber node,並掛載數據到節點上的 memoizedState 的字段上。這樣函數組件就有了狀態。

// react
function useState(initialState) {
  var dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

function resolveDispatcher() {
  // ReactCurrentDispatcher 的值是react-dom注入的,後續會講。
  var dispatcher = ReactCurrentDispatcher.current;

  if (!(dispatcher !== null)) {
    {
      throwError( "Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:\n1. You might have mismatching versions of React and the renderer (such as React DOM)\n2. You might be breaking the Rules of Hooks\n3. You might have more than one copy of React in the same app\nSee https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem." );
    }
  }

  return dispatcher;
}


// react-dom 會根據當前組件的狀態注入不同的useState實現方法,這裏可以先忽略。
useState: function (initialState) {
  currentHookNameInDev = 'useState';
  mountHookTypesDev();
  var prevDispatcher = ReactCurrentDispatcher.current;
  ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV;

  try {
  // 掛載state
    return mountState(initialState);
  } finally {
    ReactCurrentDispatcher.current = prevDispatcher;
  }
},

function mountState(initialState) {
  // 生成hook初始化數據,掛到fiber node節點上
  var hook = mountWorkInProgressHook();

  if (typeof initialState === 'function') {
    // $FlowFixMe: Flow doesn't like mixed types
    initialState = initialState();
  }

  hook.memoizedState = hook.baseState = initialState;
  var queue = hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState
  };
  var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);
  return [hook.memoizedState, dispatch];
}

function mountWorkInProgressHook() {
  var hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null
  };

  if (workInProgressHook === null) {
    // node節點的memoizedState指向第一個hooks
    currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;
  } else {
    // 上一個hooks的next,等於當前hooks,同時把當前workInProgressHook,等於當前hooks
    workInProgressHook = workInProgressHook.next = hook;
  }

  return workInProgressHook;
}

useState 還會返回對應的 state 和修改 state 的方法。修改 state 的方法 dispatchAction 綁定了當前的 fiber node,同時還有當前更新狀態的 action queue

// 這裏刪除了部分無關代碼
function dispatchAction(fiber, queue, action) {
  // 這些都是用於Fiber Reconciler,在這裏不用太在意
  var currentTime = requestCurrentTimeForUpdate();
  var suspenseConfig = requestCurrentSuspenseConfig();
  var expirationTime = computeExpirationForFiber(currentTime, fiber, suspenseConfig);
  var update = {
    expirationTime: expirationTime,
    suspenseConfig: suspenseConfig,
    action: action,
    eagerReducer: null,
    eagerState: null,
    next: null
  };
  {
    update.priority = getCurrentPriorityLevel();
  }


  // pending 是當前state是否有未更新的任務(比如多次調用更新state的方法)
  var pending = queue.pending;

  // queue是一個循環鏈表
  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }

  queue.pending = update;
  var alternate = fiber.alternate;

  if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
    // Reconciler 計算是否還有時間渲染,省略
  } else {
    // 此處省略很多代碼
    // 標記當前fiber node需要重新計算。
    scheduleWork(fiber, expirationTime);
  }
}

從上面代碼可以看到,當調用 setA 方法更新組件 state 的時候,會生成需要更新的數據,包裝好數據結構之後,推到 state 中的 queue 中。

scheduleWork 會觸發 react 更新,這樣組件需要重新渲染。整體的流程和初次掛載的時候基本一致,但是從 mountState 方法體的實現來看,組件渲染是使用 initialState。這樣肯定是有問題的。

function mountState(initialState) {
  // 掛載state
  var hook = mountWorkInProgressHook();

  if (typeof initialState === 'function') {
    initialState = initialState();
  }

  // state的初始值是initialState,也就是組件傳入的值
  hook.memoizedState = hook.baseState = initialState;
  var queue = hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState
  };
  var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);
  return [hook.memoizedState, dispatch];
}

從此可以推斷,在前置步驟中,肯定有標示當前組件不是初次掛載,需要替換 useState 的實現方法。於是在 renderWithHooks 中找到了答案。

爲了方便理解,簡單說一下,react 有兩個比較關鍵的數據 current,workInProgress,分別代表當前頁面渲染的 fiber node,觸發更新之後計算差別的 fiber node。全部計算完成之後,current 就會指向 workInProgress,用於渲染。

// 這裏刪除部分無關代碼

// current 當前頁面上組件對應的fiber node
// workInProgress 當前重新渲染對應的fiber node
// Component 函數方法體
// ...
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderExpirationTime) {
  // currentlyRenderingFiber$1 是當前正在渲染的組件,後續渲染流程會從改變量獲取state
  currentlyRenderingFiber$1 = workInProgress;


  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  workInProgress.expirationTime = NoWork; // The following should have already been reset
  // currentHook = null;
  // workInProgressHook = null;
  // didScheduleRenderPhaseUpdate = false;
  // TODO Warn if no hooks are used at all during mount, then some are used during update.
  // Currently we will identify the update render as a mount because memoizedState === null.
  // This is tricky because it's valid for certain types of components (e.g. React.lazy)
  // Using memoizedState to differentiate between mount/update only works if at least one stateful hook is used.
  // Non-stateful hooks (e.g. context) don't get added to memoizedState,
  // so memoizedState would be null during updates and mounts.

  {
    // 如果當前current不爲null,且有state,說明當前組件是更新,需要執行的更新state,否則就是初次掛載。
    if (current !== null && current.memoizedState !== null) {
      ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
    } elseif (hookTypesDev !== null) {
      // This dispatcher handles an edge case where a component is updating,
      // but no stateful hooks have been used.
      // We want to match the production code behavior (which will use HooksDispatcherOnMount),
      // but with the extra DEV validation to ensure hooks ordering hasn't changed.
      // This dispatcher does that.
      ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV;
    } else {
      ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
    }
  }

  // 往後省略
}

在 renderWithHooks 方法中,會修改 ReactCurrentDispatcher,也就導致了 useState 對應的方法體不一樣。HooksDispatcherOnUpdateInDEV 中的 useState 方法調用是 updateState。這個方法會忽略 initState,選擇從 fiber node 的 state 中去獲取當前狀態。

useState: function (initialState) {
  currentHookNameInDev = 'useState';
  updateHookTypesDev();
  var prevDispatcher = ReactCurrentDispatcher.current;
  ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;

  try {
    return updateState(initialState);
  } finally {
    ReactCurrentDispatcher.current = prevDispatcher;
  }
},

function updateState(initialState) {
  return updateReducer(basicStateReducer);
}

function updateReducer(reducer, initialArg, init) {
  // 根據之前的state初始化新的state結構,具體方法在下面
  var hook = updateWorkInProgressHook();
  // 當前更新state的隊列
  var queue = hook.queue;

  queue.lastRenderedReducer = reducer;
  var current = currentHook; // The last rebase update that is NOT part of the base state.

  var baseQueue = current.baseQueue; // The last pending update that hasn't been processed yet.

  var pendingQueue = queue.pending;

  if (pendingQueue !== null) {
    // We have new updates that haven't been processed yet.
    // We'll add them to the base queue.
    if (baseQueue !== null) {
      // Merge the pending queue and the base queue.
      var baseFirst = baseQueue.next;
      var pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }

    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }

  if (baseQueue !== null) {
    // We have a queue to process.
    var first = baseQueue.next;
    var newState = current.baseState;
    var newBaseState = null;
    var newBaseQueueFirst = null;
    var newBaseQueueLast = null;
    var update = first;

    do {
      // fiber Reconciler 的內容,省略
      } else {
        // This update does have sufficient priority.
        if (newBaseQueueLast !== null) {
          var _clone = {
            expirationTime: Sync,
            // This update is going to be committed so we never want uncommit it.
            suspenseConfig: update.suspenseConfig,
            action: update.action,
            eagerReducer: update.eagerReducer,
            eagerState: update.eagerState,
            next: null
          };
          newBaseQueueLast = newBaseQueueLast.next = _clone;
        } // Mark the event time of this update as relevant to this render pass.
        // TODO: This should ideally use the true event time of this update rather than
        // its priority which is a derived and not reverseable value.
        // TODO: We should skip this update if it was already committed but currently
        // we have no way of detecting the difference between a committed and suspended
        // update here.


        markRenderEventTimeAndConfig(updateExpirationTime, update.suspenseConfig); // Process this update.

        if (update.eagerReducer === reducer) {
          // If this update was processed eagerly, and its reducer matches the
          // current reducer, we can use the eagerly computed state.
          newState = update.eagerState;
        } else {
                  // 執行狀態更新,reducer是個包裝函數:typeof action === 'function' ? action(state) : action;
          var action = update.action;
          newState = reducer(newState, action);
        }
      }

      update = update.next;
    } while (update !== null && update !== first);

    if (newBaseQueueLast === null) {
      newBaseState = newState;
    } else {
      newBaseQueueLast.next = newBaseQueueFirst;
    } // Mark that the fiber performed work, but only if the new state is
    // different from the current state.


    if (!objectIs(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }

    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;
    queue.lastRenderedState = newState;
  }

  var dispatch = queue.dispatch;
  return [hook.memoizedState, dispatch];
}

function updateWorkInProgressHook() {
  var nextCurrentHook;

  // 當前
  if (currentHook === null) {
    // alternate 指向的是當前頁面渲染組件對應fiber node
    var current = currentlyRenderingFiber$1.alternate;

    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

  var nextWorkInProgressHook;

  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber$1.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;
    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.
    if (!(nextCurrentHook !== null)) {
      {
        throwError( "Rendered more hooks than during the previous render." );
      }
    }

    currentHook = nextCurrentHook;
    var newHook = {
      memoizedState: currentHook.memoizedState,
      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,
      next: null
    };

    if (workInProgressHook === null) {
      
     // 第一個hook currentlyRenderingFiber$1.memoizedState = workInProgressHook = newHook;
    } else {
      // 下一個hooks,關聯前一個hooks
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }

  return workInProgressHook;
}

至此,調用 setA 方法,react 內部做了什麼就比較清晰了。setA 會在當前 state 的 queue 裏面插入一個 update action,並通知 react,當前有組件狀態需要更新。在更新的時候,useState 的方法體和初始掛載的方法體不一樣,更新的時候時候會忽略 useState 傳遞的 initState,從節點數據的 baseState 中獲取初始數據,並一步步執行 queue 裏的 update action,直至 queue 隊列爲空,或者 queue 執行完。

爲什麼有時候函數組件獲取的狀態不是實時的?

const App3 = () => {
  const [num, setNum] = React.useState(0);
  const add = () => {
    setTimeout(() => {
      setNum(num + 1);
    }, 1000);
  };

  return (
    <>
      <div>{num}</div>
      <button onClick={add}>add</button>
    </>
  );
}

在一秒內點擊按鈕,無論點擊多少次,最終頁面返回都會是 1。原因:setTimeout 閉包了當前狀態 num,在執行 update state 的時候,對應的 baseState 其實一直沒有更新,仍然是舊的,也就是 0,所以多次點擊,仍然是 0 + 1 = 1。修改的方式就是傳入的參數變爲函數,這樣 react 在執行 queue 的時候,會傳遞上一步的 state 值到當前函數中。

setNum((state) => state + 1);

爲什麼 useState 不能在判斷語句中聲明?

react 官網有這麼一段話:

參考我們上面說的,多個 state 之間通過 next 進行關聯,假設有 3 個 state,A、B、C。如果 B 在判斷語句中,那麼就會就會出現 A,B 的狀態能夠及時更新,但是 C 不會更新。因爲調用 2 次 useState,只會更新兩次 state,在 state 的鏈表中,A.next->B,B.next->C,那麼就只會更新了 A、B,C 不會更新,導致一些不可預知的問題。

爲什麼 state 要用鏈表關聯起來?

這個問題我也沒有想到答案,能解析的通的,感覺只有:是爲了萬物皆(純)函數吧。

因爲按照我的理解,其實是可以保持和類組件一樣的狀態管理。state 還是一個對象,都通過調用一個方法來進行更新。這樣和類組件反倒保持了統一,更好理解。

結語

通過解讀源碼的形式去理解 useState 執行過程,能夠加深對 react 函數組件狀態更新的理解。不足或者有錯的地方,歡迎指出。

上文的解析,都是建立在 react@16,reac-dom@16 的基礎上。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nYX8Lnj7uwGHAeL5rEnRRg