WinddSnow

React-useRef-DOM-Operations-forwardRef-useImperativeHandle

字数统计: 4.2k阅读时长: 17 min
2026/08/01

第92课:useRef 与 DOM 操作——ref 对象、forwardRefuseImperativeHandle

在 React 的声明式编程模型中,绝大多数场景下你不需要直接操作 DOM——React 通过 State 和 Props 自动保持 UI 与数据同步。但总有例外:聚焦输入框、测量元素尺寸、与第三方非 React 库集成、存储不参与渲染的变量……这些场景需要一个脱离 React 渲染周期的逃生舱。useRef 就是这个逃生舱。它创建的是一个普通的 JavaScript 对象,其 current 属性可以在组件的整个生命周期中持续存在,且修改 current 不会触发重新渲染。结合 forwardRef(将 ref 传递给子组件)和 useImperativeHandle(限制子组件暴露给父组件的方法),你可以在保持声明式架构的同时,安全地跨越虚拟 DOM 直接与底层 DOM 或组件实例交互。


1. useRef 的基本概念与两大用途

useRef 返回一个可变的对象,该对象在整个组件生命周期中保持相同的引用。它的 current 属性既可以存储任意值,也可以用来关联 DOM 元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useRef } from 'react';

function TextInputWithFocus() {
const inputRef = useRef(null);

function handleFocus() {
// 通过 ref.current 访问真实 DOM 元素
inputRef.current.focus();
}

return (
<div>
<input ref={inputRef} type="text" />
<button onClick={handleFocus}>聚焦输入框</button>
</div>
);
}

useRefuseState 的核心区别

特性 useRef useState
返回值 一个 { current: initialValue } 的普通对象。 一个状态值和一个更新函数。
是否触发重新渲染 修改 current 不触发重新渲染 调用更新函数会触发重新渲染
值的变化时机 直接修改 current,变化立即可见。 更新函数被调用后,在下一次渲染时才能获取到新值。
典型用途 存储 DOM 引用、保存不需要触发渲染的可变值。 存储驱动 UI 变化的数据。

1.1 用途一:绑定 DOM 元素

useRef 返回的对象传递给 JSX 元素的 ref 属性,React 会在组件挂载后将该元素的 DOM 节点赋值给 ref.current,在组件卸载前ref.current 重置为 null

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function VideoPlayer() {
const videoRef = useRef(null);

function handlePlay() {
videoRef.current.play();
}

function handlePause() {
videoRef.current.pause();
}

return (
<div>
<video ref={videoRef} src="movie.mp4" />
<button onClick={handlePlay}>播放</button>
<button onClick={handlePause}>暂停</button>
</div>
);
}

访问时机ref.current首次渲染时为 null(因为 DOM 尚未生成)。它仅在 useEffect 或事件处理函数中才指向有效的 DOM 节点。在 JSX 中直接读取 ref.current(如在 <p>{ref.current?.textContent}</p> 中)是无效的——渲染时 ref 还未赋值,且 React 不会因为 ref 变化而重新渲染。

1.2 用途二:存储不需要渲染的可变值

useRef 非常适合保存不会直接影响 UI 的变量,比如定时器 ID、请求标识、前一次渲染的值等。由于修改 current 不触发渲染,你可以在回调或效果中自由更新它而无需担心不必要的渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function Timer() {
const intervalRef = useRef(null);
const [seconds, setSeconds] = useState(0);

function start() {
if (intervalRef.current !== null) return; // 防止重复启动
intervalRef.current = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
}

function stop() {
clearInterval(intervalRef.current);
intervalRef.current = null;
}

// 组件卸载时清除定时器
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);

return (
<div>
<p>Seconds: {seconds}</p>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</div>
);
}

这里 intervalRef 存储定时器 ID。由于定时器 ID 的变化不需要导致 UI 更新,使用 useRef 而非 useState 是最佳选择。

1.3 回调 Ref(Callback Ref)

除了传递 useRef 对象给 ref 属性,你还可以传递一个函数。这个函数会在 React 将 DOM 节点附加到元素时调用(参数为 DOM 节点),在节点被移除时再次调用(参数为 null)。回调 ref 比对象 ref 更灵活,尤其适合需要在 ref 附加/移除时执行逻辑的场景(如动态测量元素尺寸)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function MeasureExample() {
const [height, setHeight] = useState(0);

const measuredRef = useCallback(node => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []); // 空依赖:仅在 mount/update 时测量

return (
<div>
<div ref={measuredRef}>这段文字的高度被测量</div>
<p>高度:{height}px</p>
</div>
);
}

2. forwardRef:将 ref 传递给子组件

