先记住这个答案
React 19 允许 ref 回调返回一个清理函数,React 在移除该关联时调用它,例如节点卸载或 ref 回调换成新的函数。清理闭包可以直接保存本轮节点、监听函数和其他资源,形成明确的一次建立与对应释放。提供清理函数后,React 会使用它,而不是再以 null 调用该回调作为同一次清理;没有返回清理函数的旧式 null 处理仍有兼容行为。开发 Strict Mode 还会增加建立与清理检查,代码必须能够正确重建,不能假设回调只运行一次。
- 清理保存本轮节点与资源,避免错删新关联
- 回调身份变化也会先清理旧绑定
- 返回值应是清理函数或没有返回值
把监听函数保存在本次绑定的清理闭包里
如果释放时使用另一个新函数,removeEventListener 无法删除最初的监听。清理函数和建立逻辑写在一起,可以保留正确身份;同时它捕获的是本轮节点,不会误用某个已经指向新节点的外部 current。
示例的 label 变化会产生新的 attach 函数,因此即使按钮节点保留,也会先移除旧标签对应的监听再建立新监听。点击日志应使用当前标签,不能让旧监听继续存在而一次点击打印两种标签。
import { useCallback } from 'react';
type Props = { label: string };
export default function NativeNotice({ label }: Props) {
const attach = useCallback((node: HTMLButtonElement | null) => {
if (!node) return;
const notify = () => console.log('native', label);
node.addEventListener('click', notify);
console.log('attach', label);
return () => {
node.removeEventListener('click', notify);
console.log('cleanup', label);
};
}, [label]);
return <button ref={attach}>通知:{label}</button>;
}从标签 A 改为 B 时,旧监听被清理后重新绑定;节点卸载时释放当前监听。日志用于展示关联配对,不应把开发检查造成的额外建立与清理当成业务通知已经发送。
迁移时注意以前被忽略的返回值
旧代码常写成 node => map.set(id, node),表达式会隐式返回 Map 实例。React 19 的 ref 返回值有明确清理含义,类型检查会指出这类意外返回;应改为块体完成登记,并显式返回正确的删除逻辑或不返回值。
不能把业务处理结果、布尔值或节点本身当成清理函数返回。若共享组件还要支持旧 React,需要按支持矩阵设计兼容实现并分别测试,不能在 React 18 环境里期待新版本的清理函数契约自动生效。
重复建立暴露的是资源配对问题
Strict Mode 的额外检查会让缺失清理更容易暴露,正确实现应在检查后仍只有当前有效监听。不要为了让日志看起来只出现一次而取消清理;那会让真实卸载、条件分支和回调替换留下资源。
更复杂的观察器或第三方实例也遵循同样原则:本轮建立获得什么资源,清理就释放对应资源。若资源具有异步关闭过程,还需要单独定义旧实例与新实例交接策略,React 调用清理不等于任意外部系统都已立即完成关闭。
容易答错的地方
- 返回清理函数后还会自动收到一次 null 作为同次清理
- React 19 会使用返回的清理函数完成该关联的释放;不能把两条路径都当成必定执行,否则清理计数和兼容逻辑容易重复。
- useCallback 依赖为空就能让任何资源只建立一次
- 真实节点可能移除重建,开发检查也可能再次关联;而回调读取的配置若变化,应正确更新依赖,不能靠空数组保存过期配置。
面试官还会怎么问?
只更新父组件为什么有时也会触发 ref 清理?
如果父层每次创建新的 ref 函数,它的身份变化会导致旧关联清理和新关联建立。先检查回调身份,再判断是否真的替换了 DOM 节点。
清理应该从最新 ref.current 读取节点吗?
通常直接捕获建立时得到的节点更准确。最新 current 可能已经指向另一个关联,使用它释放旧资源会清错对象或遗漏旧监听。
可以把所有 onClick 都改成 ref 内的监听吗?
通常没有必要,React 事件属性已经表达普通点击业务。ref 资源绑定主要用于确实需要命令式集成的接口,否则会增加手动清理和维护成本。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。