自从第一个关于React Hooks的 alpha 版本发布以来,有一个问题不断出现在讨论中:“为什么是 Hook 而非 <一些其他的 API> ?”
提示一下,以下的这些就是Hooks:
useState()
用于声明一个状态变量。useEffect()
用于声明一个补充规则(side effect)。useContext()
用于读取一些上下文的内容。
但是仍有一些其他的 API,例如React.memo()
和<Context.Provider>
,它们不是Hooks。通常提出的 Hook 版本会建议是noncompositional或者antimodular。本文将帮助你了解原因。
提示:这篇文章对那些对 API 讨论感兴趣的人来说是一个深刻的话题,你不需要考虑使用 React 来提升效率!
前提
我们希望 React API 保留两个重要属性:
- 构成 Composition: 定制 Hooks很大程度上是我们对 Hooks API 感到兴奋的原因。我们希望大家可以频繁的构建自己的 Hooks,并且我们需要保证不同人写的 Hooks不会发生冲突。(我们大概会被编写清爽且不会相互破坏的组件这样的体验宠坏吧!)
- 调试 Debugging: 我们希望随着程序的增长,Bugs很容易找到。React 的最佳特征之一就是——如果你看到呈现出了任何错误,都能够通过结构树找到那个组件的 prop 或 state 导致的错误。
这两个约束放在一起可以告诉我们什么可以或不可以使用 Hook。让我们看几个例子。
使用 Hook:useState()
构成 Composition
每个调用useState()
的多个自定义 Hook 都不会冲突:
1 | function useMyCustomHook1() { |
添加一个新的无条件useState()
调用总是安全的。你不需要了解组件用于声明新状态变量的其他 Hook,也不能通过更新其中一个来破坏其他的状态变量。
结论: ✅ useState()
不会使自定义 Hook 易碎。
调试 Debugging
钩子会很有用,因为你可以用过它传递值:
1 | function useWindowWidth() { |
但是如果我们犯错了呢?该如何调试?
假设我们从theme.comment
获得的 CSS 类是错误的,我们该如何调试?我们可以在组件的主体中设置断点或几个日志输出。
也许我们会看到theme
错误但是width
和isMobile
是正确的。这会告诉我们问题是useTheme()
中的。或者也许我们会看到width
本身就是错的,那我们就应该查看useWindowWidth()
。
单独查看中间值会告诉我们顶层的那些 Hook 包含 Bug。我们不需要查看他们所有的实现。
然后我们可以“放大”有 Bug 的部分并尝试复现。
随着自定义 Hook 嵌套的深度增加,这会变得更加重要。想象我们有 3 个级别的自定义 Hook 嵌套,每个级别使用 3 个不同的自定义 Hooks。寻找3 处与潜在检查3 + 3×3 + 3×3×3 = 39 处之间的差异是巨大的。幸运的是,useState()
不能神奇的“影响”其他钩子或组件,它返回的错误值会在它后面留下一条痕迹,就像任何变量一样。🐛
结论: ✅ useState()
不会遮掩我们代码中的因果关系,我们可以直接通过痕迹追踪到 Bug。
不应使用 Hook:useBailout()
作为优化,使用 Hooks 的组件可以避免重新渲染。
一种方法是将整个组件周围方式一个React.memo()
包装器。如果 props 与我们在上一次渲染的过程中的 props 非常相等,他就会失去重新渲染的效果,这很类似PureComponent
类。
React.memo()
接受一个组件并返回一个组件:
1 | function Button(props) { |
但是为什么它不仅是个 Hook?
无论你将它成为useShouldComponentUpdate()
,usePure()
,useSkipRender()
或useBailout()
,这个体验往往看起来是这样的:
1 | function Button({ color }) { |
还有一些变化(eg:一个简单的usePure()
标记)但是在广泛的笔划中他们具有相同的缺陷。
构成 Composition
假设我们尝试将useBailout()
放在两个自定义 Hooks 中:
1 | function useFriendStatus(friendID) { |
现在如果你在同一个组件使用它们会发生什么?
1 | function ChatThread({ friendID, isTyping }) { |
什么时候重新渲染?
如果每个useBailout()
调用都有权跳过更新,那么来自useWindowWidth()
的更新将被useFriendStatus()
阻塞,反之亦然。这些 Hook 会相互破坏。
但是,如果useBailout()
尽在单个组件内的所有调用“同意”阻止更新时才能使用,那么我们的ChatThread
将无法更新isTyping
prop 的更改。
更糟糕的是,使用这些语义任何新添加到 ChatThread 的 Hook 如果没有调用 useBailout()将会损坏。否则,他们不能“反对”使用useWindowWidth()
和 useFriendStatus()
救助。
结论: 🔴 useBailout()
打破了构建方式,将其添加到 Hook 会破坏其他 Hook 的状态更新。我们希望 API 可以具有防碎性,而这种行为却背道而驰。
调试 Debugging
像useBailout()
这样的 Hook 如何影响调试?
我们将使用相同的示例:
1 | function ChatThread({ friendID, isTyping }) { |
当我们期望Typing...
标签不会出现,即便在上层的 prop 正在发生变化。我们该怎么调试?
通常,在 React 中你自信可以通过查找来给出确切的回答。如果ChatThread
没能得到一个新的isTyping
值,我们可以打开呈现<ChatThread isTyping={myVar} />
的组件并检查myVar
,以此类推。在其中一个级别,我们要么找到一个错误的shouldComponentUpdate()
救助,要么传递不正确的isTyping
值。一看链中的每个组件通常足以追寻到问题的根源。
但是如果这个useBailout()
Hook 是真的,你永远也不会知道更新被跳过的原因,知道你检查我们的ChatThread
及其所有者链中的组件使用的每个自定义 Hook(的深度)。由于每个父组件也可以使用自定义 Hook,因此拓展非常糟糕。
就像你在抽屉里寻找一把螺丝刀一样,每个抽绎都有一堆较小的抽屉柜,你不知道兔子洞有多深。
结论: 🔴 useBailout()
Hook 不仅打破了构建,更大大增加了调试步骤和求助所需的认知门槛——在某些情况下,呈指数式增加。
我们只看到了一个真正的 Hook,useState()
,还有一个关于不该使用Hook 的常规建议——useBailout()
。我们通过构建和调试的棱镜对他们进行了比较,讨论了它们中工作与否的原因。
虽然没有memo()
和shouldComponentUpdate()
的“Hook 版本”,但 React会提供一个 Hook 调用useMemo()
的方法。它有类似的用途,但它语义不同,并不会遭遇上述陷阱。
useBailout()
只是一个不该使用 Hook 工作的例子,还有很多类似的——例如:useProvider()
,useCatch()
或useSuspense()
。
你明白为什么吗?
(小声:组成…调试…)
Discuss on Twitter • Edit on GitHub
原文链接: Why Isn’t X a Hook?