2.6 Refs 与 DOM 访问


文档摘要

2.6 Refs 与 DOM 访问 本节摘要:Refs 是 React 提供的「后门」,用于在声明式世界中直接访问 DOM 节点或组件实例。本节覆盖 createRef、useRef、回调 Ref 三种创建方式,forwardRef 与 useImperativeHandle 向父组件暴露方法,并给出 Refs 的使用边界——什么时候该用、什么时候该回头用 state 和 props。

2.6 Refs 与 DOM 访问

本节摘要:Refs 是 React 提供的「后门」,用于在声明式世界中直接访问 DOM 节点或组件实例。本节覆盖 createRef、useRef、回调 Ref 三种创建方式,forwardRef 与 useImperativeHandle 向父组件暴露方法,并给出 Refs 的使用边界——什么时候该用、什么时候该回头用 state 和 props。

读前必看

阅读完本节,你应当能够:

  1. 用 useRef 聚焦输入框、播放视频等操作 DOM 的场景
  2. 解释 createRef 与 useRef 的区别
  3. 理解回调 Ref 的挂载/卸载触发时机
  4. 用 forwardRef 和 useImperativeHandle 暴露子组件方法
  5. 判断何时不该用 Ref,回归声明式写法

一、问题与直觉:声明式世界也有「必须直接动手」的时候

React 的世界观是数据驱动视图——state 变了,界面跟着变。但有些操作没法靠「改变量让界面变」来表达:

  • 页面加载后自动聚焦输入框
  • 播放/暂停视频
  • 测量一个 DOM 元素的宽度
  • 集成需要直接操作 DOM 的第三方库

这些操作必须绕过 React 的数据流,直接和真实 DOM 打交道。Refs 就是干这个的——它提供一个稳定的「引用」,让你拿到真实的 DOM 节点,然后想干嘛干嘛。

SOURCE 原文给 Ref 的定义很形象:Ref 就像一把「引用」,允许绕过 React 的虚拟 DOM,直接与真实 DOM 交互。

二、核心原理:Ref 就是「.current」

先破除 Refs 的神秘感。一个 Ref 就是一个对象,最关键的就是它的 .current 属性:

  • 创建时:.current 通常是 null
  • 绑定到元素后:React 把真实 DOM 节点赋给 .current
  • 元素卸载:.current 回到 null

就这么简单。ref 对象本身在组件的整个生命周期内保持同一个引用——这正是它和普通变量的区别:普通变量每次渲染重新创建,ref 对象跨渲染保持稳定。

useRef 的两个身份

值得单独说明的是,useRef 其实有两种用法,对应两个身份:

身份一:DOM 引用。把 ref 传给元素的 ref 属性,React 会在挂载后把 DOM 节点放进 .current。这是最常见的用法,也是本节的焦点。

身份二:可变值容器。用它存任何跨渲染保持的数据——定时器 id、上一次的 props、不需要触发重渲染的计数。修改 ref.current 不会触发重渲染,这是它和 useState 的本质区别:用 useState 存「变了要重画」的值,用 useRef 存「变了不用重画」的值。

function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; // 返回的是上一次渲染时的 value }

这个 usePrevious 是 useRef 做可变容器的经典例子——拿到上一次的 props/state,在 effect 里和当前值对比。没有 useRef 时,这类「记住上一个值」的需求很难干净地实现。

三、动手实践:聚焦输入框

需求:页面加载后自动聚焦输入框。

函数组件用 useRef

