React Hook 解析

预警!长文、多代码

前言:最近用 React Hook 写了一个项目,笔者为了沉淀出更好的解决方案,研读了很多关于 Hook 的文章,同时笔者也关注了很多社区中关于 Hook 的问题,发现很多问题的产生基本源于对 Hook 理解的不透彻。所以笔者希望通过本文帮助大家更好的理解 Hook、使用 Hook。

一、React Hook 定义

笔者个人理解:React Hook 从具象上来说就为函数组件(纯函数)提供副作用能力的 React API,从抽象意义上来说是确定状态源的解决方案。

接下来笔者将结合源码、实例来为大家解读这个定义。

二、Hook 是如何运行的

React在处理函数组时直接将其作为函数调用。但我们知道的是,Hook 在函数组件「第一次执行」时的表现和「重渲染」时的表现是不一样的。React 为了保证以同样的姿势调用 Hook,开发者却能在「第一次执行」函数组件和无数次的「重渲染」中得到想要的结果,函数组件「第一次执行」和「重渲染」所调用的 Hook 实际上指向的是不同的 Hook。函数组件在「第一次执行」时所使用的 Hook (useXXX) 指向的是对应的 mountXXX,而在更新时,Hook 指向的是对应的 updateXXX。

大体逻辑如图所示:

三、Hook 是如何存储的

状态总要持久化存储才行,不然前端就混不下去了,只能去炒粉了。我们都知道React 处理 class 组件的方式是将 class 实例化,所以 class 组件将方法、状态、钩子作为成员属性直接使用就可以了。

而 React 在处理函数组件时是将其当作函数调用的,没有实例可言,自然也不能将「状态」或是「副作用」挂在原型上。所以只能将 Hook 放在一个和函数组件有关联的地方,即函数组件在渲染过程中对应的 Fiber Node。函数组件在渲染时将 Hook 按照调用顺序以链表的形式挂在 Fiber Node 上面。得到的链表如下所示,其中 Fiber Node 的属性 memoizedState 用来指向Hook 链表的第一个节点,Hook 的属性 memoizedState 用来存放 state 值或 effect回调函数。

挂载 Hook 的伪代码如下所示:

