react hooks 实现原理

解析 react hooks 源码

介绍 React Hooks

React Hooks 是 React 16.8 版本开始引入的一项新特性,它可以让你在不编写 class 组件的情况下使用 state 以及其他的 React 特性。主要的 Hooks 包括 useStateuseEffectuseContext 等。

为了理解 Hooks 的实现原理,首先要理解 Hooks 是如何工作的。Hooks 使用了一种特殊的队列结构来存储 state,然后利用 JavaScript 的闭包机制来获取和更新这些 state。

这是一个非常简单的 Hooks 使用例子:

jsx
1import React, { useState } from 'react';
2
3function Example() {
4  const [count, setCount] = useState(0);
5
6  return (
7    <div>
8      <p>You clicked {count} times</p>
9      <button onClick={() => setCount(count + 1)}>
10        Click me
11      </button>
12    </div>
13  );
14}

这个例子里,useState 是一个 Hook,它的工作原理如下:

  1. 调用 useState 方法。useState 接受的参数是 state 的初始值,返回一个数组,包含两个元素,第一个元素是当前的 state,第二个元素是一个函数,可以用来更新这个 state。
  2. React 会在第一次渲染组件时,用传入 useState 的初始值初始化 state。
  3. 当你点击按钮时,调用 setCount,传入新的 state 值。这会触发 React 重新渲染组件。在渲染过程中,useState 会返回新的 state。

每个组件实例都有自己的独立的 state,React 使用了一个与组件实例对应的数据结构来存储每个组件的 state,这个数据结构称为 "fiber"。当组件调用 useStateuseEffect 时,React 就会向当前的 fiber 节点的 Hooks 队列中添加一个新的 Hook。

重要的是,React 需要确保 Hooks 的调用顺序在每次渲染中都是一致的。也就是说,你不能在条件判断或循环中随意调用 Hooks,因为这可能会导致 Hooks 的调用顺序在不同的渲染过程中发生变化,从而使得 React 无法正确地匹配到对应的 state。

对于 useEffect,它可以让你在组件渲染后执行一些副作用操作(例如网络请求、DOM 操作等)。React 在渲染组件后,会按照 Hooks 的添加顺序依次调用所有的 effect。如果 effect 返回一个函数,那么这个函数会在组件卸载或下一次渲染前被调用,用于执行一些清理操作。

总的来说,React Hooks 的实现依赖于 JavaScript 的闭包机制和 React 内部的数据结构。它使得我们能够在函数组件中使用 state 和其他 React 特性,同时也让代码更加简洁和易于维护。

useState 的挂载

在第一次初始声明 useState(),state 的值就是传入的值,若不传入,则是 undefined。我们再来看下 hook 的结构:

javascript
1const hook: Hook = {
2  memoizedState: null, // 这个hook目前在函数组件中显示的值,初始时,即为传入的数据(若传入的是函数,则为函数执行后的结果)
3
4  /**
5   * 该hook所有的set操作开始执行时的初始值,初始挂载时,该值与 memoizedState 相同;
6   * 在中间更新过程中,若存在低优先级的set操作,则 baseState 此时为执行到目前set的值
7   **/
8  baseState: null,
9
10  /**
11   * 执行set操作的链表,这里包含了上次遗留下来的所有set操作,和本次将要执行的所有set操作
12   **/
13  baseQueue: null,
14
15  // 所有的set操作,都会挂载到 queue.pendig 上
16  queue: null,
17
18  // 指向到下一个hook的指针
19  next: null,
20};

fiber.memoizedState 是用来挂载 hook 节点链表的;而现在讲解的 hook.memoizedState 是用来挂载该 hook 的数值的。