import { useRef, useEffect } from 'react'; function MyInput() { const inputRef = useRef(null); useEffect(() => { inputRef.current.focus(); // 挂载后聚焦 }, []); return <input type="text" ref={inputRef} />; }

三个细节拆开看:useRef(null) 创建 ref,初始值 null——因为首次渲染时 DOM 还没创建;ref 属性把 input 元素绑给 inputRef;useEffect 在挂载后执行 focus——这时 DOM 已就绪,inputRef.current 指向真实的 input 元素。

类组件用 createRef

import React, { Component } from 'react'; class MyInput extends Component { constructor(props) { super(props); this.inputRef = React.createRef(); } componentDidMount() { this.inputRef.current.focus(); } render() { return <input type="text" ref={this.inputRef} />; } }

createRef 与 useRef 的关系:useRef 是函数组件版的 createRef。两者返回的对象形态完全一样,区别只在语法位置——createRef 要挂在实例属性上,useRef 直接声明在函数里。

⚠️ 常见坑:在渲染期间读取 ref.current。首次渲染时 ref 还是 null,读了会报错或拿到 undefined。必须在 useEffect、事件处理函数、或组件挂载后这些「DOM 已就绪」的时刻访问。

四、Ref 绑定到组件:forwardRef 与 useImperativeHandle

Ref 不仅能绑 DOM 元素,还能绑 React 组件——让父组件直接调用子组件的实例方法。

类组件:直接绑

类组件的实例本身就是可访问的,父组件拿到 ref 就能调它的方法:

class MyInput extends Component { focus() { this.inputElement.focus(); } render() { return <input ref={(el) => this.inputElement = el} />; } } // 父组件 class Parent extends Component { constructor(props) { super(props); this.inputRef = React.createRef(); } componentDidMount() { this.inputRef.current.focus(); // 调子组件方法 } render() { return <MyInput ref={this.inputRef} />; } }

函数组件:forwardRef + useImperativeHandle

函数组件没有实例,直接给它挂 ref 会报错。解法是 forwardRef——它把父组件的 ref 透传给函数组件内部,配合 useImperativeHandle 决定暴露什么:

import { forwardRef, useImperativeHandle, useRef } from 'react'; const FancyInput = forwardRef((props, ref) => { const inputRef = useRef(); useImperativeHandle(ref, () => ({ focus: () => { inputRef.current.focus(); }, setValue: (value) => { inputRef.current.value = value; }, })); return <input ref={inputRef} />; }); function Parent() { const fancyRef = useRef(); const handleClick = () => { fancyRef.current.focus(); // 调 FancyInput 暴露的方法 }; return ( <div> <FancyInput ref={fancyRef} /> <button onClick={handleClick}>聚焦输入框</button> </div> ); }

useImperativeHandle 的意义是「受控地暴露」:子组件决定给父组件看哪些方法,而不是把整个 DOM 节点交给父组件随意操作。这保留了封装——父组件调 focus、setValue,但不碰 input 的其他东西。

注意 useImperativeHandle 的依赖数组:第二个参数是依赖数组,只在依赖变化时才重建暴露的方法对象。如果暴露的方法里引用了 props 或 state,要写进依赖,否则拿到的是旧快照——这和 useEffect 的依赖规则一脉相承,本质是同一个闭包问题。

💡 关键直觉:forwardRef 像「转交钥匙的人」,useImperativeHandle 是「决定配几把钥匙的人」。函数组件本来没有实例这把「门」,forwardRef 把门钥匙交给父组件,useImperativeHandle 决定钥匙能开哪几扇门。这个模式在封装组件库、设计可复用的复杂组件时几乎是必需品。

五、回调 Ref:更精细的控制

除了 createRef/useRef 这种「对象式」Ref,还有回调 Ref——直接把函数传给 ref 属性。React 在挂载时调用它(参数是 DOM 节点),卸载时调用它(参数是 null)。

class MyComponent extends Component { constructor(props) { super(props); this.myRef = null; } setMyRef = (element) => { this.myRef = element; if (element) { console.log('DOM 已挂载', element); } }; componentWillUnmount() { this.myRef = null; // 清理 } render() { return <div ref={this.setMyRef}>内容</div>; } }

回调 Ref 的价值在「挂载/卸载时执行自定义逻辑」——比如挂载时测量元素尺寸、卸载时通知外部。这是对象式 Ref 做不到的,对象式只能被动等 React 赋值。

回调 Ref 与对象式 Ref 的差异

回调 Ref 有一个对象式 Ref 没有的能力:把多个 DOM 节点存进一个数组。渲染一组列表元素时,想拿到每一个节点的引用,对象式 Ref 办不到——因为一个 ref 对象只能绑一个节点。回调 Ref 可以:

function ImageGrid({ images }) { const imgRefs = useRef([]); return ( <div> {images.map((img, index) => ( <img key={img.id} ref={(el) => { imgRefs.current[index] = el; }} src={img.url} /> ))} </div> ); }

这个「ref 数组」模式在需要批量操作一组 DOM 节点的场景里很实用。注意回调里要处理卸载时 el 为 null 的情况,避免数组里残留引用。函数组件里内联回调 Ref 有个副作用:每次渲染都会创建新函数,React 会用新函数重新调用旧回调(参数 null)再调用新回调,导致挂载/卸载逻辑被额外触发——用 useCallback 稳定回调可以避免。

六、Ref 的三种方式对比

方式 适用 特点
useRef 函数组件 简洁,Hook 语法
createRef 类组件 实例属性
回调 Ref 两者 挂载/卸载时执行逻辑,可组数组

七、什么时候不该用 Ref

Refs 是后门,用多了会让声明式世界崩溃——组件变得难以预测、难以测试。SOURCE 原文列了几条边界,我补充成一张「该/不该」对照:

想做的事 该用 不该用
读输入框当前值 受控组件 + state Ref 直接读
聚焦输入框 Ref 无替代,只能 Ref
组件间共享数据 状态提升 / Context Ref 传数据
测量元素尺寸 Ref(渲染后) 渲染期读
阻止重渲染的可变值 useRef useState(会重渲染)

判断的核心问题:这个操作能表达为「状态变化」吗? 能,用 state;不能(聚焦、测量、播放),才用 Ref。

再补一个容易走偏的场景:用 Ref 保存「上一次的值」没问题(usePrevious 模式),但用它模拟 state 的持久化就错了。有人图省事,用 ref 存所有数据、在事件里手动改 DOM 来「同步界面」——这等于抛弃了 React 的渲染机制,回到 jQuery 时代,状态和界面的一致性全靠人肉维护,项目一大必然失控。

还有一个边界值得记住:Ref 不参与渲染,所以「想让界面跟随某个值变化」的数据绝不能用 Ref 存。Ref 只适合「值需要保存、但变化不需要界面响应」的场景。把这两条边界合起来,Ref 的适用范围就非常清楚了:聚焦、测量、播放控制、第三方库容器、跨渲染的辅助值。

⚠️ 常见坑:用 Ref 在组件间传数据。比如 A 组件把值塞进一个共享 ref,B 组件去读。这绕过了 React 的数据流——B 读到的可能是旧值,因为 ref 变化不触发重渲染。跨组件共享数据用状态提升或 Context,别用 Ref。

八、工程实践:测量元素尺寸

一个真实场景:需要知道某个 DOM 元素的宽度,来做布局判断。这种「读 DOM 信息」的操作只能靠 Ref。

import { useRef, useLayoutEffect, useState } from 'react'; function MeasureBox() { const boxRef = useRef(null); const [width, setWidth] = useState(0); useLayoutEffect(() => { setWidth(boxRef.current.offsetWidth); }, []); return ( <div> <div ref={boxRef}>我是被测量的盒子</div> <p>宽度:{width} 像素</p> </div> ); }

这里用 useLayoutEffect 而不是 useEffect,是因为测量需要在浏览器绘制前完成,避免「先渲染旧宽度、再跳变到新宽度」的闪烁。useLayoutEffect 在 DOM 更新后同步执行,适合读布局信息;useEffect 是异步的,适合不关心绘制时机的事。

更完整的测量通常要监听窗口尺寸变化——把测量逻辑放进带依赖的 effect,配合 resize 事件。这类「读 DOM 再同步回 state」的模式是 Ref 与 state 协作的典型:Ref 负责拿 DOM 信息,state 负责把信息变成界面。两者配合,既绕过了虚拟 DOM 读取真实信息,又回到了声明式的渲染路径上。

九、Ref 的清理与内存安全

Ref 虽然好用,但也有内存隐患需要留意。

对象式 Ref 的泄漏风险低:React 在元素卸载时自动把 .current 置回 null,不残留引用。

回调 Ref 的泄漏风险高:如果回调里把 DOM 节点存进了外部数组、全局变量,而回调又没在卸载时清理,节点就会被长期持有。特别是「ref 数组」模式,卸载时 React 会用 null 调用回调,你必须在回调里处理这个 null——把数组对应位置清掉。

监听器的配对清理:用 Ref 配合 addEventListener 时,effect 里绑、清理函数里解绑,两者必须成对。只绑不解是事件监听器泄漏的经典写法。前面测量尺寸的例子如果把 resize 监听写在 effect 里,一定要返回清理函数移除监听。

这一节反复强调「清理」,是因为内存泄漏不报错、不显形,只会在长时间运行后让应用越来越卡。养成「绑了就要解、存了就要清」的习惯,比事后排查高效得多。

十、Ref 在集成第三方库中的应用

Refs 最常见的真实用途是集成第三方 DOM 库——图表、地图、富文本编辑器。这类库通常需要「给我一个 DOM 节点,我自己渲染」。

套路是固定的:给容器元素挂 ref,在 useEffect 里把容器交给第三方库初始化,卸载时清理:

import { useRef, useEffect } from 'react'; function ChartContainer({ data }) { const containerRef = useRef(null); useEffect(() => { // 初始化第三方图表,把 DOM 节点交给它 const chart = new Chart(containerRef.current, { data }); return () => chart.destroy(); // 卸载时销毁 }, [data]); return <div ref={containerRef} />; }

这个「挂 ref → 初始化 → 返回清理函数」的三步模式,是所有「React 包 DOM 库」的通用模板。理解了它,任何第三方库都能以同样套路接入——这也是为什么说 Ref 是「React 通往外部世界的桥」。

常见疑问快答

「为什么渲染期间不能读 ref.current?」 因为首次渲染时 DOM 还没创建,ref.current 是 null;而且渲染应该是「纯函数」,不该有副作用——读 DOM 信息本身就是副作用。React 的渲染过程可以执行多次(严格模式还会故意双调用),如果在渲染期读了 DOM 并基于它计算,结果会不稳定、不可预测。所以读 ref.current 的正确时机是「渲染之后」:useEffect、useLayoutEffect、事件处理函数、以及任何用户交互触发的回调。这条规则和「useEffect 里才能操作 DOM」是同一件事的两面。

「useRef 和 useState 都能存数据,怎么选?」 看「变化之后要不要重渲染」:要——数据变化必须反映到界面上,用 useState;不要——数据只是内部记住,变化不需要界面响应,用 useRef。定时器 id、防抖计时器、上一次的 props/state、第三方库实例,都是 useRef 的典型对象——它们变化时界面不该动,动了反而是 bug。记住一个反例:把「界面上要显示的内容」存进 useRef 再手动改 DOM 去同步,这是把 React 降级成 jQuery 的写法,几乎总是错的。Ref 是「记」数据,state 是「渲染」数据。

「forwardRef 是必须的吗?能不用吗?」 函数组件想接收 ref 就必须用 forwardRef(React 19 的 ref as prop 是新的替代写法,向后兼容期还是 forwardRef 稳妥)。如果你的组件不需要对外暴露 ref——绝大多数业务组件都不需要——就不用。需要的情形集中在「组件库封装」和「复合组件」:比如你写了一个带内部结构的 Input 组件,外层调用方想直接聚焦它,就得 forwardRef 把 ref 透传给真正的 input。判断标准:调用方需不需要拿到你组件内部的 DOM 或实例方法?需要,forwardRef;不需要,别加。

「回调 Ref 在函数组件里怎么写才不每次重新触发?」 内联回调 ref 有个坑:每次渲染都会创建新函数,React 会「先用 null 调用旧回调(模拟卸载)、再调用新回调」,导致挂载/卸载逻辑被反复触发。解法是用 useCallback 包住回调,让函数引用稳定;如果回调内部依赖组件最新值,把依赖写进 useCallback 的依赖数组。这个细节在「测量尺寸」「观察元素进出视口」这类场景特别重要——不稳定的回调会让监听器反复解绑重绑,行为异常。用稳定回调 + 正确依赖,是回调 Ref 在函数组件里的正确姿势。

一节小结

  • Ref 本质:一个带 .current 的对象,跨渲染保持稳定
  • useRef:函数组件创建 Ref,绑定元素后 .current 是 DOM 节点
  • createRef:类组件版本,挂在实例属性上
  • useRef 双身份:DOM 引用 + 可变值容器,后者修改不触发重渲染
  • 访问时机:只在 useEffect、事件、挂载后访问 .current,渲染期是 null
  • forwardRef:把父组件的 ref 透传给函数组件
  • useImperativeHandle:受控地暴露子组件方法给父组件
  • 回调 Ref:挂载/卸载时执行自定义逻辑,可组 ref 数组批量拿节点
  • useLayoutEffect 测量:读布局信息用 useLayoutEffect,避免闪烁
  • 内存安全:监听器成对绑定解绑,ref 数组卸载时清空
  • 不该用 Ref:能表达为状态变化的事用 state,Ref 用于聚焦、测量、集成第三方库
  • 集成模板:挂 ref → useEffect 初始化 → 返回清理函数

下一章进入进阶主题。核心概念打底之后,第 3 章会把这些概念推向实战——性能、路由、状态管理、测试、SSR,每一节都是「选型 + 原理 + 落地」的组合,是从「会写 React」迈向「会用 React 解决真实问题」的转折。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U