前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【useState原理】源码调试吃透REACT-HOOKS(一)

【useState原理】源码调试吃透REACT-HOOKS(一)

作者头像
源心锁
发布2022-08-12 11:48:02
4310
发布2022-08-12 11:48:02
举报
文章被收录于专栏:前端魔法指南前端魔法指南
【useState原理】源码调试吃透REACT-HOOKS(一)
【useState原理】源码调试吃透REACT-HOOKS(一)

【useState原理】源码调试吃透REACT-HOOKS(一)

1 导读

2022年了,用React开发不使用hook是不行的。同时一方面,由于我在日常开发中已经许久没有使用class组件,所以一直对于hook的设计理念、实现原理和相关源码有一定的兴趣。原因无他,用hook真的太爽了。

开始之前,先抛出几个问题:

  1. react-hook解决了什么问题?
  2. react中的函数是无状态的,hook是怎么做到赋予其状态的?
  3. 典型问题:为什么hook必须在顶层调用?-->引申:在函数组件中多个hook是怎么记录的
  4. useMemouseCallback是怎么做缓存的?
  5. hook的调用过程,从挂载、首次渲染、二次渲染到销毁的流程?
  6. ······

从这里开始,我们一一解答。

2 HOOK的相关概念

协调器目录 github.com/facebook/re…

2.1 为什么要在react中引入hooks?

不知道诸位有没有使用class组件的经历,属实是又臭又长,繁多且命名复杂的生命周期给开发者带来的体验并不好。在这之前的function组件由于没有状态的概念,只能用来承载简单的UI,这显然不行,react的数据驱动意味着状态逻辑实际上是无处不在的。

依据官方文档的解释,引入hook解决了三个以及更多的问题

  • 在组件之间复用状态逻辑很难
  • 复杂组件变得难以理解
  • 难以理解的class

实际体现上,我也无比认同引入hook的实际效果

  • hook的引入使我们在无需修改组件结构的情况下即可复用状态逻辑,不管是在跨层级状态共享还是复杂逻辑抽象上都有了质的提高
  • 我们在使用函数式组件时不再关注生命周期,只要保证hook在最顶层即可在函数中将和组件相关联的部分自由地拆分
  • hook 使你在非 class 的情况下可以使用更多的 React 特性

2.2 Fiber结构

我有一篇文章讲的是Fiber结构的实现https://juejin.cn/post/7030069221342052389,这里只给一下代码:

代码语言:javascript
复制
function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // 实例变量,从字面意思也应该可以看出这里保存了tag、key、type、state类似这样的有很强实际意义的属性
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;

  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;
	...
}

除了Fiber结构,还需要强调的一点是,react16.8之后的Fiber架构:

  • Scheduler(调度器),还没看到请忽略,请记住这个概念
  • Reconciler(协调器)
  • Renderer(渲染器)

3 hook是怎么赋予函数式组件状态的?

开始之前,贴一下我整个hook篇的调试代码

代码语言:javascript
复制
import {useState, useEffect, useRef} from 'react';

const useMockRef = init => {
  const [ref] = useState({current: init});
  return ref;
};

const CountButton = () => {
  const [count, setCount] = useState(0);
  const [tick, setTick] = useState(0);

  const realRef = useRef(0);
  const mockRef = useMockRef(0);

  const handleClick = () => {
    setCount(count + 1);
    mockRef.current += 1;
    realRef.current += 1;
  };

  const handleRefCountClick = () => {
    mockRef.current += 10;
    realRef.current += 10;
    console.log('mockRef.current', mockRef.current);
  };

  useEffect(() => {
    setTick(count + Math.random());
  }, [count]);
  return (
    <>
      <button onClick={handleClick}>{count}:Render by state</button>
      <button onClick={handleRefCountClick}>Click to add ref</button>
      <div style={{color: 'red'}}>{tick}</div>
      <div style={{color: 'yellow'}}>{mockRef?.current}</div>
      <div style={{color: 'green'}}>{realRef?.current}</div>
    </>
  );
};

export {CountButton};

image-20220713215802847
image-20220713215802847

进入到这里,我们需要进入到react源码的部分,这里我们需要关注的是FiberBeginWork

github.com/facebook/re…

调试代码

代码语言:javascript
复制
const CountButton = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };
  return (
    <>
      <button onClick={handleClick}>{count}:Render by state</button>
    </>
  );
};

3.1 beginWork

在之前的文章中,我们其实已经对Fiber有一定的了解,也知道了在react中一个Fiber其实也就是对应一个虚拟DOM。那么我们现在看到function beginWork #L3829

代码语言:javascript
复制
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
    ...
}

可以发现beginWork的入参为「current、workInProgress、renderLanes」,前两者对应react架构中的两颗Fiber树renderLanes则是和优先级相关的参数,和Scheduler相关,这一部分我还没仔细研究🧐暂时MARK。

