你真的懂useState吗?useState源码解析

1,919 阅读10分钟

前言

React版本:16.8以上

如果你是一个react忠实用户,看到这个标题肯定会嗤之以鼻,useState还能不懂?这每天都要写好几遍的东西,早已如呼吸般自然~

别急,接下来将设置几道关卡,看一看你的useState是否如你想那样。

第一关:

const [arr, setArr] = useState([]);
const test = () => {
  arr.push({});
};
console.log(arr);
return <div onClick={test}>App</div>;

触发test后的打印是什么?⬆️

const [arr, setArr] = useState([]);
const test = () => {
  arr.push({});
  setArr(arr);
};
console.log(arr);
return <div onClick={test}>App</div>;

触发test后的打印是什么?⬆️

const [arr, setArr] = useState([]);
const test = () => {
  setArr([]);
};
console.log(arr);
return <div onClick={test}>App</div>;

触发test后的打印是什么?⬆️

答案揭晓:

  1. 未打印(没有触发render
  2. 未打印(arr内存地址没有改变,所以没有监听到变化,setArr没有触发render
  3. 打印[]( [] !== [],触发render

如果答对恭喜你已经通过了第一关,让我们继续~

第二关:

const [count, setCount] = useState(0);
const test = () => {
  setTimeout(() => {
    setCount(count + 1);
  }, 3000);
  console.log(count);
};
return <div onClick={test}>App</div>;

在3s内快速点击触发test,然后在3s结束后再次点击,将会打印什么?⬆️

const [count, setCount] = useState(0);
const test = () => {
  setTimeout(() => {
    setCount(count => count + 1);
  }, 3000);
  console.log(count);
};
return <div onClick={test}>App</div>;

在3s内快速点击触发test,然后在3s结束后再次点击,将会打印什么?⬆️

答案揭晓:

  1. 0->0->0->...->1(闭包旧值,每次setCount的count为0+1
  2. 0->1->2->...->8(新值传入,所以点击8次后为=>0 + 1 + 1... + 1 = 8

什么,这还难不倒你?看来有点东西,现在让我们进入最后一关!

第三关:

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(1);
  setCount(2);
  setCount(3);
}, []);
console.log(count);

打印什么呢?⬆️

const [count, setCount] = useState(0);
useEffect(() => {
  setTimeout(() => {
    setCount(1);
    setCount(2);
    setCount(3);
  });
}, []);
console.log(count);

打印什么呢?⬆️

答案揭晓:

  1. 0->3
  2. 0->1->2->3(react18版本后为0->3)

以上关卡答案有没有出乎大家的意料呢?如果不了解的话也别急,接下来让我们一起扒开useState的外衣,看看他里面究竟藏着什么玄机~

useState

本章节让我们从基础用法开始,一步步剖析理解useState内部的构造~

useState基础用法

const [state, dispatch] = useState(initData)
  • state:定义的数据源,可视作一个函数组件内部的变量,但只在首次渲染被创造。
  • dispatch:改变state的函数,推动函数渲染的渲染函数。dispatch有两种情况-非函数和函数
  • initData:state的初始值,initData有两种情况-非函数和函数。

dispatch的两种情况

  1. 非函数:
const [count, setCount] = useState(0);
// 一个点击事件方法
const test = () => {
  setCount(2) // => count = 2
  setCount(count + 1) // => count = 0 + 1 = 1
  setCount(count + 1) // => count = 0 + 1 = 1
};
  1. 函数:
const [count, setCount] = useState(0);
// 一个点击事件方法
const test = () => {
  setCount((newCount) => newCount + 1) // => count = 0 + 1 = 1
  setCount((newCount) => newCount + 1) // => count = 1 + 1 = 2
  setCount((newCount) => newCount + 1) // => count = 2 + 1 = 3
};

大家可以看出,非函数的情况下,setCount将参数作为新的值赋予state,下一次渲染时使用。函数的情况下,传入函数的入参(本例中的newCount)-是上一次返回的最新state,而函数的返回值,作为新的值赋予state,下一次渲染使用。

initData的两种情况

  1. 非函数:
const [count, setCount] = useState(0);
  1. 函数:
const { id } = props;
const [count, setCount] = useState(() => {
  if(id === 1) return 10;
  if(id === 2) return 15;
});

等同于

const { id } = props;
let initCount;
if (id === 1) {
  initCount = 10;
} else if(id === 2) {
  initCount = 15;
}
const [count, setCount] = useState(initCount);

大家可以看出,useState初始化的值可以写死,也可以由外部变量进行控制。

源码分析

我们都知道,react在不同阶段引用的hooks不是同一个函数,useState也不例外。首先我们先看一下react中对于useState在不同阶段的处理函数。

// ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  //...省略无关代码
  // 我们通过判断当前fiber中的memoizedState是否为空来判断当前的阶段
  ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  //...省略无关代码
}

image.png

image.png 我们从一个简单的例子来分析下useState的原理⬇️

function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  const addCount = () => {
    setCount1(count1 + 1);
  }

  return (
    <div onClick={addCount}>
      {count1} - {count2}
    </div>
  )
}

从组件初始化到点击按钮总共有两步:

  1. 初始化-首次渲染,调用mountState方法,将count1与count2初始化为0,并在页面渲染出来。
  2. 组件更新-点击按钮后,进入更新阶段,调用updateState方法,整个App组件函数重新执行一次,并且把count1更新为1,count2不变,在页面上渲染出来。

组件初始化阶段(首次render)

React中存在着一个叫Fiber的对象来保存着它各种节点的信息,也就是React中的虚拟DOM,这里不了解也不影响接下来的阅读,感兴趣的同学可以网上进行查阅更详细的信息。

在了解state初始化之前,我们首先得对state的结构以及state如何被记录的有一些简单的认知。

首先我们看一段hook节点初始化创建的代码:

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

  if (workInProgressHook === null) {
    // 这是初始化第一个hook节点时
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 不是第一个节点直接放到节点后面
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

我们首先来了解下各个变量的含义:

  • memoizedState:Fiber上有一个记录state的对象叫做memoizedState,有了memoizedState,我们就能在每次渲染时获取state里的数据。memoizedState是一个单项链表的结构。我们每一次useState就是在链表后面生成一个新hook节点用next作为指针连接起来,初始化的memoizedState则作为头节点。

image.png

  • currentlyRenderingFiber:当前组件渲染对应的fiber对象。
  • workInProgressHook:当前运行到的hook,如上图所示,组件内部可能会存在多个hook。
  • hook:每次useState便会产生一个hook对象来存储相关的状态。

进入正题,我们看一下初始化阶段useState调用mountState的代码:

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  // 创建新的hook节点,就是上文提到的函数
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    // state初始值赋值
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  ...
}

举一个栗子⬇️

function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(1);
  ...
}
  1. 首次渲染App组件时,还没有执行useState,currentlyRenderingFiber.memoizedState会记录当前组件的状态,此时为null,workInProgressHook也为空。
  2. 执行第一个useState命令,进入mountWorkInProgressHook函数,创建一个初始值为0的hook节点,并将currentlyRenderingFiber.memoizedState指向它。
  3. 初始值被记录在currentlyRenderingFiber上,此时memoizedStatenull变为0
  4. 执行第二个useState命令,生成一个初始值为1的hook节点,然后将上一个hook节点指向它,重复以上步骤,形成图一中的链表结构。

