~/ ?.log $
返回文章列表
15 min read
更新于 2026年3月6日

我不知道的 React(07)— 组件通信与自定义 Hooks 实战

React 组件之间要共享数据,方式不止一种。从最常见的 Props 到 Context,再到 Ref 暴露子组件方法,每一种都有各自的适用场景和陷阱。但组件通信只是前半段——当多个组件反复写着相同的状态逻辑时,如何把这些逻辑抽离成可复用的单元,才是 Hooks 体系真正的威力…

React 组件之间要共享数据,方式不止一种。从最常见的 Props 到 Context,再到 Ref 暴露子组件方法,每一种都有各自的适用场景和陷阱。但组件通信只是前半段——当多个组件反复写着相同的状态逻辑时,如何把这些逻辑抽离成可复用的单元,才是 Hooks 体系真正的威力所在。

这篇文章前半段快速过一遍通信路径,重点讲 forwardRef + useImperativeHandle 这个容易误用的组合;后半段逐个拆解三个经典自定义 Hook 的设计思路。

一、组件通信的几条路径

React 的数据流是单向的,父组件通过 Props 向下传递数据,子组件通过回调函数向上通知变化。这是最基础也最推荐的通信方式,因为数据流向清晰可追踪。

当数据需要跨越多层组件传递时,逐层透传 Props 会让中间层组件承载大量与自身无关的属性,这就是所谓的 “Props Drilling”。Context API 就是为解决这个问题而设计的——它允许数据跳过中间层,直接从 Provider 到达任意深度的 Consumer。但 Context 不是万能的,它有一个很多人会忽略的代价:当 Context 的值发生变化时,所有消费该 Context 的组件都会 re-render,无论它们是否用到了变化的那部分数据。

说白了,Props 和 Callback 解决的是”数据往哪走”的问题,Context 解决的是”数据走多远”的问题。但还有一类场景这两者都覆盖不了:父组件想直接调用子组件的方法,或者直接读取子组件内部的 DOM 节点。这就轮到 Ref 出场了。

二、Ref 的进阶用法:forwardRef 与 useImperativeHandle

useRef 在类组件时代叫 createRef,最初的用途很单纯——拿到一个 DOM 节点的引用,做聚焦、滚动、测量尺寸等操作。但当你把 ref 直接放到一个函数组件上时,问题来了。

function TextInput() {
  return <input type="text" />;
}

function Parent() {
  const inputRef = useRef(null);
  // inputRef.current 始终是 null
  return <TextInput ref={inputRef} />;
}

这段代码不会报错,但 inputRef.current 永远是 null。原因在于:函数组件没有实例,ref 无处可挂。React 会在控制台给出警告,提示你使用 forwardRef

forwardRef 的作用是让函数组件能够”转发”父组件传入的 ref,将它绑定到内部的某个 DOM 节点上:

const TextInput = forwardRef(function TextInput(props, ref) {
  return <input ref={ref} type="text" />;
});

function Parent() {
  const inputRef = useRef(null);
  return (
    <>
      <TextInput ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>聚焦输入框</button>
    </>
  );
}

父组件通过 inputRef.current 直接拿到了子组件内部的 <input> DOM 节点,可以调用 focus() 等原生方法。这看起来很方便,但问题也随之而来——父组件获得了子组件内部 DOM 的完全控制权,它可以修改样式、移除节点、做任何 DOM 操作。这严重破坏了组件封装。

useImperativeHandle 就是为了解决这个问题。它允许子组件精确控制通过 ref 暴露给父组件的接口,而不是把整个 DOM 节点交出去:

const TextInput = forwardRef(function TextInput(props, ref) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    clear: () => {
      inputRef.current.value = '';
    },
  }));

  return <input ref={inputRef} type="text" />;
});

现在父组件只能调用 focus()clear() 两个方法,访问不到底层的 <input> 元素本身。这段代码有一个关键设计:子组件内部用自己的 inputRef 持有 DOM 引用,通过 useImperativeHandle 只暴露方法。父组件拿到的不是 DOM 节点,而是一个受控的 API 接口。

如果你只记住一句话:forwardRef 解决的是”ref 传不进函数组件”的问题,useImperativeHandle 解决的是”传进去之后暴露太多”的问题。 两者搭配使用才是完整方案。

但即便有了 useImperativeHandle,Ref 通信仍然应该是最后手段。它绕过了 React 的单向数据流,让父子组件之间产生了隐式的命令式耦合。能用 Props + Callback 解决的,就不要用 Ref。

三、自定义 Hooks:逻辑复用的正确姿势

当多个组件中出现了结构相似的状态逻辑——同样的 useState + useEffect 组合、同样的事件监听和清理、同样的防抖延迟处理——把它们提取成自定义 Hook 是 React 推荐的复用方式。

自定义 Hook 的本质非常简单:它就是一个以 use 开头的普通函数,内部调用了其他 Hook。 不需要继承、不需要装饰器、不需要高阶组件,函数调用就完成了逻辑复用。下面通过三个经典的自定义 Hook 来看看”为什么这样设计”。

useDebounce:为什么返回值而不是函数

防抖是前端高频需求。很多人的第一反应是封装一个 useDebouncedCallback,返回一个经过防抖包装的函数。但 React 社区更常见的做法是返回一个防抖后的值:

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

用法是这样的:

function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      fetchResults(debouncedQuery);
    }
  }, [debouncedQuery]);

  return <input onChange={(e) => setQuery(e.target.value)} />;
}

为什么选择返回值而不是函数?这和 React 的数据流模型有关。在 React 中,UI 是状态的函数。debouncedQuery 是一个状态值,当它变化时会自然触发依赖它的 useEffect 重新执行——这完全符合声明式的思维方式。而如果返回的是一个防抖函数,调用方需要在回调中手动触发,属于命令式风格,和 React 的 Hook 范式格格不入。

