React技术细节手册 - React Hook 的运行原理和让人困惑的秘密

2,167 阅读12分钟

修订记录

2020/05/31

  • 增加了新的示例, 以便更好的解释 Hooks 的运行原理和 React 作出限制的原因
  • 修改了部分概念解释

写在前头

本文希望通过揭开一些 React 隐藏的技术细节, 来辅助对官方文档中某些概念的理解

读者可以将本文看做对官方文档的补充

行文方式我采用的是提问-解答的方式, 即先根据官方文档给出的使用规则, 提出问题, Why ? 然后我们根据实际的调试再来解答这个 Why, 最后系统的整理这些 Why 变成 How, 如果你们有更好的行文方式, 也欢迎留言讨论

另外为了阅读体验, 我不会粘贴过多的源码, 避免打断各位读者的思路.

正文

从 Hooks 一些使用限制来看背后隐藏的细节

一. Hooks 为什么只能写在 FCComponent 内 ? React 怎么知道的 ?

其实没有什么黑魔法, React 在初始化的过程中会构建一个 ReactCurrentDispatcher 的全局变量用于跟踪当前的 dispatcher

dispatcher 可以理解成一个 Hooks 的代理人

由于你在 FCC 外部执行 Hooks, 这时候要么 React 没有初始化, 要么就是 Hooks 无法关联到 ReactCurrentDispatcher, 大部分场景都是因为生命周期的错配而报错, 所以 React 也并不能百分百知道你的 Hooks 执行时机是否正确

二. React useState如何在没有 Key 的情况下只通过初始值来判断读写的是哪个 State ?

官方文档在关于 Hooks 执行顺序和 State 读写之间的关联说明上语焉不详

"那么 React 怎么知道哪个 state 对应哪个 useState?答案是 React 靠的是 Hook 调用的顺序。"

不得不说这个重要的细节, 官方却给了个模棱两可的答案.

看过其他相关介绍的读者应该知道 React 在 State 读写上借鉴了一个类似状态单元格的概念, 通过将 State 和 setState 分离到两个数组中, 然后根据数组下标就能确定要读写的是哪个 State, 但对于 React 来说基于 Fiber 的架构自然不可能这么简单

要解开这个谜题, 首先我们得知道两点, 即 React 如何存储 State, Hook 究竟是什么

React 对 State 的处理并不复杂, 类似下面的链表结构来存储一个 FCC 内部的, 通过 useState 声明的 State

{   
    memoizedState:1
    next: {
        memoizedState:"ff"
        next: null
    }
}

通过 next 指针, React 可以按照顺序来读写 State, 这很方便

再次推荐前端开发同学掌握基本的数据结构, 这样有助于你更好的理解代码

那 Hook 呢 ? 究竟什么是 Hook, React 如何存储 Hook ?

Hook 是一个对象, 当然 JS 里一切都是对象, React 将 hook 声明为这样一个结构

var hook = {
      memoizedState: null,
      baseState: null,
      baseQueue: null,
      queue: null,
      next: null
    };

跟 State 一样 Hook 也是一个单向链表结构, 这里的 memoizedState 和上面的那个是一致的, 嗯如果你有遵规则的话, 那就是一致的......

官网其实没有明确给 Hook 做出定义, 相比 State, Hook 主要多了一个 queue 属性, 那么这是什么呢?

    var queue = {
      pending: null,
      dispatch: null,
      lastRenderedReducer: basicStateReducer,
      lastRenderedState: initialState
    };

这是 React 对 queue 的结构声明, 在不深入 Fiber 关于如何使用 queue 的细节下, 我们姑且做个猜测, queue 是队列的意思, pending 可能意指某个执行中的 dispatch, lastRenderedReducer, 这里是一个默认函数, 在更新阶段保存的是上一次使用的用来更新 State 的 Reducer 函数, 至于 lastRenderedState, 自然是前一个 State.

结合 queue 的结构, 我们可以试着给 Hook 一个定义 Hook 是一个对 State 逻辑处理函数进行管理的管理者, 它通过队列的方式有效管理这些逻辑处理函数

考虑到 Hook 并不止 useState useEffect, React 的源码也在不停的变更, 所以这里的定义或许并不严谨, 不过本系列的文章并不是一篇一次性的文章, 后续随着细节的深入和讨论, 我会更新相关的一些定义和内容来修订原有的版本, 以力求严谨和一致性

这里的概念很接近 Redux, 不过在深入这些细节之前, 本文还是先聚焦 Hooks 的规则, 关于 React 内部的这种 State 更新管理机制以及它和 Fiber 的关系, 我会在后续文章中讨论, 在这里先有个概念吧.

了解了 React 如何存储 State 和 Hook, 同时对 Hook 有了明确的结构定义后, 再补充一个 Fiber 的渲染逻辑, 即在 commit 阶段, 渲染一旦发生就要全部完成, 不存在局部渲染, 每一次都是完整的"所有的节点"

这里所有的节点打了个引号, 对于 Fiber 使用链表实现的树全部遍历一次的开销依然巨大, 所以 React 做了优化, 关于这部分可查看这篇文章

