去年接手一个中后台系统时,我遇到了一个诡异的现象:页面在频繁切换路由后,操作逐渐卡顿,最终浏览器标签崩溃。你以为这是React的经典内存泄漏问题?不,这次的主角是看似无害的事件监听器注册与清除。
现象:一个"安全"的封装为何导致内存泄漏?
项目基于React + TypeScript,封装了一个useGlobalEvent钩子用于监听全局事件。核心代码是这样的:
typescript
// 错误写法:看似封装完美实则埋雷
function useGlobalEvent(eventName: string, handler: EventListener) {
useEffect(() => {
window.addEventListener(eventName, handler)
return () => window.removeEventListener(eventName, handler)
}, [eventName, handler]) // 依赖项看似合理
}
在列表页快速切换筛选条件时(每次变化都会触发组件卸载),内存占用从初始的80MB飙升至1.2GB。用Chrome DevTools的Memory面板抓取堆快照后,发现EventListener对象数量异常积累。
根因:函数引用比你想的更狡猾
关键问题出在依赖项数组中的handler。你以为传入的handler是同一个函数?看这个调用方代码:
typescript
function SearchPage() {
const [query, setQuery] = useState('')
// 每次渲染都生成新的匿名函数
useGlobalEvent('resize', () => {
console.log(`当前查询值: ${query}`)
})
return <input onChange={e => setQuery(e.target.value)} />
}
由于handler依赖了状态query,每次输入变化都会触发组件渲染,生成新的函数实例。而useEffect的依赖项比较是浅比较,导致每次都会执行清理和重新绑定------实际上旧监听器并未被正确移除。
解法:函数稳定的三种武器
方案1:useCallback缓存
typescript
const handler = useCallback(() => {
console.log(`当前查询值: ${query}`)
}, [query]) // 只有query变化时才更新引用
useGlobalEvent('resize', handler)
方案2:移出依赖项(危险但高效)
typescript
function useGlobalEvent(eventName: string, handler: EventListener) {
const handlerRef = useRef(handler)
handlerRef.current = handler // 实时更新引用
useEffect(() => {
const wrapper = (e) => handlerRef.current(e)
window.addEventListener(eventName, wrapper)
return () => window.removeEventListener(eventName, wrapper)
}, [eventName]) // 移除handler依赖
}
方案3:事件代理(适用于高频事件)
typescript
// 单例监听+发布订阅模式
const eventHub = new EventTarget()
window.addEventListener('resize', () => {
eventHub.dispatchEvent(new CustomEvent('globalResize'))
})
function useGlobalEvent(eventName: string, handler: EventListener) {
useEffect(() => {
eventHub.addEventListener(eventName, handler)
return () => eventHub.removeEventListener(eventName, handler)
}, [eventName, handler]) // 安全,因为eventHub始终唯一
}
性能对比:选择比努力更重要
在1000次组件挂载/卸载的压测下:
- 原始方案:内存占用持续增长,操作延迟达到1200ms
- useCallback方案:内存稳定在150MB,延迟200ms
- ref方案:内存稳定在110MB,延迟180ms
- 事件代理方案:内存稳定在90MB,延迟50ms
避坑清单:事件监听的三重门
- 依赖项陷阱:永远记住useEffect的依赖项比较是浅比较,对象/函数必须显式缓存
- 清除顺序:组件卸载时React会先执行新effect的清理,再执行旧effect的清理(React 17+)
- 被动监听风险:resize/scroll等高频事件要特别小心,建议用防抖或单例监听
- SSR兼容:在服务端渲染时window不存在,需要做环境判断
- TS类型坑 :事件参数类型容易被忽略,推荐使用
Parameters<typeof window.addEventListener>
结语
下次当你封装自定义Hook时,不妨先问自己:这个函数引用真的稳定吗?我的依赖项数组是否诚实?有时候,最隐蔽的Bug就藏在那些"看起来肯定没问题"的代码里。
你在项目中是怎么处理事件监听的?有没有遇到过更奇葩的内存泄漏场景?欢迎在评论区分享你的实战案例。