function renderWithHooks(current) {
  //current为当前正在处理的Fiber Node
  //nextCurrentHook先指向第一个Hook
  nextCurrentHook = current !== null ? current.memoizedState : null
  //。。。省略其他代码

  //执行函数组件
  let children = Component(props, refOrContext);

  //将指向链表第一个节点的firstWorkInProgressHook
  //赋值给currentlyRenderingFiber.memoizedState
  const renderedWork: Fiber = (currentlyRenderingFiber: any);

  //将Hook挂在FiberNode.memoizedState下
  renderedWork.memoizedState = firstWorkInProgressHook; 
  renderedWork.expirationTime = remainingExpirationTime;

  //将Effect Hook挂在FiberNode.updateQueue下
  renderedWork.updateQueue = (componentUpdateQueue: any);
  renderedWork.effectTag |= sideEffectTag;

  //重置Hook指针的指向,使每一个FiberNode都有一个专属于自身的、独立的链表
  currentHook = null;
  nextCurrentHook = null;
  firstWorkInProgressHook = null;
  workInProgressHook = null;
  nextWorkInProgressHook = null;
}
function mountWorkInProgressHook() {
  const hook = {
    memoizedState: null,
    baseState: null,
    queue: null,
    baseUpdate: null,
    next: null,
  };
  if (workInProgressHook === null) {
    //如果当前Hook 链表为空则证明是第一次调用Hook
    //需将当前调用Hook作为链表中的第一个节点
    firstWorkInProgressHook = workInProgressHook = hook;
  } else {
    // 其余Hook均按顺序挂在上一个Hook的next下,形成一个链表结构
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}


function updateWorkInProgressHook() {
  //在每次渲染时,nextCurrentHook最开始都会指向第一个Hook
  currentHook = nextCurrentHook;
  const newHook = {
    memoizedState: currentHook.memoizedState,
        baseState: currentHook.baseState,
    queue: currentHook.queue,
    baseUpdate: currentHook.baseUpdate,
    next: null,
  };

  //将Hook从原链表中取出,放进新的链表中
  if (workInProgressHook === null) {
    workInProgressHook = firstWorkInProgressHook = newHook;
  } else {
    workInProgressHook = workInProgressHook.next = newHook;
  }
  //指向下一个Hook
  nextCurrentHook = currentHook.next;
  return workInProgressHook;
}

关于 Hook 存储结构的问题,最常见的问题就是「Why call order ?」了。

React 在「第一次执行」函数组件时,组件内部每调用一个 Hook,React 就会在组件的 Hook 链表末尾挂载一个 Hook。在「重渲染」,每调用一次 Hook 会先将其从原链表中取出,进行相应「更新」操作后,再将其挂到新的链表上。如果在嵌套的逻辑中(比如 if 或者 switch )调用了 Hook,就会发生错位的问题。

比如下面这个例子中,函数组件「第一次执行」时,「isMount」 为「true」,当前组件的Hook 链表中将会出现 a、b、c 三个 Hook。「第二次执行」时,组件按照调用顺序读取 Hook 链表,读取 a 的值还是 1,往下执行时 b 没有被调用,继续往下执行调用了 c,但是指针还是指向了 Hook b,那么就会将 b 的值赋给 c , c 的值为2。

import React, { useState } from 'react'

function Test() {
  const [a, setA] = useState(1)
  if(isMount) {
    const [b, setB] = useState(2)
  }
  const [c, setC] = useState(3)
  return (
    <div>
        A:{a}
        B:{b}
        C:{c}
    </div>
  )
}

执行结果如下图所示:

至于同样是线性存储结构,为什么用链表而不用数组这个问题,大家可以自行 Google,这只是一个数据结构的经典面试题,不在本文的讨论范围内。

笔者认为顺序调用的意义就在于:可以保证每次调用 Hook 的时机和顺序都是确定的,不会受到外部状态的影响,开发者也就能更好的把控代码的执行。

四、Hook 是如何实现的?

作为函数而被调用的函数组件,每一次渲染都会生成新的作用域。如果想在函数组件中持久化管理、存储状态,还不想造成全局污染等问题,只有闭包这条路可走了,接下来我们通过 State Hook 来看看 React 是怎么通过闭包实现状态管理的。

首先讲讲最常用的 State Hook,无论是 useState 还是 useReducer 本质上都是 State Hook,返回的都是由当前 state 和 state setter 函数组成的数组;并且在函数组件更新时,useState 内部直接调用了 useReducer,可以将 useState 看作简易版的 useReducer,所以这里我们只看useReducer 的实现就可以了。

在函数组件初始化时,useReducer 做的事情很简单:1、把自己挂到链表上;2、记录「初始值」;3、返回「初始值」和触发更新的 dispatch 函数。

函数组件更新时 useReducer 的逻辑也很简单:1、从 Hook 链表中读取当前指向的 Hook;2、 顺序执行当前 Hook 的更新动作队列;3、记录下更新后的状态;4、返回更新后的状态和 dispatch 函数。

代码如下所示:

function mountReducer(
  reducer,
  initialArg,
  init,
){
  const hook = mountWorkInProgressHook();

    //如果是惰性初始化,执行惰性初始化回调,并记录结果
  let initialState;
  if (init !== undefined) {
    initialState = init(initialArg);
  } else {
    initialState = initialArg
  }
  //记录初始值
  hook.memoizedState = hook.baseState = initialState;
  //生成更新动作队列
  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: reducer,
    lastRenderedState: initialState,
  }); 
  //生成绑定了当前Fiber Node和更新动作队列的state setter函数,
  const dispatch = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  )));
  return [hook.memoizedState, dispatch]
}

