我不知道的 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;
}
这段代码看起来太简单了,以至于很多人看完不确定它为什么能工作。秘密在于 useRef 和 useEffect 的执行时序。
当组件 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 都先 removeEventListener 再 addEventListener,频繁地绑定和解绑事件监听器。
通过 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。
本系列其他文章:
相关主题:
- useState 底层机制:批量更新与 Hook 底层机制
- 生命周期与 Render:生命周期演进与 Render 触发机制