这是用户在 2024-6-3 16:44 为 https://robinmalfait.com/blog/conditional-react-hooks-pattern 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?
Robin Malfait 罗宾·马尔菲特

June 2, 2024 2024 年 6 月 2 日

Conditional React hooks pattern
条件 React hooks 模式

Recently I was refactoring some internal hooks in Headless UI and there is a pattern we use often to enable React hooks conditionally and thought I would write about it.
最近,我正在重构 Headless UI 中的一些内部钩子,我们经常使用一种模式来有条件地启用 React 钩子,我想我会写一下它。

Often you want to use a React hook based on certain conditions, but that's not allowed by the rules of hooks. But luckily for us, there is a relatively simple trick you can use to conditionally enable or disable hooks instead.
通常,您希望根据某些条件使用 React hook,但 hooks 规则不允许这样做。但幸运的是,我们可以使用一个相对简单的技巧来有条件地启用或禁用挂钩。

Let's take a look at the problem first. In Headless UI we often have components that can be "open" or "closed". For example a <Menu /> component or a <Dialog /> component. Once they are open, we want to enable some functionality but when they are closed we want to disable that functionality again.
我们先来看看问题所在。在 Headless UI 中,我们经常有可以“打开”或“关闭”的组件。例如 <Menu /> 组件或 <Dialog /> 组件。一旦它们打开,我们希望启用某些功能,但当它们关闭时,我们希望再次禁用该功能。

Two hooks we use are the useOutsideClick hook and the useScrollLock hook.
我们使用的两个钩子是 useOutsideClick 钩子和 useScrollLock 钩子。

  • useOutsideClick this hook calls a callback when you click outside of a given element. This is used to close the <Dialog /> for example.
    useOutsideClick 当您单击给定元素外部时,此挂钩会调用回调。例如,这用于关闭 <Dialog />
  • useScrollLock this hook prevents you from scrolling the page when the component is open. This is useful in <Dialog /> components so that you can't accidentally scroll the page behind the open <Dialog /> component, which would be a suboptimal user experience.
    useScrollLock 该钩子可防止您在组件打开时滚动页面。这在 <Dialog /> 组件中很有用,这样您就不会意外地将页面滚动到打开的 <Dialog /> 组件后面,这将是次优的用户体验。

Starting with the useOutsideClick hook, a very simple and naive implementation looks like this:
useOutsideClick 钩子开始,一个非常简单且幼稚的实现如下所示:

function useOutsideClick(elementRef: React.MutableRefObject<HTMLElement | null>, cb: () => void) {
  useEffect(() => {
    let element = elementRef.current
    if (!element) return

    function handle(e: MouseEvent) {
      if (!element.contains(e.target)) {
        cb()
      }
    }

    document.addEventListener('click', handle)

    return () => {
      document.removeEventListener('click', handle)
    }
  }, [elementRef, cb])
}

This hook can now be used in the <Dialog /> component like this:
现在可以在 <Dialog /> 组件中使用此钩子,如下所示:

function Dialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  let elementRef = useRef<HTMLElement | null>(null)

  useOutsideClick(elementRef, () => { 
    onClose() 
  }) 

  return isOpen ? <div ref={elementRef} role="dialog" /> : null
}

One issue with this approach is that the callback passed to the useOutsideClick hook will be called on every outside click, even when the <Dialog /> is closed.
这种方法的一个问题是,传递给 useOutsideClick 挂钩的回调将在每次外部单击时调用,即使 <Dialog /> 已关闭。

It's not the end of the world because we would try to close an already closed <Dialog />, but it could result in unnecessary re-renders. There is another catch, but we'll get to that later.
这不是世界末日,因为我们会尝试关闭已经关闭的 <Dialog /> ,但这可能会导致不必要的重新渲染。还有另一个问题,但我们稍后会讨论。

We can easily prevent calling the onClose function by checking whether the <Dialog /> is open or not.
我们可以通过检查 <Dialog /> 是否打开来轻松阻止调用 onClose 函数。

function Dialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  let elementRef = useRef<HTMLElement | null>(null)

  useOutsideClick(elementRef, () => {
    if (!isOpen) return

    onClose()
  })

  return isOpen ? <div ref={elementRef} role="dialog" /> : null
}

Now we won't call the onClose function when the <Dialog /> is closed. But the catch I mentioned earlier is that the useOutsideClick hook will still be active when the <Dialog /> is closed. Sure, we will end up with a no-op, but we are potentially wasting memory and doing more work than necessary because we are still setting up that event listener.
现在当 <Dialog /> 关闭时我们不会调用 onClose 函数。但我之前提到的问题是,当 <Dialog /> 关闭时, useOutsideClick 钩子仍然处于活动状态。当然,我们最终会出现空操作,但我们可能会浪费内存并做不必要的工作,因为我们仍在设置该事件侦听器。

If we briefly take a look at the useScrollLock hook, we have a bigger problem. A very simple implementation looks like this:
如果我们简单地看一下 useScrollLock 钩子,我们就会发现一个更大的问题。一个非常简单的实现如下所示:

