从入门到精通:React 中 useRef 和 forwardRef 的全面解析

242 阅读7分钟

深入 React 的 useRef 与 forwardRef:从零开始的详细指南


作者:React 深度爱好者
时间:2025年7月23日
阅读时长:约 25 分钟
适用人群:对 React 基础有一定了解,但希望深入理解 useRefforwardRef 工作机制的新手开发者


在 React 开发中,有时我们需要直接访问 DOM 元素或组件实例,而不是通过 props 和 state 来间接控制它们。为此,React 提供了两个非常有用的工具:useRefforwardRef。本文将从基础讲起,逐步深入探讨这两个 API 的工作原理、使用场景及具体实现方法。


一、useRef:获取引用

什么是 useRef?

useRef 是一个 React Hook,它允许你在组件中创建一个持久化的引用对象。这个引用对象不会导致组件重新渲染,因此非常适合用于存储一些不需要触发更新的信息,比如 DOM 节点、计时器 ID 等。

基本用法
import { useRef } from 'react';

function MyComponent() {
  const myRef = useRef(null);

  return (
    <div>
      <input type="text" ref={myRef} />
    </div>
  );
}
代码解析:
  • useRef(null) 创建了一个初始值为 null 的引用对象。
  • <input type="text" ref={myRef} />myRef 绑定到 <input> 元素上。这意味着你可以通过 myRef.current 访问该输入框的 DOM 节点。

示例:自动聚焦输入框

假设你有一个表单,希望页面加载后自动聚焦第一个输入框:

import { useRef, useEffect } from 'react';

function InputField() {
  // 创建一个引用对象
  const inputRef = useRef(null);

  // 使用 useEffect 在组件挂载后执行
  useEffect(() => {
    // 当组件挂载后,尝试让输入框获得焦点
    inputRef.current.focus(); // 使用 .current 来访问实际的 DOM 节点
  }, []);

  return <input ref={inputRef} type="text" />;
}
详细解释:
  1. useEffect 钩子:在组件首次渲染完成后执行一次(依赖项为空数组)。此时通过 inputRef.current 获取到真实的 <input> DOM 节点,并调用其 .focus() 方法。
  2. ref.current?.focus():使用了可选链操作符,防止在 ref.currentnull 时调用 focus() 报错。

底层原理

在 React 的 Fiber 架构中,每个组件实例都有一个对应的 fiber 节点。useRef 创建的引用对象是在组件的 Hook 链表 中维护的。React 会为每个 Hook 分配一个内存位置,并在组件更新时保留这些值。

  • useRef 的值不会触发组件更新,因为它不是状态(state),而是直接保存在 Hook 对象中。
  • React 在组件挂载时创建 ref 对象,并在组件卸载时保持其存在,直到组件被销毁。
  • useRef.current 是一个可变的引用容器,React 不会追踪它的变化,因此适用于保存一些“副作用”相关的值,如计时器 ID、DOM 元素、上次的 props/state 等。

使用场景

  • 访问 DOM 元素:如聚焦输入框、滚动行为、动画等。
  • 跨渲染保持状态:不希望触发重新渲染的状态,如上一次的 props。
  • 缓存昂贵计算:避免在每次渲染中重复计算。

二、forwardRef:穿透组件的引用传递机制

什么是 forwardRef?

有时我们需要在父组件中使用子组件内部的某个 DOM 元素或组件实例。这时可以使用 forwardRef,它能够“转发” ref 到子组件内部,让父组件可以直接操作子组件内部的 DOM 节点或组件实例。

基本用法
import { forwardRef } from 'react';

const CustomInput = forwardRef((props, ref) => {
  return <input ref={ref} {...props} />;
});
代码解析:
  • forwardRef 接受一个函数作为参数,该函数接收两个参数:propsref
  • 函数返回的 JSX 中可以直接使用传入的 ref,将其绑定到任意子节点上。

示例:封装一个可聚焦的输入组件

import { forwardRef, useRef } from 'react';

const CustomInput = forwardRef((props, ref) => {
  return <input ref={ref} {...props} />;
});

export default function App() {
  const inputRef = useRef(null);

  return (
    <div>
      <CustomInput ref={inputRef} placeholder="请输入..." />
      <button onClick={() => inputRef.current.focus()}>
        聚焦输入框
      </button>
    </div>
  );
}
详细解释:
  • CustomInput 是一个封装好的组件,它内部使用了 forwardRef 来接收外部传入的 ref
  • 父组件 App 中创建了一个 inputRef,并通过 ref={inputRef} 传递给 CustomInput
  • 点击按钮时调用 inputRef.current.focus(),从而实现了对子组件内部 <input> 元素的聚焦控制。

底层原理

