发布于 2026-01-06 2 阅读
0

Avoiding useEffect with callback refs DEV's Worldwide Show and Tell Challenge Presented by Mux: Pitch Your Projects!

避免使用回调引用调用 useEffect

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

注意:本文假设读者对React 中的refs有基本的了解。

尽管 ref 是可变容器,理论上可以存储任意值,但它们最常用于访问 DOM 节点:

const ref = React.useRef(null)

return <input ref={ref} defaultValue="Hello world" />
Enter fullscreen mode Exit fullscreen mode

ref是内置基本元素上的保留属性,React 会将渲染后的 DOM 节点存储在其中。组件卸载时,该属性会被重置为null 。

与裁判互动

大多数交互情况下,你不需要访问底层 DOM 节点,因为 React 会自动处理更新。一个可能需要用到 ref 的例子是焦点管理。

Devon Govett提出了一个很好的 RFC,建议将 FocusManagement 添加到 react-dom 中,但目前 React 中没有任何东西可以帮助我们做到这一点。

聚焦效果

那么,现在该如何让渲染后的输入元素获得焦点呢?(我知道有自动聚焦功能,这只是一个例子。如果你觉得这样不方便,可以想象一下你想给节点添加动画效果。)

我见过的绝大多数代码都试图做到这一点:

const ref = React.useRef(null)

React.useEffect(() => {
  ref.current?.focus()
}, [])

return <input ref={ref} defaultValue="Hello world" />
Enter fullscreen mode Exit fullscreen mode

这基本没问题,没有违反任何规则。空的依赖数组是可以接受的,因为里面只用到了 ref,而 ref 是稳定的。代码检查器不会因为将其添加到依赖数组而报错,而且在渲染过程中也不会读取 ref(这可能会对 React 的并发特性造成问题)。

该效果会在“挂载时”运行一次(严格模式下运行两次)。届时,React 已经将 DOM 节点填充到 ref 中,因此我们可以聚焦该节点。

然而,这并不是最好的方法,在某些更高级的情况下也存在一些问题。

具体来说,它假定在效果运行时 ref 已被“填充”。如果 ref 不可用,例如,因为你将 ref 传递给了一个自定义组件,该组件会延迟渲染或仅在用户进行其他交互后才显示输入框,那么在效果运行时ref 的内容仍然为null ,并且没有任何元素会获得焦点:

function App() {
  const ref = React.useRef(null)

  React.useEffect(() => {
    // 🚨 ref.current is always null when this runs
    ref.current?.focus()
  }, [])

  return <Form ref={ref} />
}

const Form = React.forwardRef((props, ref) => {
  const [show, setShow] = React.useState(false)

  return (
    <form>
      <button type="button" onClick={() => setShow(true)}>
        show
      </button>
      // 🧐 ref is attached to the input, but it's conditionally rendered
      // so it won't be filled when the above effect runs
      {show && <input ref={ref} />}
    </form>
  )
})
Enter fullscreen mode Exit fullscreen mode

事情经过是这样的:

  • 表单渲染。
  • 输入未渲染,ref仍为
  • 效果运行,但没有任何作用。
  • 输入框已显示,引用框将被填充,但不会获得焦点,因为效果不会再次运行。

问题在于,该效果“绑定”于表单的渲染函数,而我们实际上想要表达的是:“在输入框渲染时聚焦输入框”,而不是“在表单挂载时聚焦输入框”。

回调引用

这时回调引用就派上用场了。如果你看过引用的类型声明,就会发现我们不仅可以传递一个引用对象,还可以传递一个函数:

type Ref<T> = RefCallback<T> | RefObject<T> | null
Enter fullscreen mode Exit fullscreen mode

从概念上讲,我喜欢把 React 元素上的 refs 看作是组件渲染完成后调用的函数。这个函数会接收渲染后的 DOM 节点作为参数。如果 React 元素被卸载,它会再次被调用,并将参数设置为null

因此,将 ref 从useRef(一个 RefObject)传递给 React 元素,实际上只是以下代码的语法糖:

<input
  ref={(node) => {
    ref.current = node;
  }}
  defaultValue="Hello world"
/>
Enter fullscreen mode Exit fullscreen mode

我再强调一遍:

所有引用属性都只是函数!

这些函数是在渲染完成后运行的,此时执行副作用完全没问题。或许如果ref 函数在onAfterRender之类的事件中被调用会更好

有了这些知识,我们为什么不直接在回调引用中聚焦输入,在那里我们可以直接访问节点呢?

<input
  ref={(node) => {
    node?.focus()
  }}
  defaultValue="Hello world"
/>
Enter fullscreen mode Exit fullscreen mode

确实,一个很小的细节会影响结果:React 会在每次渲染后运行这个函数。所以,除非我们乐意让输入框如此频繁地获得焦点(我们可能并不乐意),否则我们必须告诉 React 只在我们需要的时候运行这个函数。

使用回调函数来救场

幸运的是,React 使用引用稳定性来检查回调函数的引用是否应该执行。这意味着如果我们传递相同的引用(此处双关),则会跳过执行。

这就是useCallback 的作用所在,因为它可以确保不会不必要地创建函数。也许这就是为什么它们被称为回调引用(callback-refs)——因为你必须一直用useCallback包裹它们。😂

最终解决方案如下:

const ref = React.useCallback((node) => {
  node?.focus()
}, [])

return <input ref={ref} defaultValue="Hello world" />
Enter fullscreen mode Exit fullscreen mode

与初始版本相比,此版本代码更少,并且仅使用一个钩子而不是两个。此外,它在所有情况下都能正常工作,因为回调引用绑定到 DOM 节点的生命周期,而不是挂载该节点的组件的生命周期。而且,在严格模式下(在开发环境中运行时),它不会执行两次,这对于许多用户来说似乎非常重要。

正如(旧版)React 文档中这个隐藏的宝藏所示,你可以用它来运行任何类型的副作用,例如在其中调用setState。我就把这个例子放在这里,因为它确实很不错:

function MeasureExample() {
  const [height, setHeight] = React.useState(0)

  const measuredRef = React.useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height)
    }
  }, [])

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

所以,如果您需要在 DOM 节点渲染后直接与其交互,请尽量不要直接使用useRef + useEffect,而应考虑使用回调 refs


今天就到这里。如有任何疑问,欢迎在推特上联系我
,或者在下方留言。⬇️

文章来源:https://dev.to/tkdodo/avoiding-useeffect-with-callback-refs-2462