function updateReducer(
  reducer,
  initialArg,
  init?: I => S,
) {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;


  const last = queue.last;
  let first = last !== null ? last.next : null
  if (first !== null) {
    let newState = baseState;
    let newBaseState = null;
    let newBaseUpdate = null;
    let prevUpdate = baseUpdate;
    let update = first;

    // 执行更新队列里的更新动作
    do {
      const action = update.action;
      newState = reducer(newState, action);
      prevUpdate = update;
      update = update.next;
    } while (update !== null && update !== first);

    //记录更新后的状态
    hook.memoizedState = newState;
    hook.baseUpdate = newBaseUpdate;
    hook.baseState = newBaseState;

    queue.lastRenderedState = newState;
  }
  const dispatch = queue.dispatch;
  //返回更新后的状态及setter函数
  return [hook.memoizedState, dispatch];
}

这里的 dispatch 函数,就是引用了当前「Fiber Node」和「当前 Hook 的更新动作队列」的 dispatchAction 函数闭包,dispatchAction 函数做的事情很简单:1、把我们调用 setState 函数时传入的更新动作或值,放进当前 Hook 的更新队列;2、启动重渲染。

React 会在下一次函数组件执行时,调用 Hook 获取更新后的值。

dispatchAction 代码如下所示:

function dispatchAction(fiber,queue,action) {
  // 把更新放进更新动作队列
  const update = {
    expirationTime,
    action,
    eagerReducer: null,
    eagerState: null,
    next: null,
  };
  const last = queue.last;
  if (last === null) {
    update.next = update;
  } else {
    const first = last.next;
    if (first !== null) {
      update.next = first;
    }
    last.next = update;
  }
  queue.last = update;

  //启动重渲染
  scheduleWork(fiber, expirationTime);
}

实际的运行效果通过下面的例子说明下,假设有一个组件,代码如下所示。

import React, { useState } from 'react'

import React, { useState } from 'react'

function Test() {
  const [count, setCount] = useState(0)
  return (
    <div>
        {count}
        <button
            onClick={() => {
                setCount((prev) => prev + 1)
            }}
        >
          click me!
        </button>
    </div>
  )
}

第一次渲染时,useState 会将初始值即 0,存放到 hook.memoizedState 上,并将其和保留了「组件对应的 Fiber Node」和 「更新队列 hook.queue」引用的 dispatch 函数一起返回,这样我们就得到了一个值为 0 的 count 变量和一个 setCount 函数,组件展示出的 count 值就是0。

当点击「click me!」按钮后 setCount 会将回调函数 「(prev) => prev + 1」 作为更新动作放到更新队列 hook.queue 中,然后触发重渲染。 在重渲染阶段函数组件会被再次执行,此时useState 会先执行更新队列 hook.queue 里的更新动作,然后返回更新后的值以及 dispatch 函数。此时我们就得到了最新的 count 值。

关于 State Hook 常见的问题有以下两个:

1、如果我们将 setState 放在了一个不受限制(比如顶层作用域、没有依赖参数的 useEffect )的地方会导致「无限」重渲染。 原因就在于每一次 setState 都会触发重渲染(如果是同一setState 在同一次渲染被多次调用只会触发一次),而每次重渲染又会执行setState,这就是个死循环。就比如下面这个例子。

import React, { useState } from 'react'

function Test() {
  const [count, setCount] = useState(0)
  setCount((prev) => prev + 1)
  return (
    <div>
        {count}
    </div>
  )
}

2、为什么每次拿到的不是「最新」的 state ?

每次函数组件「重渲染」时所获取的 state,都是上一次渲染时被塞进更新队列中的更新动作执行后的结果,且每次返回的 state 都是一个全新的 state,而不是旧 state 的引用,state 在当前渲染阶段是不可变的。这样就类似于对当前整个组件中的状态形成了一个快照,而不是实时的。比如下面的例子中,不管你在 5 秒内点击了多少次,控制台都会按序输出「 1 2 3 4 5... ...」,而不是「n n n... ...」(不信邪的话可以试试,证明手速的时候到了)。

import React, { useState } from 'react'