你可能会问了,hook链表结构被创建出来了,那我们怎么去更新他呢?下面我们来看一看mountState函数的下半段:

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  // 上文设置初始结构部分省略
  ...
  // 初始化queue
  const queue: UpdateQueue<S, A> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null, // 更新state的函数
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  // 初始化触发器
  const dispatch: Dispatch<A> = (queue.dispatch = (dispatchReducerAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  // 返回初始state和触发器
  return [hook.memoizedState, dispatch];
}

// 真如刚刚看到的,state初始值可以为常量,也可以为一个函数
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}

这里我们只关心dispatch函数(useState的第二个参数),可以看出,每个hook节点上dispatch都由dispatchReducerAction进行功能赋值。那dispatchReducerAction是个什么东西呢?

function dispatchReducerAction<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
  // 更新state的方式
  ...
}

在这里,我们了解到每个节点对应的dispatch都传入了对应的fiberqueue,那我们不妨想一想,为什么要绑定这两个值呢?

其实很简单,这里只起到一个标识的作用,fiber对应某一个fiber树,queue对应fiber树上某一个hook节点。这样的话,我们才能更快更准确地进行更新任务。

在这里你可能会问,第三个参数action是干啥用的呢?没错,它就是更新的方法,下面让我们进入组件更新部分~

组件更新

第二步是组件更新,也就是调用了setCount方法。也就是上文提到的dispatchReducerAction函数的第三个参数。

刚刚介绍完组件的初始化后,细心的同学可能会提出疑问:那queue有什么用呢?有那么复杂的数据结构,并且更新方法函数也需要传入,难道就只起到一个标识符的作用吗?如果这样的哈,我用个boolean值不也能做到吗?