在这种情况下 FCC 的 ReRender 会导致内部的 Hooks 全部都执行一遍, 我们把官网的那个例子稍微改改然后再做说明

示例 1.0

"use strict";
function Counter({
    initialCount
}) {
    const [count, setCount] = React.useState(1);

    if (count === 1) {
        const [count2, setCount2] = React.useState(2);
    } 

    const [surname, setSurname] = React.useState('Poppins');

    return /*#__PURE__*/React.createElement(React.Fragment, null, "Count: ", count, /*#__PURE__*/React.createElement("button", {
        onClick: () => setCount(initialCount)
    }, "Reset"), /*#__PURE__*/React.createElement("button", {
        onClick: () => setCount(prevCount => prevCount - 1)
    }, "-"), /*#__PURE__*/React.createElement("button", {
        onClick: () => setCount(prevCount => prevCount + 1)
    }, "+"));
}


ReactDOM.render(React.createElement(Counter, { initialCount: 1 }, null),
    document.getElementById('root')
);

为了便于调试, 我只使用了 React 必须的两个库, 例子中的代码也没有使用 JSX

在说明具体的例子前, 将上面的和一些背景知识做个整理

在了解 FCC ReRender 导致所有 Hooks 重新执行的基础上, 我们再加一条, 即对于 State 而言存在两个阶段即 "mount" 和 "update", 两个阶段都有不同的 dispatcher 来触发, 也会分别调用 mountState 和 updateState 这样的函数来处理, 路径的分叉是在 Hooks 被执行前, React 称为 renderWithHooks, 在这个阶段, React 会判断 current 节点上是否有 memoizedState, 无则 mount, 有则 update

current 节点在 performUnitOfWork 中声明, 并通过 beginWork 传递进 renderWithHooks 中, unitOfWork 是一个 FiberNode, 因为涉及到 Fiber 架构的工作逻辑分析, 我们先有个概念, 在后续文章中讨论这些细节

总结下:

  • Hooks 会随着 FCC ReRender 而重复执行
  • Hooks 和 State 都保存在一个单向链表中, 其中的 memoizedState 和 State 单向链表中的一致
  • 读写 State 存在 mount 和 update 两条不同的路径
  • 每个 FCC 都有存有自己的 State 表

回到上面的例子, 第一次 render 后, Counter 节点上的 State 和 Hooks 的两个链表应当是

// State List
{   
    memoizedState:1,
    next: {
        memoizedState: 2,
        next: {
            memoizedState: "Poppins",
            next: null
        }
    }
}

// Hooks List
{
    memoizedState: 1,
    queue: {
        dispatch: fn()
    },
    next: {
        memoizedState: 2,
        queue:{
            dispatch: fn()
        },
        next: {
            memoizedState: "Poppins",
            queue: {
                dispatch: fn()
            }
            next: null
        }
    }
};

这里简化了结构, 去掉了某些属性以便于理解

然后我们通过点击按钮触发 setCount + 1 来引发 ReRender, 由于此时 count = 2, 导致原有的第二个 useState 不会执行, 但是 React 并不知道这一点, 他会默认你是守规矩的, 这就导致了一个有意思的结果

const [surname, setSurname] = React.useState('Poppins');

我们预期 surname 应该是 'Poppins', 因为我们没做任何变更, 但实际上 React 此时返回的数组中的 surname 是 2. 因为在更新路径中, Hook 对应的链表里第二个 memoizedState 是 2, 不是 'Poppins', React 按照顺序沿着 Hook 的指针前进并调用对应的 queue 里的 dispatch, 它并不关心你的真实逻辑, 于是就产生了预期结果和执行结果的不一致, 也就是官网所说的导致了 bug, 但官网中提到的提前执行其实有歧义, 对于 React 来说一切都是有序的, 不存在将后置的 Hook 提前执行, 只是你预期的和它实际干的没有对应上, 这里可以用图来说明

第二次执行的时候
React给你的       你想的
1                1
2                'Poppins'
'Poppins'

React 给了一个你认为是错误, 但是它认为是正确的结果, 不得不说这有点违反直觉, 而且有点反人类, 估计 React 也知道这么干有点不太好, 所以做了些防御性措施, 我在 16.13.1 的开发版中测试了下, React 会针对两种情况抛出异常

  • Hooks 两次执行相同顺序下的名称不对应, 会报错
  • Hooks 两次执行的数量不一致, 像上面这种也会报错, 因为第二次渲染中实际只执行了两次 Hooks, React 会跟踪 Hooks 的执行, 因为保存了上一次执行的 List, 所以它会对比

关于 React 如何跟踪这里涉及到 Fiber 中 workInProgress 部分的设计, 先埋个坑, 后面来填

Hooks 和变量的冲突

这部分让我们来看一个符合实际需求的, 稍微复杂一点的示例

示例 1.1

