在开发过程中,我们经常需要直接与DOM元素进行交互。在这种情况下,React为我们提供了一种称为refs的机制,允许我们在元素渲染后访问这些元素。最常用的是通过useRef
(让我们称之为标准对象refs),但还有另一种称为callback refs的方法。这种方法提供了对元素生命周期更灵活的控制,使我们能够在元素附加到DOM或从DOM中分离时在特定时刻执行某些特定操作。
在本文中,我想解释一下callback refs是什么,它们是如何工作的,讨论您可能遇到的一些问题,并展示它们的使用示例。
什么是Callback Refs以及它们是如何工作的?
与对象refs相比,callback refs让您对ref附加具有更精细的控制。让我们看看它们在实践中是如何工作的:
- 挂载。当一个元素挂载到DOM中时,React会调用带有DOM元素本身的ref函数。这使您可以在元素出现在页面上后立即对其执行操作。
- 卸载。当一个元素卸载时,React会使用
null
调用ref函数。这为您提供了清理或取消与该元素相关的任何操作的机会。
示例:跟踪挂载和卸载
import React, { useCallback, useState } from 'react';
function MountUnmountTracker() {
const [isVisible, setIsVisible] = useState(false);
const handleRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
console.log('Element mounted:', node);
} else {
console.log('Element unmounted');
}
}, []);
return (
<div>
<button onClick={() => setIsVisible((prev) => !prev)}>
{isVisible ? 'Hide' : 'Show'} element
</button>
{isVisible && <div ref={handleRef}>Tracked element</div>}
</div>
);
}
export default MountUnmountTracker;
每次切换元素的可见性,handleRef
函数将被调用,传入节点或null
,使我们能够跟踪元素附加或分离的时刻。
常见问题和解决方案
问题:重复的回调 Ref 调用
在使用回调 Ref 时经常遇到的问题是,在组件重新渲染时反复创建 Ref 函数。因此,React 认为这是一个新的 Ref,调用旧的 Ref 传入null
(清理它),然后初始化新的 Ref — 即使我们的元素或组件实际上并未改变。这可能导致意外的副作用。
问题示例
考虑一个Basic
组件,其中有一个用于切换div
可见性的按钮,带有一个回调 Ref,另一个按钮用于强制组件重新渲染:
import React, { useState, useReducer } from 'react';
function Basic() {
const [showDiv, setShowDiv] = useState(false);
const [, forceRerender] = useReducer((v) => v + 1, 0);
const toggleDiv = () => setShowDiv((prev) => !prev);
const refCallback = (node: HTMLDivElement | null) => {
console.log('div', node);
};
return (
<div>
<button onClick={toggleDiv}>Toggle Div</button>
<button onClick={forceRerender}>Rerender</button>
{showDiv && <div ref={refCallback}>Example div</div>}
</div>
);
}
export default Basic;
每次单击重新渲染按钮时,组件会重新渲染,创建一个新的refCallback
函数。结果,React 调用旧的refCallback(null)
然后调用新的refCallback(node)
,即使具有 Ref 的元素并未更改。在控制台中,你会看到依次出现div null
和div [node]
。显然,我们通常希望避免这种不必要的调用。
解决方案:使用 useCallback 对回调 Ref 进行记忆
避免这种情况非常简单:只需使用useCallback
对函数进行记忆。这样,函数会跨重新渲染保持不变,除非其依赖项发生变化。
import React, { useState, useCallback, useReducer } from 'react';
function Basic() {
const [showDiv, setShowDiv] = useState(false);
const [, forceRerender] = useReducer((v) => v + 1, 0);
const toggleDiv = () => setShowDiv((prev) => !prev);
const refCallback = useCallback((node: HTMLDivElement | null) => {
console.log('div', node);
}, []);
return (
<div>
<button onClick={toggleDiv}>Toggle Div</button>
<button onClick={forceRerender}>Rerender</button>
{showDiv && <div ref={refCallback}>Example div</div>}
</div>
);
}
export default Basic;
现在,refCallback
只在初始渲染时创建一次。在后续重新渲染时不会触发额外的调用,从而避免不必要的回调,提高性能。
回调 Ref、useLayoutEffect 和 useEffect 的顺序
在我们讨论如何在代码中使用回调 Ref 来解决特定问题之前,让我们了解回调 Ref 如何与 useEffect
和 useLayoutEffect
钩子交互,以便您可以正确地组织资源的初始化和清理。
执行顺序
- 回调 Ref – 在渲染 DOM 元素后立即调用,在 effect 钩子运行之前
- useLayoutEffect – 在所有 DOM 变化之后运行,但在浏览器绘制之前之前
- useEffect – 在组件完成对屏幕的渲染后运行
import React, { useEffect, useLayoutEffect, useCallback } from 'react';
function WhenCalled() {
const refCallback = useCallback((node: HTMLDivElement | null) => {
if (node) {
console.log('Callback ref called for div:', node);
} else {
console.log('Callback ref detached div');
}
}, []);
useLayoutEffect(() => {
console.log('useLayoutEffect called');
}, []);
useEffect(() => {
console.log('useEffect called');
}, []);
return (
<div>
<div ref={refCallback}>Element to watch</div>
</div>
);
}
export default WhenCalled;
控制台输出
为 div 调用回调 Ref: [div 元素]
调用 useLayoutEffect
调用 useEffect
这个顺序告诉我们,回调 Ref 在像useLayoutEffect
和 useEffect
这样的钩子之前被触发,这在编写逻辑时是必须牢记的。
回调 Ref 在代码中解决了哪些问题?
首先,让我们重现通常使用常规对象 Ref 时遇到的问题,以便然后使用回调 Ref 来解决它。
import { useCallback, useEffect, useRef, useState } from 'react';
interface ResizeObserverOptions {
elemRef: React.RefObject<HTMLElement>;
onResize: ResizeObserverCallback;
}
function useResizeObserver({ elemRef, onResize }: ResizeObserverOptions) {
useEffect(() => {
const element = elemRef.current;
if (!element) {
return;
}
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(element);
return () => {
resizeObserver.unobserve(element);
};
}, [onResize, elemRef]);
}
export function UsageDom() {
const [bool, setBool] = useState(false);
const elemRef = useRef<HTMLDivElement>(null);
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
console.log('resize', entries);
}, []);
useResizeObserver({ elemRef, onResize: handleResize });
const renderTestText = () => {
if (bool) {
return <p ref={elemRef}>Test text</p>;
}
return <div ref={elemRef}>Test div</div>;
};
return (
<div style={{ width: '100%', textAlign: 'center' }}>
<button onClick={() => setBool((v) => !v)}>Toggle</button>
{renderTestText()}
</div>
);
}
我们在这里不会深入讨论每个细节。简而言之,我们通过ResizeObserver
跟踪我们的div
或p
元素的大小。最初,一切都运行正常:在挂载时,我们可以获取元素的大小,并且调整大小也会在控制台中报告。
真正的麻烦是当我们切换要观察的元素时切换状态。当我们改变状态并因此替换被跟踪的元素时,我们的ResizeObserver
就无法正常工作了。它仍然会观察第一个元素,而这个元素已经从DOM中移除了!即使切换回原始元素也没有帮助,因为对新元素的订阅从未正确地附加。
注意:以下解决方案更具代表性,适用于在库上下文中编写的代码,需要通用代码。在实际项目中,您可能会通过组合标志、效果等来解决这个问题。但在库代码中,您无法了解特定组件或其状态。这正是回调引用可以帮助我们的场景。
import { useCallback, useRef, useState } from 'react';
function useResizeObserver(onResize: ResizeObserverCallback) {
const roRef = useRef<ResizeObserver | null>(null);
const attachResizeObserver = useCallback(
(element: HTMLElement) => {
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(element);
roRef.current = resizeObserver;
},
[onResize]
);
const detachResizeObserver = useCallback(() => {
roRef.current?.disconnect();
}, []);
const refCb = useCallback(
(element: HTMLElement | null) => {
if (element) {
attachResizeObserver(element);
} else {
detachResizeObserver();
}
},
[attachResizeObserver, detachResizeObserver]
);
return refCb;
}
export default function App() {
const [bool, setBool] = useState(false);
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
console.log('resize', entries);
}, []);
const resizeRef = useResizeObserver(handleResize);
const renderTestText = () => {
if (bool) {
return <p ref={resizeRef}>Test text</p>;
}
return <div ref={resizeRef}>Test div</div>;
};
return (
<div style={{ width: '100%', textAlign: 'center' }}>
<button onClick={() => setBool((v) => !v)}>Toggle</button>
{renderTestText()}
</div>
);
}
正如您所看到的,我们重写了我们的useResizeObserver
钩子以使用回调引用。我们只需传入一个(记忆化的)回调,在调整大小时应触发该回调,无论我们切换多少次元素,我们的调整大小回调仍然有效。这是因为观察者在我们希望的确切时间附加到新元素并从旧元素分离,这要归功于回调引用。
这里的关键优势在于使用我们的钩子的开发人员不再需要担心在幕后添加/移除观察者的逻辑 — 我们已经将该逻辑封装在钩子内。开发人员只需将回调传递给我们的钩子,并将其返回的 ref 附加到他们的元素上。
将多个 Ref 合并为一个
以下是另一个场景,回调 Ref 再次发挥作用:
import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';
interface InputProps {
value?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const Input = forwardRef(function Input(
props: InputProps,
ref: React.ForwardedRef<HTMLInputElement>
) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!inputRef.current) {
return;
}
console.log(inputRef.current.getBoundingClientRect());
}, []);
return <input {...props} ref={ref} />;
});
export function UsageWithoutCombine() {
const inputRef = useRef<HTMLInputElement | null>(null);
const focus = () => {
inputRef.current?.focus();
};
return (
<div>
<Input ref={inputRef} />
<button onClick={focus}>Focus</button>
</div>
);
}
在上面的代码中,我们有一个简单的输入组件,在其中我们设置了一个 ref,通过使用 forwardRef
从 props 中获取它。但如果我们还需要来自外部的 ref,例如用于聚焦,该如何在 Input
组件内部使用 inputRef
?也许我们还想在输入组件本身做一些其他事情,比如 getBoundingClientRect
。用我们内部的 ref 替换 prop ref 意味着来自外部的聚焦将不再起作用。那么,如何将这两个 ref 结合起来呢?
这就是回调 ref 再次发挥作用的地方:
import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';
type RefItem<T> =
| ((element: T | null) => void)
| React.MutableRefObject<T | null>
| null
| undefined;
function useCombinedRef<T>(...refs: RefItem<T>[]) {
const refCb = useCallback((element: T | null) => {
refs.forEach((ref) => {
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(element);
} else {
ref.current = element;
}
});
}, refs);
return refCb;
}
interface InputProps {
value?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const Input = forwardRef(function Input(
props: InputProps,
ref: React.ForwardedRef<HTMLInputElement>
) {
const inputRef = useRef<HTMLInputElement>(null);
const combinedInputRef = useCombinedRef(ref, inputRef);
useEffect(() => {
if (!inputRef.current) {
return;
}
console.log(inputRef.current.getBoundingClientRect());
}, []);
return <input {...props} ref={combinedInputRef} />;
});
export function UsageWithCombine() {
const inputRef = useRef<HTMLInputElement | null>(null);
const focus = () => {
inputRef.current?.focus();
};
return (
<div>
<Input ref={inputRef} />
<button onClick={focus}>Focus</button>
</div>
);
}
解释
我们实现了一个 useCombinedRef
钩子,开发人员可以传入标准 ref、回调 ref,以及可选的 null
或 undefined
。该钩子本身只是一个循环遍历 refs 数组的 useCallback
。如果参数是 null
,我们会忽略它,但如果 ref 是一个函数,我们会将其与元素一起调用;如果是标准 ref 对象,我们会将 ref.current
设置为该元素。
这样,我们将多个 refs 合并为一个。在上面的示例中,Input
组件内部的 getBoundingClientRect
和外部的 focus 调用都将正常工作。
React 19 中回调 refs 有什么改变?
自动清理
React 现在在元素卸载时自动处理回调 refs 的清理工作,使资源管理更加简单。以下是 React 团队在他们的文档中展示的一个示例:
{
// 创建 ref
// 新特性:返回一个清理函数以重置
// 在元素从 DOM 中移除时清理 ref。
return () => {
// 清理 ref
};
}}
/>
您可以在官方博客文章中阅读更多详情,在其中提到,不久将会废弃通过 null
清理 refs 的方法,留下一个统一的清理 refs 方法。
在普通 refs 和回调 refs 之间进行选择
- 当您只需要简单访问 DOM 元素或想在渲染之间保留一些值而不需要在附加或分离时执行其他操作时,请使用标准 refs(
useRef
)。 - 当您需要更精细地控制元素的生命周期、当您正在编写通用代码(例如,您自己的库或包)或者需要一起管理多个 refs 时,请使用回调 refs。
结论
在React中,回调引用是一种强大的工具,为开发人员在处理DOM元素时提供额外的灵活性和控制。在大多数情况下,通过useRef
使用标准对象引用已经足够应付日常任务,但是在处理上述讨论的更复杂情况时,回调引用可以提供帮助。
Source:
https://dzone.com/articles/react-callback-refs-guide