更详细点说,在这里函数的入参中:

  • current对应当前组件对应的Fiber节点上一次更新时的节点
  • workInProgress对应当前组件的对应的Fiber节点

当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树 之所以要有两根Fiber树是因为react使用了一种“双缓存机制”,这种机制的意义是可以把当前页面下一帧放到内存中绘制,在绘制完毕后直接用当前帧替换上一帧,省去两帧替换的计算时间(diff瓶颈/),减少白屏闪现的情况。这也是Fiber架构的重要工作原理

我们目前研究的是函数式组件,那么在#L3942我们可以看到,react基于当前Fiber节点的tag(即FunctionComponent) 进行updateFunctionComponent的调用:

代码语言:javascript
复制
 switch (workInProgress.tag) {
   case xx:
     ...
   case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
 }

3.2 updateFunctionComponent

github.com/facebook/re…

我们转到updateFunctionComponent,忽略掉一些代码,可以发现返回值workInProgress.child其实就是nextChildren

代码语言:javascript
复制
function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderLanes,
) {
  ...
  if (__DEV__) {
    ... 
  } else {
    nextChildren = renderWithHooks(
      current,
      workInProgress,
      Component,
      nextProps,
      context,
      renderLanes,
    );
    hasId = checkDidRenderIdHook();
  }
  ...
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}
代码语言:javascript
复制
//https://github.com/facebook/react/blob/e225fa43ada4f4cf3d3ba4982cdd81bb093eaa46/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L299

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes,
) {
  if (current === null) {
    // 如果这是一个尚未渲染的新组件,我们不会通过应用最小的副作用来更新其子集。
    // 相反我们将在渲染子对象之前将它们全部添加到子对象。
    // 这意味着我们可以通过不跟踪副作用来优化这个调节过程   
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes,
    );
  } else {
    // 如果当前子项与正在进行的工作相同,则表示我们还没有开始对这些子项进行任何研究。
    // 因此,我们使用克隆算法,用于创建所有当前子项的副本。
    // 如果我们已经有任何进展的工作,在这一点上是无效的,所以我们把它抛出。
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes,
    );
  }
}

3.3 renderWithHooks‼️

github.com/facebook/re…

显然,renderWithHooks这个函数的作用非常关键,这里隐藏了hook工作原理的关键,

image.png
image.png

往下阅读会发现renderWithHooks做的第一件事就是把当前Fiber节点的「memoizedState、updateQueue、lanes」置空了。

那么这里涉及两个概念:

  • memoizedState:
  • updateQueue
代码语言:javascript
复制
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  workInProgress.lanes = NoLanes;

为什么要这么做呢?看注释是

代码语言:javascript
复制
  // The following should have already been reset
  // currentHook = null;
  // workInProgressHook = null;

  // didScheduleRenderPhaseUpdate = false;
  // localIdCounter = 0;

  // 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.

翻译过来就是,我的理解是这里的作用是清除当前Fiber节点的遗留状态。

代码语言:javascript
复制
	// 以下内容应已重置
	// currentHook = null;
	// workInProgressHook = null;
	
	// didScheduleRenderPhaseUpdate = false;
	// localIdCounter = 0;

  // TODO 警告如果在挂载过程中根本没有使用钩子,那么在更新过程中就会使用一些钩子。
  // 目前,我们将更新呈现标识为挂载,因为 memoizedState === null.
  // 这很棘手,因为它对某些类型的组件是有效的 (e.g. React.lazy)

  // 只有在至少使用一个有状态钩子的情况下,才使用memoizedState去区分挂载/更新
  // 非状态钩子(例如上下文)不会被添加到 memizedState,
  // 因此,在更新和挂载期间,memizedState 将为 null。

经历了上边的步骤,终于来到创建新的节点。

image-20220713151334595
image-20220713151334595

这里的Component实际上就是我们的组件函数

image-20220713152554867
image-20220713152554867

而这里实际上的运行流程大抵如下:

  • createSignatureFunctionForTransform --- 这里实际上是react-refresh的热更新这块的东西,可以暂时不看
  • useState-->resolveDispatcher
3.3.1 组件挂载

HooksDispatcherOnMountInDEV["useState"]

image-20220713180939170
image-20220713180939170

​ 解析一下,这里主要是两部分:

  • mountHookypesDev
  • mountState

​ 一步步来:

a. mountHookypesDev
image.png
image.png

其实mountHookypesDev并不是一个很难理解的部分,但是为什么要拿出来说呢?

可以看到,该函数的作用很简单,获得当前hook的名字,塞入hookTypesDev或者创建hookTypesDev

image.png
image.png

那么记不记得上边抛出的一个问题:为什么hook必须在顶层调用?