默认情况下,React 组件的 ref 属性只对原生 HTML 元素有效,不能直接传递给自定义组件。如果你尝试 <MyInput ref={inputRef} />,React 会抛出警告——因为自定义组件默认没有 ref 这个 prop。

forwardRef 允许组件将接收到的 ref 转发给其内部的某个 DOM 元素或子组件。它接收一个渲染函数作为参数,该函数的第二个参数就是 ref

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { forwardRef, useRef } from 'react';

// 定义一个可接受 ref 的输入框组件
const MyInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});

function Parent() {
const inputRef = useRef(null);

function handleFocus() {
inputRef.current.focus();
}

return (
<div>
<MyInput ref={inputRef} placeholder="输入内容" />
<button onClick={handleFocus}>聚焦</button>
</div>
);
}

forwardRef 的渲染函数签名(props, ref) => ReactElement。第二个参数 ref 是父组件传递下来的 ref。你可以将它绑定到内部的 DOM 元素上,也可以传递给另一个使用了 forwardRef 的子组件。

2.1 在 forwardRef 中使用 useImperativeHandle

有时你不希望父组件完全控制子组件内部的 DOM 元素——你只想暴露一组有限的方法(如 focusscrollToTop)。useImperativeHandle 允许你自定义暴露给父组件的 ref 值,通常配合 forwardRef 使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { forwardRef, useRef, useImperativeHandle } from 'react';

const FancyInput = forwardRef((props, ref) => {
const inputRef = useRef(null);

useImperativeHandle(ref, () => ({
// 仅暴露 focus 和 blur 方法,不暴露整个 input DOM
focus: () => {
console.log('Custom focus logic');
inputRef.current.focus();
},
blur: () => {
inputRef.current.blur();
},
// 也可以暴露自定义方法
selectAll: () => {
inputRef.current.select();
}
}));

return <input ref={inputRef} {...props} />;
});

function Parent() {
const fancyRef = useRef(null);

return (
<div>
<FancyInput ref={fancyRef} />
<button onClick={() => fancyRef.current.focus()}>聚焦</button>
<button onClick={() => fancyRef.current.selectAll()}>全选</button>
</div>
);
}

useImperativeHandle(ref, createHandle, [deps]) 接收三个参数:

  • ref:父组件传递的 ref。
  • createHandle:一个函数,返回你想要暴露给父组件的对象(即 ref.current 的值)。
  • deps:依赖数组。当依赖变化时,createHandle 会重新执行并更新暴露的对象。

使用场景:当你封装了一个复杂的组件,希望对外隐藏内部 DOM 结构,只暴露必要的命令式方法。这是面向对象设计中“封装”理念在 React 组件中的体现。


3. useRefuseEffect 的协作模式

useRef 常与 useEffect 结合使用,解决“需要读取最新值但不想重新执行效果”的场景(闭包陷阱的解决方案之一)。

3.1 跟踪前一次的 Props 或 State

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // 效果执行后更新 ref
}, [value]);
return ref.current; // 返回上一次的值
}

function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);

return (
<div>
<p>Current: {count}, Previous: {prevCount}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}

3.2 在异步回调中访问最新 State

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function LatestValue() {
const [text, setText] = useState('');
const textRef = useRef(text);

useEffect(() => {
textRef.current = text; // 始终保持同步
}, [text]);

function handleSubmit() {
setTimeout(() => {
// 即使 setTimeout 在 text 变化之前被调用,也能读取最新值
alert('提交的值:' + textRef.current);
}, 3000);
}

return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={handleSubmit}>提交</button>
</div>
);
}

4. 最佳实践与常见陷阱

4.1 不要在渲染期间读写 ref.current

ref.current 的修改不会触发渲染,因此如果在 JSX 中直接使用 ref.current,UI 不会自动更新。如果需要根据 ref 的值驱动 UI 变化,应该使用 State,并在效果或事件中同步 ref 到 state。

4.2 避免过度使用 forwardRef + useImperativeHandle

这种模式适用于必须封装命令式 API 的场景(如输入框的 focus、视频播放器的 play/pause)。大多数情况下,通过 Props 和 State 进行声明式交互是更好的选择。如果滥用,会导致组件间耦合增加,降低可维护性。

4.3 使用 TypeScript 为 ref 提供类型

1
2
3
4
5
const inputRef = useRef<HTMLInputElement>(null);

function Component() {
return <input ref={inputRef} />;
}

对于通过 forwardRef 暴露的 ref,需要定义 ref 的类型:

1
2
3
4
5
6
7
8
type FancyInputHandle = {
focus: () => void;
selectAll: () => void;
};

const FancyInput = forwardRef<FancyInputHandle, {}>((props, ref) => {
// ...
});

4.4 避免在多个效果中依赖 ref 的值

