React Refs

深入理解 useRef 的本质是 mutable container,掌握 createRef vs useRef 的区别,理解 forwardRef 的 ref 穿透机制,以及 useImperativeHandle 的自定义暴露控制。

React Refs

为什么 refs 是 React 模型的必要补充

React 的核心哲学是声明式 UI——你描述在任何给定时刻 UI 应该是什么样子,React 负责更新实际的 DOM。这个模型在绝大多数场景下工作得很好:通过 state 和 props 的变化驱动 UI 更新。

但有时候,你需要命令式地与 DOM 节点或 React 组件实例交互:

  • 设置输入框焦点
  • 触发动画
  • 测量 DOM 节点尺寸
  • 调用第三方库的 API
  • 保存一个不需要触发 re-render 的值(定时器 ID、上一次的值)

这些场景有一个共同特征:你需要绕过 React 的声明式模型,直接访问底层 DOM 节点或组件实例。Refs 就是这个"后门"——它提供了 React 声明式世界和命令式 DOM 世界之间的桥梁。

面试定位:refs 是 React 面试中考察"对 React 渲染模型理解深度"的代表性话题。面试官通过候选人能否说清楚 useRef 的本质是 mutable container、forwardRef 的穿透机制、useImperativeHandle 的自定义暴露控制,来判断其对 React 内部工作原理的理解程度。


一、useRef 的本质:mutable container

1.1 什么是 Ref

Ref 是一个容器,其 .current 属性可以存储任何值。修改 ref 的值不会触发组件 re-render——这是 ref 与 state 的根本区别。

function Timer() {
  const intervalRef = useRef(null);
  const [count, setCount] = useState(0);

  const startTimer = () => {
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
  };

  const stopTimer = () => {
    clearInterval(intervalRef.current);
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={startTimer}>开始</button>
      <button onClick={stopTimer}>停止</button>
    </div>
  );
}

1.2 为什么修改 ref 不触发 re-render

React 的渲染决策由 state 和 props 变化驱动。Ref 被设计为"React 数据流之外的值"——它的变化不被视为需要重新渲染的信号。

从实现角度看:ref 只是一个普通的 JavaScript 对象 { current: initialValue },修改它的 .current 属性不会调用 setState,自然不会触发 re-render。

1.3 useRef 的初始化

useRef 接受一个初始值作为参数:

const ref = useRef(initialValue);
// ref.current === initialValue

这个初始值只在首次渲染时使用,后续渲染不会重置 ref——ref 的值在每次渲染之间持久化

function Counter() {
  const countRef = useRef(0);

  const handleClick = () => {
    countRef.current += 1;
    console.log('countRef.current:', countRef.current);
    // 点击多次后可以看到计数在增加
    // 但组件不会 re-render,所以 UI 不会更新
  };

  return <button onClick={handleClick}>点击 {countRef.current}</button>;
  // 显示的永远是初始值 0,因为没有 state 驱动 re-render
}

1.4 useRef vs useState:何时选择

场景 useRef useState
值的变化需要立即反映在 UI 上
值的变化不需要反映在 UI 上
需要保存定时器 ID
需要保存上一次渲染的 props
值变化后需要触发副作用 在 useEffect 中监听 ref 变化 useEffect 中监听 state 变化

一个常见误解:认为 useRef 比 useState "性能更好",所以应该优先使用。这是错误的——useRef 不触发 re-render,所以它根本不在 React 的渲染竞争模型中。如果你需要在 UI 上显示某个值,必须用 state;如果你需要在组件中存储一个值但不需要驱动 UI,useRef 是正确选择。


二、createRef vs useRef

2.1 两种创建 ref 的方式

// 类组件中
class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.inputRef = createRef(); // createRef
  }

  render() {
    return <input ref={this.inputRef} />;
  }
}

// 函数组件中
function MyComponent() {
  const inputRef = useRef(null); // useRef
  return <input ref={inputRef} />;
}

