为什么 X 不是 Hook?
January 26, 2019
自从 React Hooks 的第一个 alpha 版本发布以来,社区中一直有个问题反复被提及:“为什么 <某些其他 API> 不是 Hook?”
提醒一下,以下这些是 Hook:
useState()
允许你声明一个状态变量。useEffect()
允许你声明副作用。useContext()
允许你读取上下文。
但有一些其他 API,比如 React.memo()
和 <Context.Provider>
,它们不是 Hook。常见的将它们变成 Hook 的提议,往往会导致不可组合或反模块化。本文将帮助你理解其中的原因。
注意:这篇文章适合对 API 设计感兴趣的读者深入阅读。你并不需要了解这些内容才能高效使用 React!
我们希望 React API 能够保持两个重要特性:
-
组合性(Composition): 自定义 Hook 是我们对 Hooks API 感到兴奋的主要原因。我们希望开发者经常编写自己的 Hook,并且需要确保不同人写的 Hook 之间不会冲突。(组件之间可以优雅组合且互不干扰,不正是我们喜欢 React 的原因吗?)
-
易于调试(Debugging): 随着应用规模增长,我们希望 bug 容易定位。React 最棒的特性之一,就是当你发现渲染结果有问题时,可以沿着组件树向上查找,直到发现是哪个组件的 prop 或 state 导致了错误。
这两个约束结合起来,可以帮助我们判断什么可以、什么不能成为 Hook。让我们通过几个例子来看看。
一个真正的 Hook:useState()
组合性
多个自定义 Hook 各自调用 useState()
不会互相冲突:
function useMyCustomHook1() {
const [value, setValue] = useState(0);
// 这里发生的事情只影响这里。
}
function useMyCustomHook2() {
const [value, setValue] = useState(0);
// 这里发生的事情只影响这里。
}
function MyComponent() {
useMyCustomHook1();
useMyCustomHook2();
// ...
}
新增一个无条件的 useState()
调用总是安全的。你无需了解组件中其他 Hook 的实现细节,就可以声明新的状态变量。你也无法通过更新某个状态变量来破坏其他状态变量。
结论: ✅ useState()
不会让自定义 Hook 变得脆弱。
易于调试
Hook 的一个用处是可以在 Hook 之间传递值:
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
// ...
return width;
}
function useTheme(isMobile) {
// ...
}
function Comment() {
const width = useWindowWidth();
const isMobile = width < MOBILE_VIEWPORT;
const theme = useTheme(isMobile);
return (
<section className={theme.comment}>
{/* ... */}
</section>
);
}
但如果我们犯了错误,调试起来会怎样?
假设我们从 theme.comment
得到的 CSS 类不对。我们如何调试?可以在组件体内打个断点或加几个日志。
也许我们会发现 theme
的值不对,但 width
和 isMobile
是正确的。这就说明问题出在 useTheme()
。或者我们发现 width
本身就错了,那就该去查查 useWindowWidth()
。
只要看一眼中间变量的值,就能判断出顶层哪个 Hook 有 bug。 我们无需查看所有 Hook 的实现。
然后我们可以“聚焦”到有 bug 的那个 Hook,继续深入排查。
当自定义 Hook 嵌套层级增加时,这种方式就更加重要了。想象一下,我们有 3 层自定义 Hook 嵌套,每层都用到了 3 个不同的自定义 Hook。查找 bug 的位置从3 个地方变成了3 + 3×3 + 3×3×3 = 39 个地方,差别巨大。幸运的是,useState()
不会神奇地“影响”其他 Hook 或组件。它返回的错误值就像普通变量一样,会留下线索。🐛
结论: ✅ useState()
不会让代码中的因果关系变得模糊。我们可以直接顺藤摸瓜找到 bug。
不是 Hook 的例子:useBailout()
作为一种优化,使用 Hook 的组件可以跳过重新渲染。
一种做法是用 React.memo()
包裹整个组件。如果 props 与上次渲染时浅比较相等,就跳过重新渲染。这和类组件中的 PureComponent
类似。
React.memo()
接收一个组件,返回一个组件:
function Button(props) {
// ...
}
export default React.memo(Button);
但为什么它不是 Hook 呢?
无论你叫它 useShouldComponentUpdate()
、usePure()
、useSkipRender()
还是 useBailout()
,相关提案大致都像这样:
function Button({ color }) {
// ⚠️ 并非真实 API
useBailout(prevColor => prevColor !== color, color);
return (
<button className={'button-' + color}>
OK
</button>
)
}
还有一些变体(比如简单的 usePure()
标记),但大体上都存在同样的问题。
组合性
假设我们尝试在两个自定义 Hook 中都用上 useBailout()
:
function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);
// ⚠️ 并非真实 API
useBailout(prevIsOnline => prevIsOnline !== isOnline, isOnline);
useEffect(() => {
const handleStatusChange = status => setIsOnline(status.isOnline);
ChatAPI.subscribe(friendID, handleStatusChange);
return () => ChatAPI.unsubscribe(friendID, handleStatusChange);
});
return isOnline;
}
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
// ⚠️ 并非真实 API
useBailout(prevWidth => prevWidth !== width, width);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
return width;
}
那么,如果你在同一个组件里同时用它们,会发生什么?
function ChatThread({ friendID, isTyping }) {
const width = useWindowWidth();
const isOnline = useFriendStatus(friendID);
return (
<ChatLayout width={width}>
<FriendStatus isOnline={isOnline} />
{isTyping && 'Typing...'}
</ChatLayout>
);
}
什么时候会重新渲染?
如果每个 useBailout()
调用都能决定跳过更新,那么 useWindowWidth()
的更新会被 useFriendStatus()
阻断,反之亦然。这些 Hook 会互相破坏。
如果只有当所有 useBailout()
调用都“一致同意”时才跳过更新,那么我们新增的 ChatThread
组件在 isTyping
prop 变化时也会失效。
更糟糕的是,只要你往 ChatThread
里新增 Hook,如果它们没也调用 useBailout()
,就会导致其他 Hook 的状态更新被破坏。 因为它们无法“投票反对” useWindowWidth()
和 useFriendStatus()
里的 bailout。
结论: 🔴 useBailout()
破坏了组合性。把它加到某个 Hook 里,会影响其他 Hook 的状态更新。我们希望 API 能抗脆弱,而这种行为恰恰相反。
易于调试
像 useBailout()
这样的 Hook 会如何影响调试?
我们还是用上面的例子:
function ChatThread({ friendID, isTyping }) {
const width = useWindowWidth();
const isOnline = useFriendStatus(friendID);
return (
<ChatLayout width={width}>
<FriendStatus isOnline={isOnline} />
{isTyping && 'Typing...'}
</ChatLayout>
);
}
假设 Typing...
标签没有在我们预期时出现,尽管在更高层的 prop 已经发生了变化。我们该怎么调试?
通常情况下,在 React 里你可以自信地“向上查找”问题。 如果 ChatThread
没有获得新的 isTyping
值,我们可以打开渲染 <ChatThread isTyping={myVar} />
的父组件,检查 myVar
,以此类推。某一层我们要么会发现有个 bug 的 shouldComponentUpdate()
,要么就是传递下来的 isTyping
值不对。通常只需逐层查看每个组件,就能定位问题。
但如果真的有 useBailout()
这个 Hook,你永远不知道更新被跳过的原因,除非你检查所有自定义 Hook(包括深层嵌套)以及它们在父组件链上的使用。因为每个父组件也可能用自定义 Hook,这样查找的复杂度会急剧上升。
这就像你在一个抽屉柜里找螺丝刀,每个抽屉里又有一堆更小的抽屉柜,而你根本不知道这个“兔子洞”有多深。
结论: 🔴 useBailout()
不仅破坏了组合性,还极大增加了调试步骤和认知负担——有时甚至是指数级增长。
我们刚刚分析了一个真正的 Hook——useState()
,以及一个被刻意不是 Hook 的常见建议——useBailout()
。我们从组合性和调试性两个角度对比了它们,讨论了为什么一个可行而另一个不可行。
虽然没有 memo()
或 shouldComponentUpdate()
的 Hook 版本,但 React 确实 提供了一个叫做 useMemo()
的 Hook。它的作用类似,但语义足够不同,因此不会遇到上述那些陷阱。
useBailout()
只是一个不适合作为 Hook 的例子。还有其他类似的,比如 useProvider()
、useCatch()
或 useSuspense()
。
你能看出原因吗?
(小声提示:组合性……调试性……)
Pay what you like