如果多个 useEffect 都需要读取某个 State 的最新值,考虑使用 useRef 存储一个“最新值”,但要注意保持一致性——最好将更新 ref 的 useEffect 放在单一位置,避免多处更新导致竞态。


5. 综合示例:可滚动到底部的聊天组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';

// 子组件:消息列表容器
const MessageList = forwardRef(({ messages }, ref) => {
const listRef = useRef(null);

useImperativeHandle(ref, () => ({
scrollToBottom() {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}));

return (
<div
ref={listRef}
style={{ height: '300px', overflow: 'auto', border: '1px solid #ccc', padding: '8px' }}
>
{messages.map((msg, idx) => (
<div key={idx} style={{ marginBottom: '8px' }}>
<strong>{msg.user}:</strong> {msg.text}
</div>
))}
</div>
);
});

function ChatRoom() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const listRef = useRef(null); // 引用 MessageList 暴露的 scrollToBottom

function handleSend() {
if (input.trim() === '') return;
setMessages(prev => [...prev, { user: 'Me', text: input }]);
setInput('');
}

// 每当消息更新,自动滚到底部
useEffect(() => {
listRef.current?.scrollToBottom();
}, [messages]);

return (
<div>
<MessageList ref={listRef} messages={messages} />
<div style={{ marginTop: '12px' }}>
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="输入消息..."
/>
<button onClick={handleSend}>发送</button>
</div>
</div>
);
}

设计要点

  • MessageList 通过 forwardRefuseImperativeHandle 暴露 scrollToBottom 方法,隐藏了内部 listRef 的 DOM 细节。
  • 父组件通过 listRef.current?.scrollToBottom() 在消息更新后滚动到底部。
  • 输入框通过 useState 受控,发送后清空。

课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) useRef 返回的对象在组件整个生命周期中有什么特性?
    A. 每次渲染都会生成一个新的对象。
    B. 同一个对象引用,修改 current 会触发重新渲染。
    C. 同一个对象引用,修改 current 不会触发重新渲染。
    D. 仅用于保存 DOM 引用,不能存储其他值。

  2. (单选) useImperativeHandle 的主要作用是什么?
    A. 将子组件的 DOM 节点直接暴露给父组件。
    B. 自定义暴露给父组件的 ref 值,通常用于限制父组件可调用的方法。
    C. 将 props 转发给子组件。
    D. 在组件间共享状态。

  3. (填空) 要将 ref 从父组件传递给子组件内部的 DOM 元素,需要使用 ______ 包裹子组件。

  4. (多选) 以下哪些场景适合使用 useRef 而非 useState
    A. 存储定时器 ID。
    B. 存储触发 UI 更新的表单输入值。
    C. 获取输入框 DOM 元素以实现焦点控制。
    D. 在异步回调中读取最新的 State 值而不触发重新渲染。

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要实现一个倒计时组件,具备以下功能:

  • 使用 Props 接收初始秒数(initialSeconds)和倒计时结束后触发的回调(onComplete)。
  • 内部使用 useRef 存储定时器 ID,使用 useState 存储剩余秒数。
  • 提供“开始”、“暂停”、“重置”三个按钮。
  • 倒计时到 0 时自动停止,并调用 onComplete
  • 在组件卸载时清除定时器,防止内存泄漏。
  • 使用 useRef 确保在回调中能访问最新的 onComplete 回调(因为 onComplete 可能在倒计时过程中被替换)。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 React 组件代码。提示词中需明确指定 useRef 的用途、清除定时器的方式以及如何避免闭包陷阱。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请创建一个 React 倒计时组件。需要创建文件 src/components/CountdownTimer.jsx

  • Props: initialSeconds: number, onComplete?: () => void
  • 使用 useState 管理 remaining(初始值为 initialSeconds)和 isRunning(初始 false)。
  • 使用 useRef 存储定时器 ID(timerRef)和最新的 onComplete 回调(onCompleteRef)。
  • 使用 useEffect 同步更新 onCompleteRef.current = onComplete
  • start() 函数:如果 remaining > 0 && !isRunning,设置 isRunningtrue,并启动一个 setInterval(1000ms),将定时器 ID 存入 timerRef。定时器回调中使用函数式更新 setRemaining(prev => { if (prev <= 1) { clearInterval(timerRef.current); onCompleteRef.current?.(); return 0; } return prev - 1; })
  • pause() 函数:clearInterval(timerRef.current)isRunning 设为 false
  • reset() 函数:clearInterval(timerRef.current)remaining 重置为 initialSecondsisRunning 设为 false
  • 返回 JSX:显示剩余秒数,以及三个按钮“开始”、“暂停”、“重置”(根据 isRunning 状态切换按钮禁用)。
  • 使用 useEffect 清理函数 return () => clearInterval(timerRef.current)
  • 添加 JSDoc 注释,确保代码可直接运行。完成后输出完整文件内容。

