什么是钩子?
既然我们已经讲解了 React Hook 基础架构的简化版本,并且基于该架构编写了一个函数,接下来就让我们在函数组件中实际试用一下它吧:
const Title = () => {
const a= _useHook(0)
}
前面提到的变量 a 在(组件)挂载阶段被赋值为 0,之后它就作为后续所有更新阶段的状态使用。
从技术层面来说,_useHook 是一个 React Hook 函数。尽管它并非官方支持的 Hook(函数),且我们在这里编写它只是为了演示(Hook 的)基础架构,但它具备了一个 Hook 函数应有的全部核心特性。下面我们来仔细分析一下它。
hook是一个函数
钩子是一个会接收参数并返回值的函数,按照惯例,钩子函数会以 use来 开头。
如果我们 把useHook 当作一个通用的 hook,下面是它接收不同参数的用例:
const Title = () => {
const a = useHook()
const b = useHook(1)
const c = useHook(1, 2, "Hello")
const d = useHook({ text: "Hello World" })
}
作为一个函数,钩子可以按照我们的需要返回一些值。这些返回值可以设计成任何格式:
const Title = () => {
useHook(...)
const i = useHook(...)
const [j, k] = useHook(...)
const { value } = useHook(...)
}
并不是所有的钩子都会有返回值。如果一个值被返回了,它可以是 null, 可以是 一个 数字,可以是 一个 字符串,可以是 一个数组或者对象,或者任何 JS 表达式。
因为一个函数的返回值可以成为 另一个函数的 参数,链式调用hooks 也是可以的:
const Title = () => {
useHook(...)
const i = useHook(...)
const j = useHook(i)
const k = useHook(i, j, text)
}
在之前的代码中,变量 i 和 j 是从两个 Hook 中返回的,随后它们被传入另一个 Hook 作为输入参数,进而计算得到 k。此外,text 属性也作为输入参数传递给了一个 Hook。实际上,一条 Hook 语句与一条局部赋值语句的差别并不大。
总而言之,从技术层面来讲,Hook 本质就是一个函数。不要仅仅因为它叫 “Hook” 就对它产生畏惧。你所了解的关于函数的大部分特性,都适用于 Hook。话虽如此,Hook 终究是一种特殊的函数,它有一个需要特别注意的警告 —— 即它的调用顺序。
hook的调用顺序
目前为止,我们知道 在函数组件内 多次调用同一个钩子并不会 造成冲突,因为每一个状态 都指向一个 独立 的内存空间。
const Title = () => {
const a = _useHook(0)
const b = _useHook("Hello")
}
我们在实现 _getM2 方法时,是通过 key 来作为每一个字段的 唯一索引的。现在,有了 Hook 架构后,我们不用再这么做了。那么,你也许想知道 为什么 没有 key 作为 唯一索引,也可以得到 每一个 状态?
在挂载阶段,在 函数组件内调用 钩子函数之前,是并不存在 钩子的:
const a = _useHook(0)
在运行了刚刚的代码后,React 会 生成 一个 Hook 结构,并把它放在 fiber 上。之后,React 又看到了 另一个钩子的调用:
const b = _useHook("Hello")
在调用了 这一句之后,React 会把另一个 Hook 结构 第一个 Hook的 next 的后面。这个操作会在 更新阶段执行。
之后,进入到了第二次更新;当React 引擎 读取到 针对 a 的 第一个钩子函数,React引擎 会在 当前fiber的链表上查找其对应的 Hook 对象;当 React 引擎读取到 针对 b 到 第二个钩子函数时,也会顺着 链表 找到与之对应的 Hook 对象。
本章上而言,React 引擎没有 采用key,因为 链表的顺序本身就是一个 key,而 这个key 正是钩子们的调用顺序。
只要获取变量 a 的第一个 Hook 先调用,获取变量 b 的第二个 Hook 后调用,链表中存储的状态对应的位置就能被正确标记。因此,我们无需刻意记录(状态的)键名 —— 因为在编写完所有 Hook 语句后,它们的调用顺序就已经确定了。
这一 没有 显式key 的 设计 方案,让开发者可以 更便捷地使用 钩子。不过有一个需要注意的点:只要我们能避免踩这个坑,从实际使用来看,这种设计的效果会非常好。
所以,这里就要说说那个需要注意的点了:Hook 的调用顺序并非在代码编译时就固定下来,而是在运行时才确定的。这两者有什么区别呢?区别就在于,运行时确定的内容是可能发生变化的。举个例子,我们可以用一个 if 语句来构造这样的场景。
条件式 hook 所带来的问题
我们看看这个 Title 组件:
const Title = ({ flag }) => {
const a = flag ? _useHook('a') : ' '
const b = _useHook('b')
return <h1>Hello World+{a} {b}</h1>
}
在前面的代码逻辑中,我们想把 'a' 和 'b' 到 变量 a 和变量 b里。但是,当flag 为 false 时,变量 a 为 ' '。
为了验证代码是否能正常运行,我们来对这个组件进行两次更新,同时切换 flag 属性的值。假设第一次更新时,flag 属性被设为 true;第二次更新时,它被改为 false。在这样的设置下,会生成如下的时间线示意图:
第一次更新时,变量 a 和 b 都被正确赋值了。但到了第二次更新,变量 b 却被设成了字符 'a'。这有点奇怪,因为我们在代码里从未要求把字符 'a' 赋值给变量 b。怎么会出现这种情况呢?!
为什么调用_useHook('b'),得到的是 'a', 这个 'a' 又是怎么来的。为了回答这两个问题,我们要深入理解 Hook 对象 在 fiber 下的 工作原理:
在前面的时间线示意图中,我们打印出了两个 Hook 下存储的状态。两次更新中,Hook1 始终存储字符 'a',Hook2 始终存储字符 'b'。我们来仔细看一下第二次更新的情况:此时编译器所 “看到” 的代码如下:
const Title = () => {
const a = ' '
const b = _useHook('b')
return <div>Hello World + {a}{b}</div>
}
在前面的代码设置中,我们将 flag 属性硬编码设为了 false。正因为如此,用于获取变量 a 的第一个 Hook 调用被跳过了,最终代码里就只剩下用于获取变量 b 的那一条 Hook 语句。你可以在图 3.3 中看到这些信息 —— 该图不仅展示了两个 Hook(的存储状态),还标注了每一条 Hook 语句读取的(状态)值。
本次场景下发生的情况如下:第一次更新时,由于 flag 的值为 F(false),我们只执行了用于获取变量 b 的那一个 Hook 调用。而这一次更新属于组件挂载阶段(mount),因此字符 'b' 被初始化为 Hook1 的状态,Hook2 则未被初始化(处于未创建状态)。到了第二次更新时,由于 Hook1 早已完成初始化,其状态值不会再重新初始化,因此仍保留着字符 'b';而 Hook2 则在这次更新中才最终被初始化,并同样被赋予了字符 'b'。这就是为什么在第二次更新后,变量 a 和 b 存储的都是字符 'b'。是不是相当令人费解?当然,从某种程度上来说,这个场景比上一个场景的问题更严重 —— 不过两种情况都属于错误实现。
通过这个两个例子,我们可知结合 if 使用 钩子会 带来奇怪的问题。而这是因为 在更新过程中,该白了 钩子的 调用顺序,所以导致状态的 key 混乱了,所以无法正确 读取到 状态。
事实上,这不仅限于 if 语句;任何涉及条件判断的 Hook 调用都是不允许的。再举一个例子:
const Title = ({ arr }) => {
const names = arr.map(v => _useHook(v))
return <div>{names.join(' ')}</div>
}
我们看当前的代码,这个钩子是在一个循环中调用的。我们是无法准确得知调用了多少次这个钩子的,这是由运行时决定的。我们不会深入讨论其细节,但是你也可以猜到,当这个数组长度发生变化时,也没会发生异常。
React 官方团队也在其在线文档这样推荐到:“不要在循环、条件语句和 嵌套函数中 调用钩子。相反,永远在 React 函数的顶部 调用钩子。”现在,你已经更深刻地理解这句话的意思了。
React官方团队也意识到这个问题的危害性,因为它会破坏钩子的可用性。因此,在代码边缘极端,编译器在检测到这些错误用法时,会提出警告。此外,即便(React)偶尔没能提前识别出这类问题,在运行时,React 也会监控 Hooks 链表,以检查在新的更新过程中是否存在 Hook 调用顺序错乱的情况。一旦发现错乱,你就会看到一条警告信息(如下 / 类似内容):
避免条件式钩子
现在,我们知道我们不该写条件式钩子了,但是我们该如何避免它?换一个说法,我们在调用钩子时涉及到条件式逻辑的化,该怎么办?
这个问题的解决方案其实并不复杂。我们仍然可以编写条件语句,只是不能编写条件性的 Hook 调用语句。只要我们保证 Hook 的数量固定,且调用顺序始终一致,那么 Hook 语句本身想怎么写都可以。
让我们修正先前的例子。我们不再条件式地调用_useHook,我们可以先声明两个变量:
const Title = ({ flag }) => {
const _a = _useHook('a')
const b = _useHook('b')
const a= flag ? _a : ' '
return <h1>Hello World + {a} {b}</h1>
}
在这段代码中,我们使用一个 辅助变量_a 来获取 'a' 字符串。而 b 变量 一直 有 'b'的值。如此一来,无论发生什么更新,这两个钩子的调用顺序都是一样的。
那么现在,我们就可以将与变量 a 相关的条件逻辑部分,移到所有 Hook 调用语句之后。我们可以通过查看生成的时间线示意图,来验证这种写法是否能正常运行:
同理,我们可以看看当falg 从 F变为 T的情况:
如此一来,这段代码就可以正常运作了。
这种在函数顶层作用域调用钩子的写法,是得到 React 官方团队推荐的,它也适用于 循环场景:
const Title = ({ arr }) => {
const t = _useHook(arr)
const names = t.map((v, i) => t[i] || '')
return <div>{names.join('')}</div>
}
在当前的代码中,我们无法得知arr的长度,所以最好不要在map里调用钩子函数。相反,我们可以先把arr赋值给一个状态,然后再遍历整个状态。如此一来,我们就避免了 数组长度 变动 带来的隐患。
幸运的是,前面提到的那个注意事项(Hook 调用顺序问题)是 React Hooks 唯一的核心约束;而且当我们遇到条件语句时,只需采用 “正确的处理方式”—— 将 Hook 调用语句放在函数的最前面,就能规避问题。
简而言之,React 钩子是一种 运行 函数组件 持续 存储值的函数。基于这一套基础架构,React 为我们提供了一些常用的钩子。在下一章,我们会学习这些钩子的使用细节,这些钩子包括:useState,useEffect, useMemo, useContext, 和 useRef。在第九章,我们会探讨如何基于实际需求来自定义钩子。
总结
在本章节,你已经学习了一个好的状态方案,也知道了React 是如何构建 Hook 来 支持这一方案的。你也知道了,什么是 React hook,知道了 钩子调用顺序的重要性,当然,你也知道了要避免在条件语句、循环语句中调用钩子。
在下一章,我们要学习 React 大家庭的第一个钩子 useState。有了useState,我们就可以定义一个状态来驱动一个 UI 了。