避免使用回调引用调用 useEffect
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
注意:本文假设读者对React 中的refs有基本的了解。
尽管 ref 是可变容器,理论上可以存储任意值,但它们最常用于访问 DOM 节点:
const ref = React.useRef(null)
return <input ref={ref} defaultValue="Hello world" />
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" />
这基本没问题,没有违反任何规则。空的依赖数组是可以接受的,因为里面只用到了 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>
)
})
事情经过是这样的:
- 表单渲染。
- 输入未渲染,ref仍为空。
- 效果运行,但没有任何作用。
- 输入框已显示,引用框将被填充,但不会获得焦点,因为效果不会再次运行。
问题在于,该效果“绑定”于表单的渲染函数,而我们实际上想要表达的是:“在输入框渲染时聚焦输入框”,而不是“在表单挂载时聚焦输入框”。
回调引用
这时回调引用就派上用场了。如果你看过引用的类型声明,就会发现我们不仅可以传递一个引用对象,还可以传递一个函数:
type Ref<T> = RefCallback<T> | RefObject<T> | null
从概念上讲,我喜欢把 React 元素上的 refs 看作是组件渲染完成后调用的函数。这个函数会接收渲染后的 DOM 节点作为参数。如果 React 元素被卸载,它会再次被调用,并将参数设置为null。
因此,将 ref 从useRef(一个 RefObject)传递给 React 元素,实际上只是以下代码的语法糖:
<input
ref={(node) => {
ref.current = node;
}}
defaultValue="Hello world"
/>
我再强调一遍:
所有引用属性都只是函数!
这些函数是在渲染完成后运行的,此时执行副作用完全没问题。或许如果ref 函数在onAfterRender之类的事件中被调用会更好。
有了这些知识,我们为什么不直接在回调引用中聚焦输入,在那里我们可以直接访问节点呢?
<input
ref={(node) => {
node?.focus()
}}
defaultValue="Hello world"
/>
确实,一个很小的细节会影响结果:React 会在每次渲染后运行这个函数。所以,除非我们乐意让输入框如此频繁地获得焦点(我们可能并不乐意),否则我们必须告诉 React 只在我们需要的时候运行这个函数。
使用回调函数来救场
幸运的是,React 使用引用稳定性来检查回调函数的引用是否应该执行。这意味着如果我们传递相同的引用(此处双关),则会跳过执行。
这就是useCallback 的作用所在,因为它可以确保不会不必要地创建函数。也许这就是为什么它们被称为回调引用(callback-refs)——因为你必须一直用useCallback包裹它们。😂
最终解决方案如下:
const ref = React.useCallback((node) => {
node?.focus()
}, [])
return <input ref={ref} defaultValue="Hello world" />
与初始版本相比,此版本代码更少,并且仅使用一个钩子而不是两个。此外,它在所有情况下都能正常工作,因为回调引用绑定到 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>
</>
)
}
所以,如果您需要在 DOM 节点渲染后直接与其交互,请尽量不要直接使用useRef + useEffect,而应考虑使用回调 refs。
今天就到这里。如有任何疑问,欢迎在推特上联系我
,或者在下方留言。⬇️