useImperativeHandle,useRef,forwardRef的协作关系

25 阅读3分钟

背景:面试中面试官问到了组件通信,回答了props,useContext,组件库,面试官问我兄弟组件通信,提到这个没听说过的api,后续去了解一下useImperativeHandle

一、 角色分工:它们分别解决什么问题?

在正式写代码之前,我们先用一句话给这三个工具定性:

  1. useRef(创建者) :React Hook在组件内部使用,用来放 DOM 节点或者不需要触发重新渲染的变量。
  2. forwardRef(传话筒) :React 默认不让父组件直接拿子组件的 Ref。forwardRef 包裹组件,允许组件接收父组件传过来的 ref,并向下传递给子组件或 DOM 元素。
  3. useImperativeHandle(防盗门/过滤器) :如果子组件被 forwardRef 暴露了,父组件就能对它“为所欲为”。useImperativeHandle 的作用就是限制父组件的权限,只允许父组件调用子组件明确暴露的几个方法。

二、 经典实战:兄弟组件的“跨界”通讯

在 React 中,兄弟组件由于没有直接的血缘关系,是无法直接对话的。

假设我们有一个场景:哥哥组件(ControlPanel 控制面板)里面有一个按钮,点击之后,需要让弟弟组件(Player 播放器)开始播放音乐。

如果用传统的“状态提升”,会导致父组件频繁重新渲染。今天我们用 Ref 全家桶 来实现一个高性能、精准控制的兄弟通信。

1. 弟弟组件:用 useImperativeHandle 打造安全接口

首先,弟弟组件(播放器)内部有自己的播放状态。它通过 forwardRef 接受父亲传来的管道,并用 useImperativeHandle 锁住自己,只暴露 startPlaystopPlay 两个接口。

import React, { useState, forwardRef, useImperativeHandle } from 'react';

// 1. 使用 forwardRef 让子组件可以接收 ref 
const Player = forwardRef((props, ref) => {
  const [isPlaying, setIsPlaying] = useState(false);

  // 2. 使用 useImperativeHandle 限制暴露的内容
  useImperativeHandle(ref, () => {
    return {
      startPlay() {
        setIsPlaying(true);
        console.log("播放器:收到指令,开始播放...🎵");
      },
      stopPlay() {
        setIsPlaying(false);
        console.log("播放器:收到指令,暂停播放...⏸");
      }
    };
  });

  return (
    <div style={{ border: '2px solid #007acc', padding: '15px', borderRadius: '8px' }}>
      <h3>子组件:弟弟(Player 播放器)</h3>
      <p>播放状态:{isPlaying ? '🎵 正在播放 Music...' : '⏸ 已暂停'}</p>
    </div>
  );
});

export default Player;

2. 哥哥组件:单纯的触发者

哥哥组件非常纯粹,它不需要知道弟弟是怎么播放音乐的,它只需要从父组件那里拿到一个“触发按钮的回调”即可。

import React from 'react';

function ControlPanel({ onPlayClick }) {
  return (
    <div style={{ border: '2px solid #228b22', padding: '15px', borderRadius: '8px', marginBottom: '15px' }}>
      <h3>子组件:哥哥(ControlPanel 控制面板)</h3>
      <button onClick={onPlayClick} style={{ padding: '8px 16px', cursor: 'pointer' }}>
        命令弟弟:播放音乐
      </button>
    </div>
  );
}

export default ControlPanel;

3. 父组件:牵线搭桥的“调度中心”

父组件就像是一个接线员。它用 useRef 生成一个遥控器(playerRef),把遥控器插在弟弟身上,然后把“按遥控器”的动作(handleBrotherClick)交给哥哥。

JavaScript

import React, { useRef } from 'react';
import ControlPanel from './ControlPanel';
import Player from './Player';

function App() {
  // 1. 创建一个 ref 容器,充当遥控器
  const playerRef = useRef(null);

  // 2. 哥哥点击时的响应函数
  const handleBrotherClick = () => {
    // 4. 精准调用弟弟暴露出来的核心方法
    if (playerRef.current) {
      playerRef.current.startPlay(); 
    }
  };

  return (
    <div style={{ padding: '20px', maxWidth: '500px', margin: '50px auto', boxShadow: '0 0 10px #ccc' }}>
      <h2>父组件(调度中心)</h2>
      <hr />
      {/* 3. 把触发权限给哥哥 */}
      <ControlPanel onPlayClick={handleBrotherClick} />
      
      {/* 3. 把遥控器绑定给弟弟 */}
      <Player ref={playerRef} />
    </div>
  );
}

export default App;

三、 深度思考:为什么不直接用 Props(状态提升)?

看完这个例子,你可能会想:“我直接在父组件定义一个 isPlaying 的状态,然后用 props 传给弟弟不就行了吗?”

确实可以,那是标准的 React 单向数据流做法。但使用 useImperativeHandle 有两个无法替代的优势:

  1. 极致的渲染性能:如果使用 Props 改变状态,哥哥点击 -> 父组件 State 改变 -> 父组件及旗下所有子组件全部重新渲染。而使用 Ref 方式,哥哥点击后,直接通过引用唤醒弟弟,父组件完全不触发重新渲染
  2. 完美的组件封装(高内聚) :播放器的“播放”和“暂停”涉及很多内部逻辑(比如音频上下文初始化、计时器重置等)。如果全部提升到父组件,父组件会变得极其臃肿。通过 useImperativeHandle逻辑依然保留在子组件内部,父组件只需要负责“下达命令”