overreactedby Dan Abramov

为什么 X 不是 Hook?

January 26, 2019

自从 React Hooks 的第一个 alpha 版本发布以来,社区中一直有个问题反复被提及:“为什么 <某些其他 API> 不是 Hook?”

提醒一下,以下这些是 Hook

但有一些其他 API,比如 React.memo()<Context.Provider>,它们不是 Hook。常见的将它们变成 Hook 的提议,往往会导致不可组合反模块化。本文将帮助你理解其中的原因。

注意:这篇文章适合对 API 设计感兴趣的读者深入阅读。你并不需要了解这些内容才能高效使用 React!


我们希望 React API 能够保持两个重要特性:

  1. 组合性(Composition): 自定义 Hook 是我们对 Hooks API 感到兴奋的主要原因。我们希望开发者经常编写自己的 Hook,并且需要确保不同人写的 Hook 之间不会冲突。(组件之间可以优雅组合且互不干扰,不正是我们喜欢 React 的原因吗?)

  2. 易于调试(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 的值不对,但 widthisMobile 是正确的。这就说明问题出在 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

Edit on GitHub