javascript
1function mountState<S>(initialState: (() => S) | S): [S, Dispatch<BasicStateAction<S>>] {
2  /**
3   * 创建一个hook节点,并将其挂载到 currentlyRenderingFiber 链表的最后
4   * @type {Hook}
5   */
6  const hook = mountWorkInProgressHook();
7  if (typeof initialState === 'function') {
8    // 若传入的是函数,则使用执行该函数后得到的结果
9    initialState = initialState();
10  }
11  /**
12   * 设置该 hook 的初始值
13   * memoizedState 用来存储当前hook要显示的数据
14   * baseState 用来存储执行setState()的初始数据
15   **/
16  hook.memoizedState = hook.baseState = initialState;
17
18  // 为该 hook 添加一个 queue 结构,用来存放所有的 setState() 操作
19  const queue = {
20    pending: null,
21    interleaved: null,
22    lanes: NoLanes,
23    dispatch: null,
24    lastRenderedReducer: basicStateReducer, // 上次render后使用的reducer
25    lastRenderedState: initialState, // 上次render后的state
26  };
27  hook.queue = queue;
28
29  /**
30   * 这里用到了 bind() 的偏函数的特性,我们稍后会在下面进行讲解,
31   *
32   */
33  const dispatch = (queue.dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue));
34  return [hook.memoizedState, dispatch]; // useState() 返回的数据
35}

mountState()的整体流程:

  1. 创建一个 hook 节点,挂载所有初始的数据;
  2. 若 initialState 是函数类型,则使用执行它后的结果;
  3. 执行当前节点的方法是 basicStateReducer() 函数;这里跟我们后续要讲解的 useReducer() 有关系;
  4. 将 hook 节点挂载到函数组件对应的 fiber 节点上;
  5. 返回该 hook 的初始值 和 set 方法;

basicStateReducer() 函数的具体实现:

javascript
1/**
2 * 对当前的 state 执行的基本操作,若传入的不是函数类型,则直接返回该值,
3 * 若传入的是函数类型,返回执行该函数的结果
4 * @param {S} state 当前节点的state
5 * @param {BasicStateAction<S>} action 接下来要对该state执行的操作
6 * @returns {S}
7 */
8function basicStateReducer(state, action) {
9  return typeof action === 'function' ? action(state) : action;
10}

这个 action 就是我们执行 useState() 里的第 2 个返回值的 set 操作。如:

javascript
1setCount(count + 1); // action 是数值
2setCount(count => {
3  // action是函数,参数为当前的 count
4  console.log('dispatch setCount');
5  return count + 1;
6});

bind()方法可以基于某个函数返回一个新的函数,并且可以为这个新函数预设初始的参数,然后剩余的参数给到这个新函数。官方文档:bind()的偏函数功能

我们这里暂时先不管这个函数 dispatchSetState() 的作用是什么,目前只关心参数的传递:

javascript
1function dispatchSetState(fiber: Fiber, queue, action) {}

dispatchSetState() 本身要传入 3 个参数的:

  1. fiber: 当前处理的 fiber 节点
  2. queue: 该 hook 的 queue 结构,用来挂载 setState() 中的操作的;
  3. action: 要执行的操作,即 setState(action)里的 action,可能是数据,也可能是函数;

可是我们在执行 dispatch()(即 setState())时只需要传入一个参数就行了,这就是因为源码中利用到了 bind() 的偏函数功能。

再来看下派生出 dispatch() 的操作:

javascript
1/**
2 * 这里已经提前把当前的 fiber 节点和 hook 的 queue 结构传进去了,
3 * 就只留一个 action 参数给dispatch。
4 */
5const dispatch = (queue.dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue));

可以看到,通过 bind()方法,已经提前把当前的 fiber 节点和 hook 的 queue 结构传进去了,就只留一个 action 参数给 dispatch。在调用dispatch(action时),就是在执行dispatchSetState(fiber, queue, action)

如果不太理解的话,我们再看一个简化后的例子:

javascript
1/**
2 * 设置学生的某学科的分数
3 *
4 * @param nick 学生姓名
5 * @param subject 学科
6 * @param score 分数
7 */
8const setStudentInfo = (nick, subject, score) => {
9  console.log(nick, subject, score);
10};
11
12// 设置jack的分数
13// 已预设了1个参数,剩余的两个参数供新函数设置
14const setJackInfo = setStudentInfo.bind(null, 'Jack');
15setJackInfo('math', 89); // Jack math 89
16setJackInfo('computer', 92); // Jack computer 92
17
18// 已预设了2个参数,剩余的一个参数供新函数设置
19const setTomEnglishScore = setStudentInfo.bind(null, 'Tom', 'english');
20setTomEnglishScore(97); // Tom english 97

再回到 dispatch(action) 这儿,我们在执行该方法的时候,其实已经预定了前 2 个参数:fiber 和 queue。即 dispath()已经和当前的 fibe 节点强绑定了,执行的操作只会在该 fiber 节点中产生影响。