2.2 关键区别

createRef 每次渲染都会创建新的 ref 对象。在函数组件中使用 createRef 意味着每次渲染都会创建一个新的 { current: null } 对象,这会导致 ref 引用不稳定。

// 错误:在函数组件中使用 createRef
function MyComponent() {
  const inputRef = createRef(); // 每次渲染都创建新对象!

  useEffect(() => {
    inputRef.current.focus(); // focus() 可能在某些渲染中失效
  }, []);

  return <input ref={inputRef} />;
}

useRef 使用单例模式——它维护的 ref 对象在多次渲染之间保持稳定:

// 正确:在函数组件中使用 useRef
function MyComponent() {
  const inputRef = useRef(null); // 单例,整个组件生命周期内保持同一对象

  useEffect(() => {
    inputRef.current.focus(); // 稳定引用
  }, []);

  return <input ref={inputRef} />;
}

2.3 规则:函数组件用 useRef,类组件可以用 createRef

这个区别的工程含义是:

  • 函数组件:永远使用 useRef
  • 类组件createRef 或在构造函数中创建 this.ref = createRef() 都可以
// 类组件中 createRef 的正确用法
class MyComponent extends React.Component {
  inputRef = createRef(); // 类属性,整个生命周期稳定

  componentDidMount() {
    this.inputRef.current.focus();
  }

  render() {
    return <input ref={this.inputRef} />;
  }
}

三、forwardRef:ref 穿透多层组件

3.1 问题:ref 不能跨组件传递

默认情况下,ref 不能像 props 一样传递——如果你尝试将 ref 作为 prop 传给子组件,React 会忽略它并给出一个警告:

function Parent() {
  const inputRef = useRef(null);
  return <Child inputRef={inputRef} />; // ref 会被忽略!
}

function Child({ inputRef }) {
  return <input ref={inputRef} />; // 不会生效
}

这是因为 ref 在 React 看来是一个"命令式句柄",不应该像普通 props 一样流动。

3.2 forwardRef 的解决方案

forwardRef 允许组件接收 ref 作为第二个参数,并将其转发到内部的 DOM 节点:

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

function Parent() {
  const inputRef = useRef(null);
  return <Child inputRef={inputRef} />; // ref 现在可以正常传递
}

3.3 forwardRef 的工作原理

从实现角度,forwardRef 是一个 HOC(高阶组件),它接收一个组件并返回一个新组件。新组件的 render 方法中,ref 被作为第二个参数传入:

// forwardRef 的简化实现
function forwardRef(render) {
  return function ForwardRefComponent(props, ref) {
    return render(props, ref);
  };
}

3.4 ref 穿透的实际使用场景

场景一:暴露 DOM 节点给父组件

const Button = forwardRef((props, ref) => (
  <button ref={ref} className="btn" {...props} />
));

// 父组件可以直接操作 button 的 DOM
function Parent() {
  const buttonRef = useRef(null);
  return <Button ref={buttonRef}>点击</Button>;
}

场景二:High-Order Component 中的 ref 穿透

function withLogger(WrappedComponent) {
  const WithLogger = forwardRef((props, ref) => {
    useEffect(() => {
      console.log('Component mounted');
    }, []);
    return <WrappedComponent ref={ref} {...props} />;
  });
  return WithLogger;
}

如果不使用 forwardRef,HOC 会"吃掉" ref,导致父组件无法访问底层组件的 ref。

3.5 ref 的类型定义

TypeScript 中使用 forwardRef 时,需要注意 ref 的类型:

// DOM ref
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
  <input ref={ref} {...props} />
));

// 组件实例 ref(罕见场景)
interface FancyButtonHandle {
  focus(): void;
  scrollIntoView(): void;
}

const FancyButton = forwardRef<FancyButtonHandle, ButtonProps>((props, ref) => (
  <button ref={ref} {...props} />
));

