React: useRef 与 DOM 操作

最后更新:2026-08-26

useRef 就像一个 保险箱——你可以往里面存任何东西,取出时它还在。和 useState 不同,改变 ref不会触发组件重新渲染


1. 你将学到



2. 一个搜索输入框的故事

useRef 的两大用途

100%
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 当"不触发渲染的变量容器"来理解就对了。

📖 小节


📝 作业

  1. 基础题(难度⭐):创建一个 AutoFocusInput 组件,挂载时自动聚焦,并提供 focus()clear() 两个方法供父组件调用。
  2. 进阶题(难度⭐⭐):创建一个 ClickCounter 组件,使用 useRef 记录点击次数(不显示在 UI 上),每次渲染时用 useEffect 在控制台打印"已点击 X 次"。
  3. 挑战题(难度⭐⭐⭐):创建一个 InfiniteScroll 组件,使用 useRef 监听 sentinel 元素(页面底部的一个标记元素)进入视口,触发加载更多数据。使用 IntersectionObserver API。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