function Test() {
  const [count, setCount] = useState(0)
  return (
    <div>
        {count}
        <button 
          onClick={() => {
            setCount((prev) => prev + 1)
            setTimeout(() => console.log(count), 5000)
          }}
        >
          click
        </button>
    </div>
  )
}

State Hook 带来的状态不可变性,就像是无状态函数组件的 props 一样,每次渲染时的props都是确定的。这种方式也符合了React一直以来对于 immutable 的追求。开发者也就能更好的把控状态的变化。如果非要搞一个 mutable的状态,请用 useRef,不过尽量还是少用,减少不确定的变化。

说完了 State Hook,那么另一个常用的 Effect Hook 是怎么实现的呢?

Effect Hook 的实现其实很简单,函数组件「第一次执行」时,Effect Hook 会把开发者传入的回调函数放到当前 Fiber Node 的更新队列 fiber.updateQueue 内,并在执行 DOM 更新之后逐一调用。

在函数组件「重渲染」时 Effect Hook 会将前后的依赖列表进行比对,如果列表为空或者前后依赖列表值的比对一致,此 Effect Hook 会被标记为不需要执行,但它依然存在于 Hook 链表当中,这样做是为了保证不会发生 Hook 错位的问题。如果依赖列表值的比对前后不一致或是没有依赖列表,此 Effect 会被标记为需要执行,回调函数会在执行 DOM 更新之后被调用。 Effect 回调函数的返回值会在组件 unmount 时执行。

代码如下

function mountEffect(fiberEffectTag, hookEffectTag, create, deps) {
  //create参数就是我们传入的callback
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;

  //pushEffect实际上执行的操作就是将Effect Hook放在Fiber Node的更新队列fiber.updateQueue中
  //并返回effect,将其存放在hook.memoizedState上
  hook.memoizedState = pushEffect(hookEffectTag, create, undefined, nextDeps);
}

function updateEffect(fiberEffectTag, hookEffectTag, create, deps){
  const hook = updateWorkInProgressHook();

  //先获取最新的依赖值
  const nextDeps = deps === undefined ? null : deps;
  const prevEffect = currentHook.memoizedState;

  //如果不存在依赖列表直接跳过对比
  if (nextDeps !== null) {
    const prevDeps = prevEffect.deps;  //获取上一次的依赖值
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      //依赖列表为空数组或者是前后依赖值一致,则将effect标记为不需要执行
      pushEffect(NoHookEffect, create, destroy, nextDeps);
      return;
    }
  }
  //将effect标记为需要执行,执行 DOM 更新之后调用
  hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps);
}

函数组件从严格意义上来说并不存在生命周期这个概念,它就是一个函数,不管是「第一次执行」还是「重渲染」,Effect 的执行时机都是 DOM 更新后,唯一类似于生命周期钩子的就是 Effect 回调函数的返回值了,它会在函数组件被卸载的时候执行。

虽说这样给开发者带来了很大的困惑,总想着怎么用Hook实现 Class Component 的生命周期。不过这样一来,开发者就不必为在哪个生命周期钩子里执行副作用而发愁了,因为没得选,只能在 Effect 的回调里执行,执行时机还是固定的。如果非要在组件渲染前拿到状态然后再去渲染,请考虑状态提升,或者使用官方骚操作。

六、如何更好的使用 Hook

通过上面的解析,我们了解了 Hook 的原理和实现,那么怎么才能更好的使用 Hook 呢?

Hook 最大的亮点是自定义 Hook,我们可以利用自定义 Hook 将复用粒度降级为代码,在此基础上将所有的取数逻辑封装为可复用单元-自定义 Hook,确定状态来源。我们先看个反例,下面「获取 table 数据并展示」的简易例子中,采用的是类似于 Vue 或者 Class Component 的风格,它有着「状态」和「成员方法」,「成员方法」中直接调用了「状态」和「setState 函数」。

import React, { useState, useEffect } from 'react'