四、useImperativeHandle:自定义暴露给父组件的方法

4.1 问题:forwardRef 暴露了整个 DOM 节点

有时候你不想把整个 DOM 节点暴露给父组件——这会导致父组件可以执行任何 DOM 操作(如 remove()replaceWith()),这在封装性上是危险的。

// 使用 forwardRef 后,父组件获得了完整的 DOM 控制权
const Input = forwardRef((props, ref) => <input ref={ref} {...props} />);

function Parent() {
  const inputRef = useRef(null);
  return (
    <>
      <Input ref={inputRef} />
      <button onClick={() => inputRef.current.remove()}>删除输入框</button>
      {/* 危险!父组件不应该能删除子组件的 DOM */}
    </>
  );
}

4.2 useImperativeHandle 的解决方案

useImperativeHandle 允许你自定义暴露给父组件的 ref 的内容:

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

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    value: () => inputRef.current.value,
    // 只暴露这两个方法,父组件无法执行其他 DOM 操作
  }));

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

4.3 使用场景

场景一:封装第三方控件

某些第三方输入控件有自己独特的 API,通过 useImperativeHandle 可以将这些 API 适配到 ref 接口:

const DatePicker = forwardRef((props, ref) => {
  const pickerRef = useRef(null);

  useImperativeHandle(ref, () => ({
    getValue: () => pickerRef.current?.getSelectedDate(),
    setValue: (date) => pickerRef.current?.setDate(date),
    open: () => pickerRef.current?.showPicker(),
    close: () => pickerRef.current?.hidePicker(),
  }));

  return <ThirdPartyDatePicker ref={pickerRef} {...props} />;
});

场景二:创建命令式 API

某些组件需要提供命令式 API(如视频播放器需要 play()pause()):

const VideoPlayer = forwardRef((props, ref) => {
  const videoRef = useRef(null);

  useImperativeHandle(ref, () => ({
    play: () => videoRef.current?.play(),
    pause: () => videoRef.current?.pause(),
    seek: (time) => { videoRef.current.currentTime = time; },
  }));

  return <video ref={videoRef} src={props.src} />;
});

4.4 注意事项

useImperativeHandle 配合 forwardRef 使用才有意义。单独使用 useImperativeHandle(没有 forwardRef)没有效果,因为父组件根本无法传递 ref。

// 正确用法
const Input = forwardRef((props, ref) => {
  useImperativeHandle(ref, () => ({ /* ... */ }));
  return <input />;
});

// 错误用法:没有 forwardRef,useImperativeHandle 毫无意义
const Input = (props) => {
  useImperativeHandle(props.inputRef, () => ({ /* ... */ })); // 毫无意义
  return <input />;
};

五、ref 在 DOM 操作之外的使用场景

5.1 存储上一次的 props 或 state

有时候你需要"记住上一次渲染时的某个值",这在 class 组件中用实例属性轻松实现,在函数组件中可以用 ref 实现:

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value; // 每次渲染后更新
  });

  return ref.current; // 返回上一次的值
}

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

  return (
    <div>
      <p>当前: {count}, 上次: {previousCount}</p>
      <button onClick={() => setCount(c => c + 1)}>增加</button>
    </div>
  );
}

注意这个 pattern 有局限性:它依赖 useEffect 同步更新,所以在同一次渲染中 previous 值是滞后的。如果需要在渲染阶段就获取 previous 值,需要用其他方式。

5.2 存储定时器 ID

定时器 ID 存储在 ref 中是最自然的选择——定时器 ID 只是为了后续清除,不需要驱动 UI:

function AutoCounter() {
  const [count, setCount] = useState(0);
  const timerRef = useRef(null);

  const start = () => {
    timerRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
  };

  const stop = () => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
  };

  useEffect(() => {
    return () => clearInterval(timerRef.current); // 清理函数
  }, []);

  return (
    <>
      <p>{count}</p>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
    </>
  );
}