其实这里就给出了答案,或者说这就是原因之一,我们可以看到图中另一个函数updateHookTypesDev

image.png
image.png

可以看到哈,mountHookTypesDev往hookTypesDev中填入所有hookName之后,后续的update会按照索引递增的方式来获取函数名,此时如hook调用顺序变化,获得的hookName就会存在问题,react也会在此抛出警告。

b. mountState

到达这里,我们可以看到mountState这个关键函数

image-20220713181646483
image-20220713181646483

mountState中又有另一个关键函数mountWorkInProgressHook

代码语言:javascript
复制
function mountWorkInProgressHook() {
  var hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }

  return workInProgressHook;
}

看到这里可以知道,关键全局变量workInProgressHook的作用是记录每次生成的hook对象,用来指向组件中正在调用哪个hook。每一次调用hook函数都会把workInProgressHook指向hook函数产生的hook对象。

TIPS: currentlyRenderingFiber即为workInProgressFiber

image.png
image.png

那么首次挂载即会有这么条链路: workInProgressFiber->memoizedState = workInProgressHook = hook 之后再次挂载则会不断进行: workInProgressHook = workInProgressHook.next = hook; 这也是我们熟知的hook存储成单向链表保存的由来

image.png
image.png

而再往下走

image.png
image.png

其实已经很好理解了,react在hook上记录下baseState,memoizedState并初始化quene,然后返回我们熟悉的[state,setState]


注意:关于quene的含义以及和dispatchSetState的内容我们放在组件更新的时候讲

image.png
image.png

3.3.2 组件更新
a. dispatchSetState ‼️

组件更新的第一步,即调用上边的dispatchSetState

image.png
image.png

会发现,我们使用的setState操作的本质是dispatchSetState.bind(),那么我们继续下钻

代码语言:javascript
复制
function dispatchSetState(fiber, queue, action) {
  ...

  var lane = requestUpdateLane(fiber);
  var update = {
    lane: lane,
    action: action,
    hasEagerState: false,
    eagerState: null,
    next: null
  };
	...
}

会发现在dispach的时候会出现一个新的类型update,我们翻到编译前的代码可以对这个结构更清晰。显然到这里你知道了update又是一个链表

image.png
image.png

update并不是一个单纯的单向链表,为了体现这个规则,我在调试代码中加入了新的两次setState我们来看看

image-20220715212359681
image-20220715212359681

我们看一下其中一个setCountupdate,加深一下印象,继续前进.

image-20220715212549260
image-20220715212549260

代码语言:javascript
复制
function dispatchSetState(fiber, queue, action) {
  if (isRenderPhaseUpdate(fiber)) {
    ...
  } else {
    ...
    var root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);

    if (root !== null) {
      var eventTime = requestEventTime();
      scheduleUpdateOnFiber(root, fiber, lane, eventTime);
      entangleTransitionUpdate(root, queue, lane);
    }
  }
}

往下我们来到enqueueConcurrentHookUpdate,这里即涉及了我们一直搁置的queue与上边的update特殊结构,前两步忽略,我们需要继续下钻enqueueUpdate

image-20220715215613195
image-20220715215613195

但是我们首先需要先知道这个函数的前两个参数此时都指代什么,我们此时需要回头来到mountState,会发现Fiber指的就是currentluRenderingFiber$1。同理其实queue在mount挂载在hook之后,被通过bind的方式送入了dispatch。

image-20220715220615718
image-20220715220615718

不过有一点值得注意的是,通过.bind产生的闭包函数有个特点,Fiber指向的仍然是当时传入的fiber,也就是说此时的fiber是我们曾经传入的workInProgressFiber

enqueueUpdate做了什么呢?这是一个关键步骤,看图我们会发现,enqueueUpdate用index持续自增的方式向concurrentQueues中分别添加「fiber、queue、update、lane」

image-20220715234527741
image-20220715234527741

所以concurrentQueues经历我们多次setCount之后呈现图中的数据

image-20220715234804685
image-20220715234804685
image-20220716002148670
image-20220716002148670

接下来其实比较麻烦,有一些调度上的代码,为了易于理解,我们找到使得concurrentQueuesIndex变化的代码处继续调试

image-20220716004117368
image-20220716004117368

我们看看这个函数做了什么:

image-20220716004634395
image-20220716004634395
image-20220716004853038
image-20220716004853038

🤔诶,这不得懂了,这里创建了一个环形链表,也就是说所有的更新,即update,会组合成一个单向环形链表挂载在queue.pending上。

之所以单向环形是因为react的更新是有优先级的,update的执行顺序并不是固定的,通过单向链表更新可能会导致第一个update丢失。而环形链表一个显然的优势就是可以从任何节点开始循环链表,由此保证了状态依赖的连续性。

OK,那么dispatchSetState的内容我们可以咔了,往下继续前进。