说白了,useDebounce 返回的不是”一个延迟执行的动作”,而是”一个稳定在最新值的状态”。

很多人会忽略的细节是清理函数的重要性:每次 value 变化都会创建新的定时器,但 useEffect 的清理函数会在下一次 effect 执行前取消上一个定时器。这保证了只有最后一次输入停下来后,debouncedValue 才会更新。如果忘了 clearTimeout,快速输入时每个中间值都会触发更新,防抖就完全失效了。

usePrevious:利用 useRef 和 useEffect 的执行时序

有时候需要知道某个状态”上一次”的值,比如做动画过渡、比较前后变化。React 没有内置这个能力,但用 useRef + useEffect 只需要几行代码就能实现:

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

  useEffect(() => {
    ref.current = value;
  });

  return ref.current;
}

这段代码看起来太简单了,以至于很多人看完不确定它为什么能工作。秘密在于 useRefuseEffect 的执行时序。

当组件 re-render 时,函数体从头到尾同步执行。此时 ref.current 还是上一次的值(因为 useEffect 的回调尚未执行),所以 return ref.current 返回的是旧值。等到 render 完成、DOM 更新之后,useEffect 的回调才执行,把 ref.current 更新为当前值。

换句话说,usePrevious 利用的是 useEffect 比 render 晚一拍的特性。 render 时读取的是上一轮写入的值,render 后才写入本轮的新值——天然地形成了一个”延迟一帧”的效果。

这里选用 useRef 而不是 useState 也是刻意的。useRef 的更新不触发 re-render,它只是一个可变的容器。如果用 useState,每次更新前一个值都会触发额外的 re-render,不仅浪费性能,还可能陷入无限循环。

useEventListener:为什么用 savedHandler ref

window 或其他 DOM 元素添加事件监听是常见操作,但在 React 中很容易写出 bug——忘了清理、闭包捕获了旧的 handler、每次 render 都重新绑定。useEventListener 就是为了把这些细节封装起来:

function useEventListener(eventName, handler, element = window) {
  const savedHandler = useRef();

  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    const listener = (event) => savedHandler.current(event);
    element.addEventListener(eventName, listener);
    return () => element.removeEventListener(eventName, listener);
  }, [eventName, element]);

  return null;
}

这个 Hook 有一个设计上非常值得琢磨的地方:为什么要用 savedHandler 这个 ref 做中间层,而不是直接把 handler 传给 addEventListener

如果直接使用 handler,那么第二个 useEffect 的依赖数组就必须包含 handler。而在大多数使用场景中,传入的 handler 是一个内联函数——每次 render 都会创建新的函数引用。结果就是每次 render 都先 removeEventListeneraddEventListener,频繁地绑定和解绑事件监听器。

通过 savedHandler ref,事件监听器只绑定一次。 每次 render 时只更新 ref 的指向(第一个 useEffect),而实际绑定在 DOM 上的 listener 始终是同一个函数——它在触发时读取 savedHandler.current,永远能拿到最新的 handler。

这个模式的核心思想是:把”需要保持最新”的引用和”需要保持稳定”的引用分开管理。 ref 负责”最新”,依赖数组负责”稳定”。这个技巧在很多自定义 Hook 中都会用到,理解它就理解了 Hooks 闭包问题的通用解法。

四、自定义 Hook 的设计原则

写过几个自定义 Hook 之后,一些设计原则会自然浮现。

use 开头不只是命名约定,而是 React 的强制约束。 React 的 lint 规则通过函数名判断它是否是 Hook,从而检查 Hook 调用规则(不能在条件语句中调用、不能在循环中调用)。如果一个使用了 useState 的函数不以 use 开头,eslint-plugin-react-hooks 不会对它进行 Hook 规则检查,隐蔽的 bug 就可能溜进来。

自定义 Hook 的返回值设计也值得考虑。如果只返回一个值,直接返回即可;如果返回一对”值 + 更新函数”,用数组(类似 useState),方便调用方自定义命名;如果返回多个相关的值或方法,用对象,语义更清晰。

另一个容易被忽视的点是依赖项的稳定性。自定义 Hook 的参数如果是对象或函数,每次调用可能传入新的引用,导致内部 useEffect 反复执行。前面 useEventListener 用 ref 解决 handler 的稳定性问题就是一个典型范例。设计 Hook 接口时,要提前预判调用方可能传入不稳定的引用,并在 Hook 内部做好防御。

说白了,自定义 Hook 的终极目标是让调用方感觉不到复杂性——只需要传入参数、拿到返回值,中间的状态管理、副作用清理、引用稳定性全部被 Hook 内部消化。

五、总结

组件通信是 React 应用的基础骨架。Props + Callback 覆盖绝大多数场景,Context 解决跨层级传递,Ref 系列(forwardRef + useImperativeHandle)处理需要命令式交互的特殊情况。三条路径各有代价,Ref 应作为最后手段。

自定义 Hook 是 Hooks 体系中逻辑复用的核心手段。useDebounce 展示了”返回值而非函数”的声明式思维,usePrevious 利用了 useEffect 的执行时序,useEventListener 演示了 ref 分离稳定引用与最新引用的经典模式。

理解自定义 Hook 不在于能写出多少个,而在于理解背后的设计考量——闭包、执行时序、引用稳定性。 掌握了这些原理,面对任何逻辑复用需求都能设计出合理的 Hook。


本系列其他文章:

相关主题:

share.ts

// 觉得这篇文章有帮助?

// 欢迎分享给更多人

export const  subscribe  =  "/rss.xml" ;