JavaScript项目实战经验分享:从内存泄漏到性能优化的血泪史

0 阅读1分钟

去年接手一个中后台系统时,我遇到了一个诡异的现象:页面在频繁切换路由后,操作逐渐卡顿,最终浏览器标签崩溃。你以为这是React的经典内存泄漏问题?不,这次的主角是看似无害的事件监听器注册与清除。

现象:一个"安全"的封装为何导致内存泄漏?

项目基于React + TypeScript,封装了一个useGlobalEvent钩子用于监听全局事件。核心代码是这样的:

// 错误写法:看似封装完美实则埋雷
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是同一个函数?看这个调用方代码:

function SearchPage() {
  const [query, setQuery] = useState('')
  
  // 每次渲染都生成新的匿名函数
  useGlobalEvent('resize', () => {
    console.log(`当前查询值: ${query}`)
  })

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

由于handler依赖了状态query,每次输入变化都会触发组件渲染,生成新的函数实例。而useEffect的依赖项比较是浅比较,导致每次都会执行清理和重新绑定——实际上旧监听器并未被正确移除。

解法:函数稳定的三种武器

方案1:useCallback缓存

const handler = useCallback(() => {
  console.log(`当前查询值: ${query}`)
}, [query]) // 只有query变化时才更新引用

useGlobalEvent('resize', handler)

方案2:移出依赖项(危险但高效)

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:事件代理(适用于高频事件)

// 单例监听+发布订阅模式
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

避坑清单:事件监听的三重门

  1. 依赖项陷阱:永远记住useEffect的依赖项比较是浅比较,对象/函数必须显式缓存
  2. 清除顺序:组件卸载时React会先执行新effect的清理,再执行旧effect的清理(React 17+)
  3. 被动监听风险:resize/scroll等高频事件要特别小心,建议用防抖或单例监听
  4. SSR兼容:在服务端渲染时window不存在,需要做环境判断
  5. TS类型坑:事件参数类型容易被忽略,推荐使用Parameters<typeof window.addEventListener>

结语

下次当你封装自定义Hook时,不妨先问自己:这个函数引用真的稳定吗?我的依赖项数组是否诚实?有时候,最隐蔽的Bug就藏在那些"看起来肯定没问题"的代码里。

你在项目中是怎么处理事件监听的?有没有遇到过更奇葩的内存泄漏场景?欢迎在评论区分享你的实战案例。