插一句,为什么我们setState的时候重复值不会引起重渲染,就是因为这个函数。 看到这个TODO了么😭麻烦亲把这个限制去了吧

image.png
image.png

而紧接着就是我们比较熟悉的组件更新,组件更新部分细节上会比挂载更多,但是实际难度也不大

image.png
image.png

可以看到,这里有两个关键函数「updateHookTypeDev」和「updateState」

b. updateHookTypeDev

其实前文已经提过这里了,所以我们跳过一下🐶

image.png
image.png
c. updateState

首先updateState的操作其实依赖于updateReducer

image.png
image.png
image.png
image.png

而最终会走到一个我们熟悉又陌生的关键函数updateWorkInProgressHook

  • updateWorkInProgressHook
image.png
image.png

我们先来看第一部分

代码语言:javascript
复制
function updateWorkInProgressHook() {
  ...
  var nextCurrentHook;

  if (currentHook === null) {
    var current = currentlyRenderingFiber$1.alternate;

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

这里涉及一个地方currentlyRenderingFiber$1.alternate,「alternate」翻译过来即为「候补」。

那它是什么呢?我们上边曾经讲过react的双缓存树架构,这里的alternate实际上就指向当前workInProgress节点对应的渲染在屏幕上的current节点。

那么纵览这整个第一步,其实就是获取当前hook


第二部分较为简单,作用是获取当前待更新的hook

代码语言:javascript
复制
function updateWorkInProgressHook() {
  ...
  var nextWorkInProgressHook;

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


而最后一部分,知道了上述一二部分变量含义之后同样很容易理解,无非react创建了一个新的hook然后用同样的方式做一个单向链表的更新。

image.png
image.png
  • updateReducer剩下的部分
image.png
image.png

剩下的部分涉及到了不少我们提过但是实际上并没有解释的变量:

  • currentHook,这个变量实际上和workPorgressHook对应,指的是current Fiber树上对应的当前hook
image.png
image.png
  • baseQueue,该变量取自currentHook,含义是本次更新之前剩余的待更新队列
  • queue更新队列,本次更新增加的待更新队列,pending中存放着环形单项链表式的update

如果你在上边已经知道了queue->pending,那么实际上对于这里进行hook的更新并不会难以理解。

这里分成几个简单的步骤:

  • 合并更新队列
image-20220716012329194
image-20220716012329194
  • 在update未清空之前do...while更新最新的状态
image-20220716012610048
image-20220716012610048
image-20220716012634038
image-20220716012634038
  • 标记完成更新
image-20220716012727415
image-20220716012727415
  • 更新hook的最新状态并返回
image-20220716012756742
image-20220716012756742

3.4 reconcileChildren*

那么此时已经开始逐级返回,我们来到updateFunctionComponent调用的最后一个方法reconcileChildren

而这里我们不深入看内部逻辑其实也是很容易理解这里是为当前Fiber节点更新child的过程,由于具体的协调器实际上就是我们常说的React diff这一部分,所以暂时跳过一下,mark以后说。

代码语言:javascript
复制
function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
  if (current === null) {
		//如果这是一个尚未渲染的新组件,我们
		//不会通过应用最小的副作用来更新其子集。相反
		//我们将在渲染子对象之前将它们全部添加到子对象。这意味着
		//我们可以通过不跟踪副作用来优化这个调节过程。
    workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
  } else {
		//如果当前子项与正在进行的工作相同,则表示
		//我们还没有开始对这些孩子进行任何研究。因此,我们使用
		//克隆算法,用于创建所有当前子项的副本。
		//如果我们已经有任何进展的工作,在这一点上是无效的,所以
		//我们把它扔掉吧。
    workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren, renderLanes);
  }
}

3.5 completeWork*

根据我们对于react的渲染流程了解,render阶段分为beginWorkcompleteWork

image.png
image.png

而这里紧接着触发的两个函数popTreeContextbubbleProperties,看上去都属于在完成react的render递归返回的工作。

由于具体工作原理中实质上和我们想说的useState已经没有多少关系,我们暂时忽略即可。这里在下一章动手

4 总结

React源码的阅读之路必定是漫长的,定一些TODO:

  • useEffect的工作原理
  • react的render阶段工作原理
  • react的commit阶段工作原理

那个人再不曾出现,他只跟我说继续前进

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-07-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 【useState原理】源码调试吃透REACT-HOOKS(一)
    • 1 导读
      • 2 HOOK的相关概念
        • 2.1 为什么要在react中引入hooks?
        • 2.2 Fiber结构
      • 3 hook是怎么赋予函数式组件状态的?
        • 3.1 beginWork
        • 3.2 updateFunctionComponent
        • 3.3 renderWithHooks‼️
        • 3.4 reconcileChildren*
        • 3.5 completeWork*
      • 4 总结
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档