5.3 避免 useEffect 依赖频繁变化的对象

当 useEffect 依赖一个高频变化的对象时,可以将这个对象存储在 ref 中,通过 ref 的变化来触发副作用:

function SearchComponent({ query }) {
  const queryRef = useRef(query);
  const [results, setResults] = useState([]);

  useEffect(() => {
    queryRef.current = query; // 更新 ref
    // 发起搜索请求
    fetchSearch(query).then(setResults);
  }, [query]); // query 变化触发 effect

  // 如果我们不需要 query 作为依赖...
  // 可以用 ref 存储 query,在 effect 内部读取 queryRef.current
}

六、ref 转发和 callback ref

6.1 callback ref:更灵活的 ref 赋值方式

除了 ref={refVar} 这种赋值方式,React 还支持 callback ref——一个接收 DOM 节点作为参数的函数:

function CallbackRefExample() {
  const [isFocused, setIsFocused] = useState(false);

  const setInputRef = (node) => {
    if (node) {
      // DOM 节点挂载时调用
      node.focus();
      node.addEventListener('focus', () => setIsFocused(true));
      node.addEventListener('blur', () => setIsFocused(false));
    }
  };

  return (
    <div>
      <input ref={setInputRef} />
      <p>{isFocused ? '输入框获得焦点' : '输入框未获得焦点'}</p>
    </div>
  );
}

6.2 callback ref vs 对象 ref

特性 对象 ref callback ref
API ref={myRef} ref={(node) => { ... }}
灵活性 固定 可自定义逻辑
多次回调 不适用 DOM 挂载/卸载时各调用一次
适用场景 一般 DOM 引用 需要在挂载时执行逻辑

6.3 混用 forwardRef 和 callback ref

forwardRef 接收的 ref 参数同样可以是 callback ref:

const Input = forwardRef((props, ref) => {
  // ref 可能是一个回调函数,也可能是一个 ref 对象
  return <input ref={ref} {...props} />;
});

function Parent() {
  const setInputRef = (node) => {
    if (node) {
      console.log('Input mounted:', node);
    }
  };

  return <Input ref={setInputRef} />;
}

七、面试高频问题

Q: useRef 和 useState 的区别是什么?

回答要点:两者都是用来存储组件数据的方式,但行为完全不同。useState 的变化会触发组件 re-render,UI 会更新;useRef 的变化不会触发 re-render。useRef 本质上是一个 mutable container,它的 .current 属性可以存储任何值,修改它不会让 React 重新渲染组件。useRef 适合存储那些"不需要显示在 UI 上"的值,如定时器 ID、上一次渲染的 props 值等;useState 适合存储那些"变化后需要更新 UI"的值。

Q: forwardRef 的作用是什么?

回答要点:默认情况下,ref 不能像 props 一样传递给子组件——这是 React 的设计决策,因为 ref 是一个命令式句柄。forwardRef 允许组件接收 ref 作为第二个参数并将其转发到内部的 DOM 节点。这在创建可复用的 UI 组件(如 Button、Input)时非常有用——父组件可以通过 ref 直接操作这些 DOM 节点。但需要注意:不加限制地暴露 ref 会破坏组件的封装性,可以使用 useImperativeHandle 限制暴露的内容。

Q: useImperativeHandle 是什么?什么场景下需要用?

回答要点:useImperativeHandle 允许你自定义通过 ref 暴露给父组件的内容。正常情况下,如果一个组件使用 forwardRef 接收 ref,它会将这个 ref 转发给内部的 DOM 节点,父组件就获得了完整的 DOM 操作能力。useImperativeHandle 允许你在 ref 中放置一个自定义对象,只暴露必要的方法(如 focus()scrollIntoView()),而不是整个 DOM 节点。典型使用场景包括封装第三方控件、创建命令式 API(如视频播放器的 play/pause)等。


延展阅读