从源码角度解析:react hook为啥不能放入条件语句中

在我们刚开始学习react时,react官方文档就提示我们react hook必须在函数组件顶层使用,不能在条件语句或者循环结构中使用。

之所以会有这样的规定,这和react hook的实现原理脱离不了关系。

本文就从源码的角度,帮助大家快速理解其中的原理。

虽然是以源码角度来解析,但本文并不会展示过多的源码,旨在于尽量简洁的说明其中的道理。

1,hooks的加载

首先准备一个函数组件案例:

js 复制代码
export default function MyFun(props) {
  // hook加载
  const [count, setCount] = useState(1)
  useEffect(() => {
    console.log('useEffect DOM渲染之后执行')
  }, [])
  function handleClick() {
    setCount(2)
  }
  return (
    <div className='MyFun'>
      <div>MyFun组件</div>
      <div>state: {count}</div>
      <button onClick={handleClick}>更新</button>
    </div>
  )
}

我们都知道在react应用中,函数组件加载的一个重点就是:会调用一次我们定义的函数,也就是案例中的MyFun

而在函数调用的过程中,就会遇到我们定义的hooks,react内部就会开始执行hooks的加载处理。

当然首先我们得知道,react组件的加载处于Fiber Reconciler协调流程之中,这个流程的主要作用就是创建FiberTree【虚拟DOM树】,Fiber即react中的虚拟DOM,每一个函数组件都会创建与之对应的Fiber节点,对于Fiber节点你只需要理解为一个JS对象,它有很多属性存储着与组件相关的信息,而与hook相关的部分内容就存储在fiber.memoizedState属性之中。

每一个hook在首次加载时,都会执行一个mountWorkInProgressHook方法:

比如useState加载时:

js 复制代码
function mountState(initialState) {
    const hook = mountWorkInProgressHook();
    ...
}

这个方法内部会为我们在组件中定义的hook创建一个对应的hook对象:

js 复制代码
// hook对象
const hook: Hook = {
  memoizedState: null,
  baseState: null,
  baseQueue: null,
  queue: null,
  next: null, // 指向下一个hook对象
};

这个hook对象的属性存储着我们组件中对应hook的相关信息,但是在这里我们只需要关心它的next属性即可:

在加载第一个hookuseState时,就会将第一个hook对象直接赋值给当前函数组件对应的Fiber节点的memoizedState属性。

js 复制代码
fiber.memoizedState = hook;

所以此时函数组件Fiber节点的memoizedState属性指向为:

加载第二个hookuseEffect时,就会将上一个hooknext属性指向当前新建的hook对象。

所以此时函数组件Fiber节点的memoizedState属性指向为:

ini 复制代码
useState => hook1
useEffect => hook2

在函数组件在加载完成后,fiber.memoizedState属性形成了一个这样的单向链表。

2,hooks的更新

来到函数组件的更新过程:函数组件的更新同样会调用一次MyFun,在这个过程中react内部就会开始执行hooks的更新处理。

同理每一个hook在更新时,都会执行一个updateWorkInProgressHook方法:

js 复制代码
function updateState(initialState) {
    const hook = updateWorkInProgressHook();
    ...
}

重点来了: updateWorkInProgressHook方法内部会引用current.memoizedState属性的内容。

函数组件更新时,current代表旧的节点内容,其实就是新旧虚拟DOM概念,current变成了旧的虚拟DOM。

所以hook更新时会引用函数组件加载阶段 就已形成的hook链表,会按照这个链表顺序来取出对应的原hook对象,利用原hook信息生成新的hook对象参与计算或者更新。

这里取出顺序为:首先从current.memoizedState取出第一个hook对象,后面就是从hook对象的next属性依次取出下一个更新的hook对象,hooks的更新就可以按照顺序复用上一次的相关信息。

hooks的更新会按照函数组件加载阶段就已经固定的hook链表顺序,这就是hook必须置于函数组件顶层使用的根本原因

如果我们将hook置于条件或者循环之中,在更新阶段就会出现无法与原来的hook链表相匹配的问题,将会导致渲染出现异常。

比如我们在条件语句之中新增一个hook

js 复制代码
export default function MyFun(props) {
  const [count, setCount] = useState(1)
  // 新增一个hook
  if (count === 2) {
    const [status, setStatus] = useState(false)
  }
  useEffect(() => {
    console.log('useEffect DOM渲染之后执行')
  }, [])
  function handleClick() {
    setCount(2)
  }
  return (
    <div className='MyFun'>
      <div>MyFun组件</div>
      <div>state: {count}</div>
      <button onClick={handleClick}>更新</button>
    </div>
  )
}

此时我们设置count === 2时才会执行新增的hookcount默认为1,所以在函数组件加载阶段时不会触发新增的hook。即函数加载阶段形成的hook链表中只有两个hook对象,但是当我们一点击更新按钮,变量条件得到满足,组件更新时就会出现三个hook,导致新增的useState复用了原来的第二个useEffecthook对象信息,这必然会导致渲染出错。即使新增的hook 为useEffect,也同样会导致渲染出错,因为在第三个hook更新时,找不到hook链表对应的hook对象,此时react同样会抛出错误:

js 复制代码
if (nextCurrentHook === null) {
  throw new Error('Rendered more hooks than during the previous render.');
}

如果我们设置count === 1就执行新增的hook,则函数组件在加载阶段就会向我们发出一个error提示:

sql 复制代码
React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render

循环结构也一样的道理,循环的次数变了或者循环结构中有条件语句,都是相同的原理。

结束语

本文从源码的角度,以尽量简洁的语句向大家解释了react hook必须于函数组件顶层使用的原因,想了解FiberTree创建过程或者函数组件具体的加载过程可以阅读本系列的其他文章。

相关推荐
web Rookie26 分钟前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust34 分钟前
css:基础
前端·css
帅帅哥的兜兜34 分钟前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3
工业甲酰苯胺37 分钟前
C# 单例模式的多种实现
javascript·单例模式·c#
yi碗汤园37 分钟前
【一文了解】C#基础-集合
开发语言·前端·unity·c#
就是个名称37 分钟前
购物车-多元素组合动画css
前端·css
编程一生1 小时前
回调数据丢了?
运维·服务器·前端
丶21361 小时前
【鉴权】深入了解 Cookie:Web 开发中的客户端存储小数据
前端·安全·web
Missmiaomiao2 小时前
npm install慢
前端·npm·node.js
程序员爱技术4 小时前
Vue 2 + JavaScript + vue-count-to 集成案例
前端·javascript·vue.js