在 React 的虚拟 DOM 构建过程中,ref 是一种特殊的属性,它不参与 props 传递,而是在组件构建时被特殊处理。默认情况下,ref 只能绑定到类组件或使用 forwardRef 包装过的函数组件。

  • forwardRef 的本质是告诉 React:“这个组件接受一个 ref,请把它传递给内部的某个节点。”
  • React 在构建组件树时,会检查组件是否是 forwardRef 类型,如果是,则将父组件传递的 ref 作为第二个参数传入组件函数。
  • 这个 ref 实际上是一个指向 useRef 创建的对象,最终指向了某个 DOM 节点或组件实例。

使用场景

  • 函数组件默认不支持 ref,因为它们是纯函数,没有实例。
  • 如果你封装了一个组件(比如一个 Input 组件),你希望外部能访问其内部的 <input> DOM 节点,就必须使用 forwardRef
  • 在开发可复用组件库时forwardRef 是实现“透明引用传递”的关键。

三、结合实际案例加深理解

案例1:动态调整输入框大小

import { useRef, useEffect } from 'react';

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

  useEffect(() => {
    const handleResize = () => {
      if (inputRef.current) {
        inputRef.current.style.width = `${inputRef.current.value.length * 8}px`;
      }
    };

    const inputElement = inputRef.current;
    inputElement.addEventListener('input', handleResize);
    
    // 清理事件监听器
    return () => {
      inputElement.removeEventListener('input', handleResize);
    };
  }, []);

  return <input ref={inputRef} type="text" />;
}
解释:
  • ResizableInput 组件:我们利用 useRef 来获取输入框的引用,并通过 useEffect 监听用户的输入事件。
  • handleResize 函数:每当用户输入内容时,根据输入框内的字符长度动态调整输入框的宽度。这里假定每个字符大约占用 8px 的宽度。
  • 清理事件监听器:在组件卸载时移除事件监听器,以避免内存泄漏问题。

案例2:自定义可拖动组件

import { useRef } from 'react';

const DraggableBox = forwardRef((props, ref) => {
  const dragStart = (e) => {
    e.target.style.opacity = '0.4';
  };

  const dragEnd = (e) => {
    e.target.style.opacity = '1';
  };

  return (
    <div
      ref={ref}
      draggable
      onDragStart={dragStart}
      onDragEnd={dragEnd}
      style={{ width: '100px', height: '100px', backgroundColor: 'blue' }}
    >
      Drag me!
    </div>
  );
});

export default function App() {
  const boxRef = useRef(null);

  return (
    <div>
      <DraggableBox ref={boxRef} />
      <button onClick={() => {
        boxRef.current.style.backgroundColor = 'red';
        console.log('背景颜色已改为红色');
      }}>
        改变颜色
      </button>
    </div>
  );
}
解释:
  • DraggableBox 组件:使用 forwardRef 来接收外部传入的 ref,并实现了一个简单的可拖动盒子。
  • onDragStartonDragEnd 事件处理程序:当用户开始拖拽时,盒子的透明度变为 0.4;拖拽结束时恢复到 1。
  • 父组件 App 中创建了一个 boxRef,并通过 ref={boxRef} 传递给 DraggableBox。此外,还提供了一个按钮来改变盒子的颜色,并在控制台打印一条消息。
  • 注意:
    • draggable 属性使元素可拖动。
    • 通过 ref,我们可以直接操作组件内部的 DOM 元素,例如更改样式或添加事件监听器。

案例3:防抖与节流

const timeoutRef = useRef(null);
function handleChange(e) {
  clearTimeout(timeoutRef.current);
  timeoutRef.current = setTimeout(() => {
    console.log('发送请求:', e.target.value);
  }, 300);
}
解释:
  • timeoutRef:用来存储定时器的 ID,确保每次输入时清除之前的定时器。
  • handleChange 函数:使用防抖技术,即在用户停止输入后的 300ms 内才执行相应的操作(在这里是打印输入值)。这有助于减少不必要的网络请求或其他高成本操作,提高应用性能。

四、最佳实践与注意事项

✅ 推荐使用场景

  • 使用 useRef 保存不触发更新的变量
  • 使用 forwardRef 在组件库中暴露内部 DOM
  • 避免在渲染中依赖 ref.current 的值,它可能不是最新的。

❌ 不推荐使用场景

  • 不要用 ref 替代 state 来控制 UI
  • 不要在 ref 中保存复杂对象,除非你知道如何管理内存。
  • 不要在 ref 中保存函数,除非是为了性能优化。

如果你喜欢这篇文章,欢迎为我点赞,或分享给更多开发者朋友。也欢迎留言交流,我们一起深入 React 的世界 🚀