React 闭包内存泄漏验证
先看问题代码
// 模块级注册表,生命周期 = 整个应用运行期
const callbackRegistry: Array<() => void> = []function MyComponent() {useEffect(() => {const payload = new LeakedPayload() // 10万元素的大对象const handler = () => console.log(payload.data.length)callbackRegistry.push(handler)// 没写 cleanup,handler 永远留在 registry 里}, [])
}
引用链:
callbackRegistry → handler(闭包) → payload → LeakedPayload
组件卸载了,callbackRegistry 引用 handler,handler 闭包又引用 payload。GC 不回收。
那 Promise 呢?
useEffect(() => {const payload = new LeakedPayload()fetch('/api').then(() => console.log(payload.data.length))
}, [])
Promise resolve 后,.then() 的回调执行完就没人引用了,GC 随时可以回收。
区别就一句话:闭包是不是被一个长期引用抓住了。
Heap Snapshot 验证
怎么操作
- 挂载组件,然后卸载
- DevTools → Memory → Heap Snapshot
- Class filter 搜
LeakedPayload - 重复几次,看实例数是不是只涨不跌
- 清空注册表,再拍一次快照,实例应该消失
实测结果

| 快照 | 操作 | LeakedPayload 实例数 |
|---|---|---|
| 1 | 挂载 → 卸载 | +1 |
| 2 | 挂载 → 卸载 | +1(累计 2) |
| 3 | 挂载 → 卸载 | +1(累计 3) |
| 4 | 清空注册表 | 全没了 |
点开实例,Retainers 会显示:
LeakedPayload← payload (闭包变量)← handler ()← callbackRegistry (模块作用域的数组)
修复
useEffect(() => {const payload = new LeakedPayload()const handler = () => console.log(payload.data.length)callbackRegistry.push(handler)// FIXED: cleanup 把引用断开return () => {const idx = callbackRegistry.indexOf(handler)if (idx > -1) callbackRegistry.splice(idx, 1)}
}, [])
被外部持有的引用,cleanup 时都得释放。
现实中类似的坑
WebSocket 回调
const wsManager = {listeners: new Map<string, (data: any) => void>(),on(event: string, cb: (data: any) => void) { this.listeners.set(event, cb) },off(event: string) { this.listeners.delete(event) },
}function ChatRoom({ roomId }: { roomId: string }) {const [messages, setMessages] = useState<Message[]>([])useEffect(() => {wsManager.on('message', (data) => {setMessages(prev => [...prev, data])})// 忘了 wsManager.off}, [roomId])
}
切 roomId 时,旧回调还赖在 wsManager.listeners 里,闭包里的 messages 被锁死。
Redux / Zustand 订阅
function UserPanel() {const [user, setUser] = useState<User | null>(null)useEffect(() => {store.subscribe((state) => {setUser(state.user)renderExpensiveProfile(state.user)})// 没有 unsubscribe}, [])
}
store 是全局单例,你往它身上挂回调,它就抓着不撒手。
埋点 SDK
analytics.onPageView((meta) => {trackWithPageContext(pageData, meta)
})function ReportPage() {const pageData = useFetchHugeReport()useEffect(() => {analytics.onPageView((meta) => {trackWithPageContext(pageData, meta)})}, [pageData])
}
pageData 每次变化都往 SDK 里塞新回调,旧回调带着旧数据赖着不走。
IntersectionObserver
function LazyImage({ src }: { src: string }) {const imgRef = useRef<HTMLImageElement>(null)useEffect(() => {const observer = new IntersectionObserver(([entry]) => {if (entry.isIntersecting) {loadImage(src).then(data => processImage(data))}})if (imgRef.current) observer.observe(imgRef.current)}, [])
}
Observer 内部也抓着回调闭包。记得 disconnect()。