function useScrollLock() {
  useEffect(() => {
    let previous = document.documentElement.style.overflow

    // Adding `overflow: hidden;` to the `<html>` element will prevent the page
    // from scrolling. At least, in desktop browsers.
    document.documentElement.style.overflow = 'hidden'

    return () => {
      document.documentElement.style.overflow = previous
    }
  }, [])
}

It doesn't really have a callback, so there is no obvious spot where we can check whether the <Dialog /> is open or not. The page is locked the moment you use the useScrollLock hook. Uh-oh.
它实际上没有回调,因此没有明显的地方可以检查 <Dialog /> 是否打开。当您使用 useScrollLock 钩子时,页面被锁定。呃哦。

The pattern 图案

The small pattern we use is fairly simple and straight forward, let's take a look. You're not ready for this.
我们使用的小模式相当简单直接,让我们看一下。你还没有准备好。

... what if hooks take an enabled value as the first argument. Crazy, I know.
...如果钩子将 enabled 值作为第一个参数怎么办?疯了,我知道。

function useOutsideClick(
  enabled: boolean, 
  elementRef: React.MutableRefObject<HTMLElement | null>,
  cb: () => void,
) {
  useEffect(() => {
    if (!enabled) return

    let element = elementRef.current
    if (!element) return

    function handle(e: MouseEvent) {
      if (!element.contains(e.target)) {
        cb()
      }
    }

    document.addEventListener('click', handle)

    return () => {
      document.removeEventListener('click', handle)
    }
  }, [enabled, elementRef, cb]) 
}
function useScrollLock(enabled: boolean) { 
  useEffect(() => {
    if (!enabled) return

    let previous = document.documentElement.style.overflow

    document.documentElement.style.overflow = 'hidden'

    return () => {
      document.documentElement.style.overflow = previous
    }
  }, [enabled]) 
}

Tadaa! 🎉 哒哒! 🎉

It's a very simple pattern, but it's also very powerful. In case of the useOutsideClick hook, we don't have to check inside the callback whether the <Dialog /> is open or closed anymore.
这是一个非常简单的模式,但也非常强大。对于 useOutsideClick 钩子,我们不必再在回调内部检查 <Dialog /> 是打开还是关闭。

But wait, isn't this the same as before? We still have to check the enabled value, right?
但是等等,这不是和以前一样吗?我们仍然需要检查 enabled 值,对吧?

While that's true, the check now happens in the useEffect hook as the very first thing. This means that we don't even setup the event listener, and in case of the useScrollLock hook, we don't lock the page if we don't need to.
虽然这是事实,但检查现在首先发生在 useEffect 挂钩中。这意味着我们甚至不设置事件侦听器,并且在 useScrollLock 挂钩的情况下,如果不需要,我们不会锁定页面。

This is how we use it in the <Dialog /> component:
这就是我们在 <Dialog /> 组件中使用它的方式:

function Dialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  let elementRef = useRef<HTMLElement | null>(null)

  useOutsideClick(isOpen, elementRef, () => { 
    onClose() 
  }) 
  useScrollLock(isOpen) 

  return isOpen ? <div ref={elementRef} role="dialog" /> : null
}

You might be wondering why we put the enabled boolean as the first argument instead of the last argument. If it's the last argument, then you can even use a default value.
您可能想知道为什么我们将 enabled 布尔值作为第一个参数而不是最后一个参数。如果它是最后一个参数,那么您甚至可以使用默认值。

While that is all true it's nothing more than a stylistic choice because in Headless UI we noticed two things that influenced this decision:
虽然这都是事实,但这只不过是一种风格选择,因为在 Headless UI 中我们注意到有两件事影响了这个决定:

  1. We always passed in a variable or a condition for the enabled value, so we would never use the default value.
    我们总是为 enabled 值传入一个变量或条件,因此我们永远不会使用默认值。
  2. Prettier formats the code in a different way when the enabled boolean is the first argument, which I personally preferred.
    enabled 布尔值是第一个参数时,Prettier 以不同的方式格式化代码,我个人更喜欢这种方式。

Here is a comparison:
这是一个比较:

useOutsideClick(enabled, elementRef, () => {
  onClose()
})

vs

useOutsideClick(
  elementRef,
  () => {
    onClose()
  },
  enabled,
)

or

useOutsideClick(
  () => {
    onClose()
  },
  elementRef,
  enabled,
)

Conclusion 结论

In conclusion, this pattern is probably well known, but I've seen enough people ask about how you can call hooks conditionally. While you still can't call hooks conditionally, this pattern will allow you to enable or disable the hook based on a condition which is typically enough for a lot of hooks.
总之,这种模式可能是众所周知的,但我已经看到足够多的人询问如何有条件地调用钩子。虽然您仍然无法有条件地调用挂钩,但此模式将允许您根据通常足以满足许多挂钩的条件来启用或禁用挂钩。

What do you think? Do you use a different pattern? Let me know on Twitter!
你怎么认为?你使用不同的模式吗?在 Twitter 上告诉我!

👨‍💻

Copyright © 2024 Robin Malfait. All rights reserved.
版权所有 © 2024 罗宾·马尔费特。版权所有。

Drag to outliner or Upload
Close 关闭