四、面试真题与参考答案

题目(阿里巴巴前端面试题):

请解释 React 中 useRef 的两大用途,并与 useState 对比。在什么情况下应该使用 useRef 而不是 useStateforwardRefuseImperativeHandle 的配合使用解决了什么实际问题?请给出一个具体的场景。

参考答案

useRef 的两大用途:一是作为 DOM 元素的引用,通过 ref 属性绑定到 JSX 元素,获取真实 DOM 节点;二是保存任意不需要触发重新渲染的可变值,如定时器 ID、上一次的 State/Props 等。

useState 的对比:useState 管理的是驱动 UI 渲染的状态,更新时会触发组件重新渲染;useRef 修改 current 不会触发渲染,适合存储不影响 UI 的变量或需要跨渲染保持同一引用的值。

应使用 useRef 的场景:存储定时器 ID、存储 DOM 引用、缓存不需要触发渲染的计算结果、在回调中访问最新 State/Props 但不希望将该变量加入依赖数组。

forwardRefuseImperativeHandle 配合解决了组件封装命令式 API 暴露之间的矛盾。典型场景:一个自定义的输入框组件(FancyInput),父组件需要对其执行 focus() 操作。如果直接暴露内部 <input> 的 ref,父组件将能够任意操作该 DOM 元素,破坏了封装性。通过 forwardRef 接收父组件的 ref,再用 useImperativeHandle 自定义暴露一个仅包含 focus()blur() 的对象,父组件只能调用这些有限的、经过封装的方法,而不能直接访问内部 DOM。这既满足了必要的命令式控制,又保持了组件的封装性。


课后练习答案

一、概念自测答案

  1. C

    • 解析:useRef 返回同一个对象引用,修改 current 不触发渲染。A 错误,每次渲染返回同一对象;B 错误,修改 current 不触发渲染;D 错误,可存储任意值。
  2. B

    • 解析:useImperativeHandle 自定义暴露给父组件的 ref 值,限制父组件可访问的方法。A 是 forwardRef 直接转发 DOM 节点的行为;C 描述的是 forwardRef 本身;D 无关。
  3. forwardRef

    • 解析:forwardRef 用于将 ref 从父组件转发给子组件内部的 DOM 元素或另一个组件。
  4. A、C、D

    • 解析:B 错误,表单输入值需要驱动 UI 更新,应使用 useState。A、C、D 均适合 useRef

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请用 React 实现一个倒计时组件。要求:

  • Props: initialSeconds: number, onComplete?: () => void
  • 使用 useRef 存储定时器 ID 和最新的 onComplete 回调。
  • ‘开始’启动 setInterval,‘暂停’清除定时器,‘重置’恢复初始值并停止。
  • 倒计时到 0 时调用 onComplete 并停止。
  • 组件卸载时清除定时器。
  • 输出完整组件代码,使用 TypeScript。”
CATALOG
  1. 1. 第92课:useRef 与 DOM 操作——ref 对象、forwardRef、useImperativeHandle
    1. 1.1. 1. useRef 的基本概念与两大用途
      1. 1.1.1. 1.1 用途一:绑定 DOM 元素
      2. 1.1.2. 1.2 用途二:存储不需要渲染的可变值
      3. 1.1.3. 1.3 回调 Ref(Callback Ref)
    2. 1.2. 2. forwardRef:将 ref 传递给子组件
      1. 1.2.1. 2.1 在 forwardRef 中使用 useImperativeHandle
    3. 1.3. 3. useRef 与 useEffect 的协作模式
      1. 1.3.1. 3.1 跟踪前一次的 Props 或 State
      2. 1.3.2. 3.2 在异步回调中访问最新 State
    4. 1.4. 4. 最佳实践与常见陷阱
      1. 1.4.1. 4.1 不要在渲染期间读写 ref.current
      2. 1.4.2. 4.2 避免过度使用 forwardRef + useImperativeHandle
      3. 1.4.3. 4.3 使用 TypeScript 为 ref 提供类型
      4. 1.4.4. 4.4 避免在多个效果中依赖 ref 的值
    5. 1.5. 5. 综合示例:可滚动到底部的聊天组件
    6. 1.6. 课后练习
      1. 1.6.1. 一、概念自测(选择题 / 填空题)
      2. 1.6.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.6.3. 三、Agent 模式下的提示词示例
      4. 1.6.4. 四、面试真题与参考答案
    7. 1.7. 课后练习答案
      1. 1.7.1. 一、概念自测答案
      2. 1.7.2. 二、AI 编程任务参考答案(提示词示例)