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 中我们注意到有两件事影响了这个决定:
- We always passed in a variable or a condition for the
enabled
value, so we would never use the default value.
我们总是为enabled
值传入一个变量或条件,因此我们永远不会使用默认值。 - 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 上告诉我!