React: useRef 与 DOM 操作
最后更新:2026-08-26
useRef就像一个 保险箱——你可以往里面存任何东西,取出时它还在。和useState不同,改变ref的不会触发组件重新渲染。
1. 你将学到
- useRef 的基础用法(DOM 引用)
- useRef 存储可变值(不触发重渲染)
- forwardRef 传递 ref 给子组件
- useImperativeHandle 暴露子组件方法
- useRef vs useState 的选择
2. 一个搜索输入框的故事
useRef 的两大用途
flowchart LR
subgraph "用途1: DOM引用"
A[useRef] --> B["ref={inputRef}"]
B --> C["inputRef.current.focus()"]
end
subgraph "用途2: 可变值存储"
D[useRef] --> E[".current = value]
E --> F[不触发渲染]
end
G[forwardRef] --> H[父组件传ref]
H --> I[子组件暴露DOM]
style A fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style G fill:#fff3e0,stroke:#e65100
(1) 痛点:输入框聚焦需要操作 DOM
Bob 做了一个搜索页面,想让页面加载时自动聚焦到搜索框:
JSX
function SearchPage() {
// ❌ 问题:没有直接方式聚焦输入框
return (
<div>
<input type="text" placeholder="搜索..." />
{/* 如何让这个输入框自动聚焦? */}
{/* 不能用 document.getElementById,React 不推荐直接操作 DOM */}
</div>
)
}
Bob 想用 useState 来实现:
JSX
// ❌ 错误:useState 的更新触发重渲染,但不会操作 DOM
function SearchPage() {
const [input, setInput] = React.useState(null)
React.useEffect(() => {
// 希望 input 是 DOM 元素...
// 但怎么把 DOM 元素存到 state 里?
}, [])
return <input ref={el => setInput(el)} /> // 函数 ref,语法奇怪
}
(2) useRef 的解法
JSX
import { useRef, useEffect } from 'react'
function SearchPage() {
const inputRef = useRef(null) // 创建 ref
useEffect(() => {
// ✅ 组件挂载后自动聚焦
inputRef.current.focus()
}, [])
return <input ref={inputRef} type="text" placeholder="自动聚焦的搜索框..." />
}
收益:useRef 返回一个可变对象 { current: null },.current 就是真正的 DOM 元素。不需要选择器、不需要 getElementById,天然 React 友好。
3. useRef 的两个核心用途
| 用途 | ref.current 指向 | 修改触发渲染 | 典型场景 |
|---|---|---|---|
| DOM 引用 | DOM 元素 | ❌ | 聚焦、滚动、测量尺寸 |
| 可变值存储 | 任意 JavaScript 值 | ❌ | 定时器 ID、前一个值、非渲染状态 |
(1) 用途 1:DOM 引用
最常见的用途——获取 DOM 元素,操作焦点、尺寸、滚动位置等。
| 操作 | 代码 | 说明 |
|---|---|---|
| 聚焦 | inputRef.current.focus() |
自动聚焦输入框 |
| 选中文本 | inputRef.current.select() |
全选输入框内容 |
| 滚动到 | divRef.current.scrollIntoView() |
滚动到指定元素 |
| 读取尺寸 | divRef.current.offsetHeight |
获取元素高度 |
| 播放视频 | videoRef.current.play() |
控制视频播放 |
▶ 示例:5 种常见 DOM 操作
JSX
// ============================================
// 示例:useRef 的 5 种常见 DOM 操作
// ============================================
function DomOperations() {
const inputRef = useRef(null)
const videoRef = useRef(null)
const listRef = useRef(null)
const boxRef = useRef(null)
const [boxHeight, setBoxHeight] = React.useState(0)
// 1. 自动聚焦
useEffect(() => { inputRef.current.focus() }, [])
function handleSelect() { inputRef.current.select() } // 2. 全选
function handlePlay() { videoRef.current.play() } // 3. 播放
function handlePause() { videoRef.current.pause() } // 3. 暂停
function handleScroll() { listRef.current.scrollIntoView({ behavior: 'smooth' }) } // 4. 滚动
function handleMeasure() { // 5. 测量尺寸
setBoxHeight(boxRef.current.offsetHeight)
}
return (
<div>
<h3>DOM 操作示例</h3>
<input ref={inputRef} placeholder="自动聚焦的输入框" />
<button onClick={handleSelect}>全选文本</button>
<div ref={listRef} style={{ height: '100px', overflow: 'auto', border: '1px solid #ddd', margin: '10px 0' }}>
{Array.from({ length: 20 }, (_, i) => <p key={i}>第 {i + 1} 行</p>)}
</div>
<button onClick={handleScroll}>滚动到底部</button>
<div ref={boxRef} style={{ padding: '20px', backgroundColor: '#f0f0f0', margin: '10px 0' }}>
<p>这个元素的高度是:{boxHeight}px</p>
<button onClick={handleMeasure}>测量高度</button>
</div>
</div>
)
}
(2) 用途 2:存储可变值(不触发重渲染)
这是 useRef 的隐藏技能——存储任何值,改变时组件不会重新渲染。
JSX
function Stopwatch() {
const [time, setTime] = React.useState(0)
const timerRef = useRef(null) // 存储定时器 ID,不参与渲染
function start() {
if (timerRef.current) return // 防止重复启动
timerRef.current = setInterval(() => {
setTime(t => t + 1)
}, 1000)
}
function stop() {
clearInterval(timerRef.current)
timerRef.current = null // 重置
}
return (
<div>
<p>计时:{time} 秒</p>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</div>
)
}
// timerRef 存储定时器 ID
// 改变 timerRef.current 不会触发重新渲染
// 组件卸载时定时器也不会自动清理(记得在 useEffect 清理)
4. useRef vs useState
| 维度 | useRef | useState |
|---|---|---|
| 返回值 | { current: 初始值 } |
[值, 设置函数] |
| 修改方式 | ref.current = 新值 |
setState(新值) |
| 修改后渲染 | ❌ 不触发 | ✅ 触发重新渲染 |
| 读取时机 | 立即读到最新值 | 要到下次渲染才读到 |
| 适用场景 | DOM 操作、定时器 ID、前一个值 | 需要显示在 UI 上的数据 |
JSX
function RefVsState() {
const renderCount = useRef(1) // 不会触发的计数器
const [count, setCount] = useState(0) // 会触发的计数器
useEffect(() => {
renderCount.current += 1 // 随便改,不触发渲染
})
return (
<div>
<p>useState:{count}(点击触发渲染)</p>
<p>useRef:{renderCount.current}(只在控制台看)</p>
<button onClick={() => setCount(c => c + 1)}>useState +1</button>
<button onClick={() => renderCount.current += 1}>useRef +1(无变化)</button>
</div>
)
}
▶ 示例:用 useRef 保存前一个值
JSX
// ============================================
// 示例:追踪前一个 Props/State 值
// ============================================
function usePrevious(value) {
const ref = useRef()
useEffect(() => {
ref.current = value // 每次渲染后更新
})
return ref.current // 返回上一次的 value
}
function Counter() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count) // 前一次的值
return (
<div>
<p>当前:{count}</p>
<p>上一次:{prevCount !== undefined ? prevCount : '(首次)'}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
)
}
// 流程:
// 1. 初始渲染:count=0, prevCount=undefined
// 2. 点击 +1 → count=1
// 3. useEffect 更新 prev=0 → 返回 0
// 4. 再点击 +1 → count=2, prev=1
5. forwardRef:父组件控制子组件的 DOM
默认情况下,函数组件不暴露自己的 DOM 引用。用 forwardRef 可以让子组件接收父组件的 ref:
JSX
// ---- 子组件:用 forwardRef 包裹 ----
const CustomInput = forwardRef(function CustomInput(props, ref) {
return (
<div style={{ border: '1px solid #d9d9d9', padding: '4px', borderRadius: '4px' }}>
<input ref={ref} {...props} style={{ border: 'none', outline: 'none', width: '100%' }} />
</div>
)
})
// ---- 父组件:直接用 ref 控制子组件的 input ----
function Form() {
const inputRef = useRef(null)
useEffect(() => {
inputRef.current.focus() // ✅ 能直接聚焦到 CustomInput 内部的 input
}, [])
return (
<div>
<CustomInput ref={inputRef} placeholder="父组件控制的输入框" />
<button onClick={() => inputRef.current.focus()}>聚焦</button>
<button onClick={() => inputRef.current.select()}>全选</button>
</div>
)
}
6. useImperativeHandle:暴露特定方法
如果不想暴露整个 DOM 元素,只用 useImperativeHandle 暴露特定方法:
JSX
// ============================================
// 示例:自定义播放器——只暴露 play/pause,不暴露整个 video
// ============================================
const VideoPlayer = forwardRef(function VideoPlayer({ src }, ref) {
const videoRef = useRef(null)
// 只暴露 3 个方法给父组件
useImperativeHandle(ref, () => ({
play() {
videoRef.current.play()
},
pause() {
videoRef.current.pause()
},
jumpTo(seconds) {
videoRef.current.currentTime = seconds
}
}))
return <video ref={videoRef} src={src} controls style={{ width: '100%' }} />
})
// ---- 使用 -----
function App() {
const playerRef = useRef(null)
return (
<div>
<h3>视频播放器</h3>
<VideoPlayer ref={playerRef} src="https://example.com/video.mp4" />
<div style={{ marginTop: '8px', display: 'flex', gap: '8px' }}>
<button onClick={() => playerRef.current.play()}>▶ 播放</button>
<button onClick={() => playerRef.current.pause()}>⏸ 暂停</button>
<button onClick={() => playerRef.current.jumpTo(30)}>⏭ 跳转到 30 秒</button>
</div>
{/* 父组件无法直接操作 video DOM,只能调用暴露的 play/pause/jumpTo */}
</div>
)
}
7. 完整示例:富文本编辑器
JSX
// ============================================
// 完整示例:简易富文本编辑器
// 功能:useRef 控制 contentEditable 区域
// forwardRef + useImperativeHandle 暴露方法
// ============================================
const RichEditor = forwardRef(function RichEditor({ placeholder }, ref) {
const editorRef = useRef(null)
const [isEmpty, setIsEmpty] = React.useState(true)
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
getContent() {
return editorRef.current.innerHTML
},
setContent(html) {
editorRef.current.innerHTML = html
checkEmpty()
},
clear() {
editorRef.current.innerHTML = ''
setIsEmpty(true)
editorRef.current.focus()
},
focus() {
editorRef.current.focus()
}
}))
function checkEmpty() {
const text = editorRef.current.textContent || ''
setIsEmpty(text.trim().length === 0)
}
function handleKeyDown(e) {
if (e.ctrlKey && e.key === 'b') {
document.execCommand('bold')
e.preventDefault()
}
if (e.ctrlKey && e.key === 'i') {
document.execCommand('italic')
e.preventDefault()
}
}
return (
<div style={{ border: '1px solid #d9d9d9', borderRadius: '4px', overflow: 'hidden' }}>
{/* 工具栏 */}
<div style={{ padding: '8px', borderBottom: '1px solid #d9d9d9', backgroundColor: '#fafafa', display: 'flex', gap: '4px' }}>
<button onMouseDown={e => { e.preventDefault(); document.execCommand('bold') }} style={toolBtnStyle}><b>B</b></button>
<button onMouseDown={e => { e.preventDefault(); document.execCommand('italic') }} style={toolBtnStyle}><i>I</i></button>
<button onMouseDown={e => { e.preventDefault(); document.execCommand('underline') }} style={toolBtnStyle}><u>U</u></button>
<span style={{ color: '#ddd' }}>|</span>
<button onMouseDown={e => { e.preventDefault(); document.execCommand('insertUnorderedList') }} style={toolBtnStyle}>列表</button>
<button onMouseDown={e => { e.preventDefault(); document.execCommand('formatBlock', false, 'h2') }} style={toolBtnStyle}>H2</button>
</div>
{/* 编辑区 */}
<div
ref={editorRef}
contentEditable
onInput={checkEmpty}
onKeyDown={handleKeyDown}
style={{
minHeight: '200px',
padding: '16px',
outline: 'none',
lineHeight: '1.6'
}}
data-placeholder={placeholder}
{...(isEmpty ? { 'data-empty': 'true' } : {})}
/>
</div>
)
})
function App() {
const editorRef = useRef(null)
const [savedContent, setSavedContent] = React.useState('')
function handleSave() {
const content = editorRef.current.getContent()
setSavedContent(content)
alert('已保存!')
}
function handleClear() {
editorRef.current.clear()
}
function handleLoad() {
editorRef.current.setContent('<h2>已加载的内容</h2><p>这是从外部加载的 HTML。</p>')
}
return (
<div style={{ maxWidth: '700px', margin: '0 auto' }}>
<h2>📝 富文本编辑器</h2>
<RichEditor ref={editorRef} placeholder="开始写作..." />
<div style={{ marginTop: '12px', display: 'flex', gap: '8px' }}>
<button onClick={handleSave} style={btnStyle('#1890ff')}>💾 保存</button>
<button onClick={handleLoad} style={btnStyle('#52c41a')}>📂 加载示例</button>
<button onClick={handleClear} style={btnStyle('#ff4d4f')}>🗑 清空</button>
</div>
{savedContent && (
<div style={{ marginTop: '16px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<p style={{ fontWeight: 'bold', margin: '0 0 8px 0' }}>已保存的内容:</p>
<div style={{ fontSize: '13px', color: '#666', wordBreak: 'break-all', fontFamily: 'monospace' }}>
{savedContent}
</div>
</div>
)}
</div>
)
}
const toolBtnStyle = {
padding: '4px 10px', border: '1px solid transparent',
borderRadius: '3px', backgroundColor: 'transparent',
cursor: 'pointer', fontSize: '14px'
}
const btnStyle = (color) => ({
padding: '8px 16px', backgroundColor: color,
color: 'white', border: 'none',
borderRadius: '4px', cursor: 'pointer'
})
预期输出:富文本编辑器,支持粗体/斜体/下划线/列表/H2 等格式,外部可通过 ref 保存/加载/清空内容。
▶ 示例 3:useRef 实现点击外部关闭弹窗
JSX
function useClickOutside(callback) {
const ref = useRef(null)
useEffect(() => {
function handler(e) {
if (ref.current && !ref.current.contains(e.target)) {
callback()
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [callback])
return ref
}
function Dropdown() {
const [open, setOpen] = useState(false)
const dropdownRef = useClickOutside(() => setOpen(false))
return (
<div ref={dropdownRef} style={{ position: 'relative', display: 'inline-block' }}>
<button onClick={() => setOpen(!open)} style={{ padding: '8px 16px', cursor: 'pointer' }}>
Menu ▾
</button>
{open && (
<div style={{ position: 'absolute', top: '100%', left: 0, background: 'white', border: '1px solid #ddd', borderRadius: 4, minWidth: 120, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
{['Profile', 'Settings', 'Logout'].map(item => (
<div key={item} onClick={() => { setOpen(false) }}
style={{ padding: '8px 16px', cursor: 'pointer' }}>
{item}
</div>
))}
</div>
)}
</div>
)
}
▶ 示例 4:useRef 存储定时器实现节流按钮
JSX
function ThrottledButton() {
const lastClickRef = useRef(0)
const [clicks, setClicks] = useState(0)
const [feedback, setFeedback] = useState('')
function handleClick() {
const now = Date.now()
if (now - lastClickRef.current < 1000) {
setFeedback('Too fast! Wait 1 second.')
return
}
lastClickRef.current = now
setClicks(c => c + 1)
setFeedback('Clicked!')
setTimeout(() => setFeedback(''), 500)
}
return (
<div style={{ textAlign: 'center', padding: 20 }}>
<button onClick={handleClick}
style={{ padding: '10px 24px', fontSize: 16, cursor: 'pointer' }}>
Click Me
</button>
<p>Clicks: {clicks}</p>
{feedback && <p style={{ color: feedback.includes('Too') ? '#ff4d4f' : '#52c41a' }}>{feedback}</p>}
</div>
)
}
▶ 示例 5:forwardRef 封装可聚焦的 Input 组件
JSX
const FancyInput = React.forwardRef(function FancyInput({ label, error, ...props }, ref) {
const internalRef = useRef(null)
useImperativeHandle(ref, () => ({
focus: () => internalRef.current?.focus(),
selectAll: () => {
const el = internalRef.current
if (el) { el.focus(); el.select() }
},
scrollIntoView: () => internalRef.current?.scrollIntoView({ behavior: 'smooth' }),
}))
return (
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>{label}</label>
<input
ref={internalRef}
style={{ width: '100%', padding: 8, borderRadius: 4, border: `1px solid ${error ? '#ff4d4f' : '#d9d9d9'}`, outline: 'none' }}
{...props}
/>
{error && <p style={{ color: '#ff4d4f', fontSize: 12, margin: '4px 0 0' }}>{error}</p>}
</div>
)
})
function FormWithFancyInput() {
const emailRef = useRef(null)
function handleSubmit(e) {
e.preventDefault()
emailRef.current?.focus()
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '0 auto' }}>
<h3>Sign Up</h3>
<FancyInput ref={emailRef} label="Email" type="email" placeholder="you@example.com" />
<FancyInput label="Password" type="password" placeholder="At least 6 characters" />
<button type="submit" style={{ padding: '8px 24px', cursor: 'pointer' }}>Submit</button>
</form>
)
}
❓ 常见问题
Q forwardRef 必须在每个组件上都用吗?
A 不需要。只在以下场景使用:① 父组件需要直接操作子组件的 DOM(聚焦输入框、媒体控制);② 封装可复用的表单组件(让父组件控制聚焦/选中)。如果只是数据传递,用 Props 就够了。
Q useRef 能替代 useState 吗?
A 不能。useRef 的改变不会触发组件重新渲染,而 setState 会。如果你的值需要在 UI 上显示或影响渲染结果,必须用 useState。如果只是存储一个"不需要展示给用户的数据"(如 Interval ID、上一个渲染的值、WebSocket 实例),用 useRef。把 useRef 当"不触发渲染的变量容器"来理解就对了。
📖 小节
useRef返回{ current: initialValue },改变.current不触发重新渲染- 两个用途:① DOM 引用(聚焦、尺寸、滚动)② 存储可变值(定时器 ID、前一个值)
forwardRef:让父组件通过 ref 控制子组件的 DOMuseImperativeHandle:限制暴露的方法,只给父组件需要的 API- 操作 ref 前一定要判空:
ref.current?.focus()
📝 作业
- 基础题(难度⭐):创建一个
AutoFocusInput组件,挂载时自动聚焦,并提供focus()和clear()两个方法供父组件调用。 - 进阶题(难度⭐⭐):创建一个
ClickCounter组件,使用useRef记录点击次数(不显示在 UI 上),每次渲染时用useEffect在控制台打印"已点击 X 次"。 - 挑战题(难度⭐⭐⭐):创建一个
InfiniteScroll组件,使用useRef监听 sentinel 元素(页面底部的一个标记元素)进入视口,触发加载更多数据。使用IntersectionObserverAPI。