当然不是,queue在整个流程中起到了至关重要的作用,整个useState的驱动都是围绕着queue来实现的,接下来让我们一起看看它到底是个什么玩意!

首先,更新时的流程和初始化渲染时的流程差不多,只不过初始化渲染时候只初始化了每个hook节点上的queue,而更新则是往queue里面加任务。

下面让我们来看一下queue的结构⬇️

const queue: UpdateQueue<S, A> = {
  pending: null,
  lanes: NoLanes,
  dispatch: null,
  lastRenderedReducer: reducer,
  lastRenderedState: (initialState: any),
};

这几个字段是什么意思呢?它们在更新中起到了什么作用呢?下面我们用一个例子来讲解⬇️

function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(1);
  
  const addCount = () => {
    setCount1(count1 + 1);
  }

  return (
    <div onClick={addCount}>
      {count1} - {count2}
    </div>
  )
}

当我首次点击,触发addCount函数时,我们会根据入参生成一个更新节点⬇️

const update: Update<S, A> = {
  lane,
  action, // setCount1的入参,也就是上文中的count1 + 1
  hasEagerState: false,
  eagerState: null,
  next: (null: any),
};

更新节点生成后,我们将它与queue队列进行处理⬇️

const pending = queue.pending;
if (pending === null) {
  // 这是首次更新,创建循环链表
  // 只有一个update,自己指向自己,形成环形链表
  update.next = update;
} else {
  // 链表插入
  update.next = pending.next;
  pending.next = update;
}
queue.pending = update;

从代码中可以看到queue.pending存储产生的更新,有下列特征:

  1. 如果只有一个节点的时候,就自己指向自己
  2. 如果有多个节点,就把queue.pending插进去

从以上特征,我们不难猜到queue是一个单项循环链表结构⬇️

image.png 看到这里大家应该已经明白了,我们一系列的更新信息都是存储在queue里的,在更新阶段中会进行调用处理。

了解完结构后我们来看看更新阶段调用的hook函数:

image.png

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}

// 为方便阅读,省略部分无关代码
function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  // 初始化hook
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;

  const current: Hook = (currentHook: any);

  let baseQueue = current.baseQueue;
  const first = baseQueue.next;

  let newState = current.baseState;
  let update = first;

  if (baseQueue !== null) {
    do {
      newState = reducer(newState, action);
      // update为一个环形链表,循环链表直到取最新值
      update = update.next;
    } while (update !== null && update !== first);
    
    newBaseQueueLast.next = (newBaseQueueFirst: any);
    // 判断新数据与老数据是否相同,如果相同则标记完成,不进行render
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }
    
    // 将state数据更新成新数据
    hook.memoizedState = newState;
  }

  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

可以看到内部将queue上存储的一系列更新任务进行了处理,最终将对应的hook.memoizedState都更新成了最新数据。

总结

总而言之,我们hooks的更新行为都挂到了hook.queue下面,所以整个流程大致分为以下三步,我们用一个例子来说明:

function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(1);
  
  const addCount = () => {
    setCount1(count1 + 1);
    setCount1(3);
    setCount2(2);
  }

  return (
    <div onClick={addCount}>
      {count1} - {count2}
    </div>
  )
}
  1. 创建hook,初始化queue - mountState
  • 首次执行-const [count1, setCount1] = useState(0),创建一个count1-hook,将hook.memoizedState头节点变为count1,初始化queue维护count1的更新信息。
  • 执行到-const [count2, setCount2] = useState(1),将hook.memoizedState指向count2-hook,初始化queue维护count2的更新信息。
  1. 维护queue - dispatchReducerAction(处理更新信息)
  • 调用setCount1(count1 + 1),dispatch一个内容为count + 1(0 + 1)的action,生成update1.1节点(update为一个环形链表结构,忘记的同学回到上文看看哈)
  • 执行setCount1(3),dispatch一个内容为3的action,生成update1.2节点,将update1.1节点指向update1.2节点,将update1.1存储在count1-hook.queue中。
  • 调用setCount2(2),dispatch一个内容为2的action,生成update2.1节点,将update2.1存储在count2-hook.queue中。
  1. 更新queue - updateState(也就是updateReducer)
  • 在updateReducer中进行queue任务的调用处理,分别更新到hook.memoizedState上,让我们获取到最新的state值

上文只是简单介绍了useState的内部执行原理,里面的奥秘远不止那么简单,其中的各种边界条件和调度器都需要我们去探索,如有兴趣,欢迎关注下期~