"use strict";
function Counter({
    initialCount
}) {
    let setDocumentTitle = null;
    let documentTitle = 'unknown';
    const [count, setCount] = React.useState(1);

    if (count === 1) {
        const [title, setTitle] = React.useState('Poppins');
        documentTitle = title
        setDocumentTitle = setTitle
    } else {
        const [title2, setTitle2] = React.useState('Jacky');
        documentTitle = title2
        setDocumentTitle = setTitle2
    }
    React.useEffect(() => {
        document.title = documentTitle
    })


    return React.createElement(
        React.Fragment, null, "Count: ", count,
        React.createElement("button", {
            onClick: () => setCount(initialCount)
        }, "Reset"),
        React.createElement("button", {
            onClick: () => setCount(prevCount => prevCount - 1)
        }, "-"),
        React.createElement("button", {
            onClick: () => setCount(prevCount => prevCount + 1)
        }, "+"),
        React.createElement("button", {
            onClick: () => setDocumentTitle(prevTitle => prevTitle + count)
        }, "setTitle")
    );
}


ReactDOM.render(React.createElement(Counter, { initialCount: 1 }, null),
    document.getElementById('root')
);

简单解释下这个示例, 有两个按钮, 我希望通过设置 count 按钮让 setTitle 按钮具备两种不同的功能, 给 document 加不同的 Title, 为了实现这个需求, 我使用了两个"变量", 同时为了绕开 React 的 Hooks 异常检测, 记得上面的 2 条规则么, 只要 Hooks 数量一致, 名称一致, React 无法检测到你使用了 condition, 为此我可能感到沾沾自喜, run 一下, 点一点, 果然没报错, 但是结合示例一, 我们就应该知道事情没这么简单.

本来想搞个 gif 结果都要收费, 如果谁有好用的视频转 gif 工具, 求告知

此时 count 是 2, 按照预期的逻辑, 点击 setTitle 的结果应该是 'Jacky2', 但截图上却是 Poppins2, React 也没有任何报错的提示, 说明我们确实绕过了检测, 但结果却不是我们预期的, why?

其实示例 1.0 已经告诉了我们答案, 示例 1.1 更好的验证了上面的一些结论

  • Hooks 只有在 mount 阶段才会去初始化 State, 而 mount 阶段是根据组件的状态来判断的, 所以 Hooks 虽然一直在 ReRun, 但是生命周期却并不一样
  • 在 mount 阶段 Hooks 链表通过 memoizedState 映射到 State 链表, 但一旦进入 update 阶段, Hooks 不会检查映射是否正确, 此时 mount 阶段的建立的映射顺序就是读写正确的保障

直到这里, 我才完全理解官网文档中的那句话 "那么 React 怎么知道哪个 state 对应哪个 useState?答案是 React 靠的是 Hook 调用的顺序。"

根据这两条结论我们分析示例 1.1 的执行过程

第一次           第二次
1                2
'Poppins'        'Poppins'

没错, 一模一样, 即使进入了 condition , 调用 useState('Jacky') 也一样, 如果你第一次使用 Hooks, 你一定会觉得 JavaScript 坏掉了, 没有进入 if 判断对么, 所以 Hooks 违反直觉的实现在示例 1.1 中得到了充分的体现

所以官网的文档中为什么没有对 Hooks 其实具有 2 个生命周期这非常重要的细节作出解释? 缺少这一关键的细节, 就会导致理解上的困难, 我觉得一方面, Hooks 的设计想让开发者不需要去考虑组件的生命周期, ClassComponent 的生命周期其实带来了一定的心智负担, 现在你也可以理解为什么 Hooks 必须不能使用各种常用的编程方法, 比如 condition, loop 等等, 为了保证 mount 和 update 两个阶段的一致性, 但这种设计是否真的合理呢?

假设我们不做任何质疑, 按照规则去写, 但是我相信一定会有 bug, 就像上面的示例, 总有办法绕过检测, 在复杂的应用中我们很难确保书写的代码完全按照官方的规则, 这种口头约定很难具有有效约束力, 而且我也相信大部分的程序员也没有足够多的时间, 尤其是上班之后, 花好几天的时间仔细的调试和思考这部分代码来理解其背后的原理, 并猜测作者的设计意图, 当然这也是我写这篇文章的原因.

理想的情况下, React 团队能找到很好的解决方案来统一两个生命周期的实现, 以便抹平这种内在差异, 不然这个不良实现的成本可能会让广大开发者来埋单, 站在 React 团队的角度, 应该先从文档入手, 充分说明这部分细节, 即使这部分细节的实现看起来似乎并不这么优雅, 但也不能因此就隐藏在看似良好的设计背后.

费曼说过, 我不能真正理解我们不能重建的事物, 所以下一次修订, 我将 Hooks 的实现从 React 中拆出来, 让我们重建它, 并看看这套系统又是如何和 Fiber 联系在一起的, 其中又有哪些被隐藏的"不好"的实现呢? 如果你感兴趣就关注我吧, 顺手点个赞或者分享一下, 我希望更多 React 开发者能知道这部分细节, 有助于避免在工作中采坑, 或许就能挽救一个 996 骚年呢? 😀

写在后头

期望通过不断的修订来完善这部分内容, 希望最终能变成一份 React 技术细节的查询手册, 这种书写方式来源于我过往的架构工作中关于演进的部分经验, 如果你们对前端架构师的日常感兴趣, 也可另开一篇讲讲.