function Test() {
  const [page, setPage] = useState(0)
  const [tableData, setTableData] = useState(null)

  function getData(pageNum) {
    //假装这里是一段获取数据的逻辑,我们现在拿到了结果res
    setTableData(res)
  }

  return (
    <div>
        {/*假装这里展示了tableData*/}
      <div>当前页数{page}</div>
      <button 
                onClick={() => {
          setPage(prev => prev + 1)
          getData(page + 1)
        }}
      >
        点我翻页
            </button>
    </div>
  )
}

很显然这不够 Hook,我们不能以类似 Vue 或者 Class Component 的方式去使用 Hook。那么什么才是够 Hook 的方式? 还是上面的例子,这次我们把获取 tableData、处理 tableData的逻辑,抽成一个自定义 Hook「useTableData」(自定义 Hook 的命名规范请参照官方文档),并把这个自定义 Hook 放在组件外部,然后直接在组件内部调用这个自定义 Hook。这样我们就将取数的逻辑封装成了一个可复用的单元。下面是改造后的结果:

import React, { useState, useEffect } from 'react'

function useTableData(page) {
  const [tableData, setTableData] = useState(null)

  useEffect(() => {
    //假装这里是一段获取数据的逻辑,我们现在拿到了结果res
    setTableData(res)
  },[page])

  return tableData
}

function Test() {
  const [page, setPage] = useState(0)
  const tableData = useTableData(page)

  return (
    <div>
        {/*假装这里展示了tableData*/}
      <div>当前页数{page}</div>
      <button 
        onClick={() => {
          setPage(prev => prev + 1)
        }}
      >
        点我翻页
            </button>
    </div>
  )
}

通过自定义 Hook 我们可以将所有的「状态」按「单一职责」划分为单独的自定义 Hook,这样我们就能将所有的状态都确定为一个个的「小状态池」,我们无需关心内部实现细节,直接作为黑盒获取所需数据就可以了。

七、最后

试着放下我们之前所熟悉的 Vue、React,不再把 Hook 当成简单的 API。假设我们只接触过只有函数式组件和 Hook 的 React,我们再去审视一下,Hook 给我们带来的东西。

笔者的感想:Hook 试图将我们的关注点分离:我们通过自定义 Hook得到一个独立模块,对于这个独立的模块而言,我们只关心它的「输入」和「输出」,而其自身只需关注自身的功能实现,而无需去改变外部的状态。

React 在「输入 -> 输出」这条路上愈行愈远,或许未来我们能看到一个响应式的React。

八、广告时间

大前端招 P7啦,PS:优秀的P6也可以,欢迎各位大佬来投。邮箱:「zc181157@alibaba-inc.com」。

饿了么大前端是上海知名的前端技术团队。 不管你是H5、小程序高手,还是Java、数据库专家,不论你追求对技术更高更快更强的挑战 ,还是寻找令亿万人生活更美好的成就感,在饿了么大前端都有机会实现。

岗位职责
1. 持续关注 Web 技术的发展,掌握新技术的核心点,从中挑选适合公司业务发展的新技术;
2. 推动建立前端的技术标准和规范,推动开发、测试、部署等最佳实践,执行并帮助改进;
3. 围绕公司业务进行前端框架和工具的设计和维护;
4. 技术攻坚,并指导和帮助团队成员一起成长。

任职要求
1. 计算机科学或相关专业毕业,深入理解数据结构、算法等基础知识;
2. 五年以上前端开发经验,对前端领域一个或多个方向有深入研究;
3. 熟悉主流前端框架的技术细节,如 React、Vue、Angular,有一定的前端框架设计经验更佳;
4. 熟练使用 webpack、jest 等工具,对工程化有深入理解;
5. 掌握至少一种服务端开发语言,如 NodeJS、Python、Java,了解 HTTP 协议相关的基础知识;
6. 具备良好的沟通、规划和项目推动能力,以及独立解决技术难题的能力;
7. 加分项:一年以上团队管理经验;推动老旧系统技术改造的经验;GitHub 有 200+ star 开源项目。

发布于 2019-11-27 14:15