使用 Codex 开发 React、Next.js 后台或实时页面时,经常会遇到一种很难第一时间定位的问题:

刚打开页面时非常流畅,但使用一段时间后越来越卡。

尤其是反复:

进入页面
→ 离开页面
→ 再进入
→ 再离开

几次以后,可能出现:

  • 浏览器内存持续上涨;

  • 同一个事件触发两三次;

  • WebSocket 消息重复处理;

  • 定时器数量越来越多;

  • 页面已经关闭,请求还在继续;

  • 切换路由以后旧组件仍然在执行逻辑;

  • Codex 修复时不断增加 useEffect,问题反而越来越明显。

这类问题经常和一个核心概念有关:

副作用创建了,但没有在组件卸载时正确清理。


一、最典型的问题:事件监听只加不删

例如:

useEffect(() => {
  window.addEventListener(
    "resize",
    handleResize
  );
}, []);

第一次进入页面:

1个 resize listener

如果组件卸载后监听器没有移除,再次进入又增加一个:

2个
3个
4个
...

最终用户只调整一次窗口大小:

handleResize()

却可能执行很多次。

正确方式通常是:

useEffect(() => {
  window.addEventListener(
    "resize",
    handleResize
  );

  return () => {
    window.removeEventListener(
      "resize",
      handleResize
    );
  };
}, []);

useEffect 返回的函数就是清理函数。

组件卸载时 React 会执行它。


二、removeEventListener必须使用同一个函数引用

下面这种代码看起来有清理:

useEffect(() => {
  window.addEventListener(
    "scroll",
    () => handleScroll()
  );

  return () => {
    window.removeEventListener(
      "scroll",
      () => handleScroll()
    );
  };
}, []);

但实际上很可能删不掉。

因为:

() => handleScroll()

每次都会创建一个新的函数对象。

注册的是函数 A。

删除时传进去的却是函数 B。

更合理:

useEffect(() => {
  const listener = () => {
    handleScroll();
  };

  window.addEventListener(
    "scroll",
    listener
  );

  return () => {
    window.removeEventListener(
      "scroll",
      listener
    );
  };
}, []);

监听和清理必须使用同一个引用。


三、setInterval是非常常见的泄漏来源

例如:

useEffect(() => {
  setInterval(() => {
    refreshStatus();
  }, 5000);
}, []);

组件离开以后,Timer 仍然可能继续执行。

再次进入页面,又创建新的 Timer。

最终:

第一次进入:1个Timer
第二次进入:2个Timer
第三次进入:3个Timer

接口请求也跟着成倍增加。

应该保存 Timer:

useEffect(() => {
  const timer = setInterval(() => {
    refreshStatus();
  }, 5000);

  return () => {
    clearInterval(timer);
  };
}, []);

类似需要清理的还有:

setTimeout
requestAnimationFrame
第三方SDK Timer
轮询任务

四、组件卸载后请求也可能还在运行

例如:

useEffect(() => {
  async function loadData() {
    const response =
      await fetch("/api/orders");

    const data =
      await response.json();

    setOrders(data);
  }

  loadData();
}, []);

如果接口需要5秒,而用户1秒后已经切换页面:

组件卸载
↓
HTTP请求仍然运行
↓
4秒后返回

这个结果已经没有价值。

更合理的方式是使用:

AbortController

例如:

useEffect(() => {
  const controller =
    new AbortController();

  async function loadData() {
    const response = await fetch(
      "/api/orders",
      {
        signal:
          controller.signal
      }
    );

    const data =
      await response.json();

    setOrders(data);
  }

  loadData().catch(error => {
    if (
      error.name !== "AbortError"
    ) {
      throw error;
    }
  });

  return () => {
    controller.abort();
  };
}, []);

组件离开时:

取消不再需要的请求

既减少资源浪费,也减少旧请求影响新页面状态的风险。


五、WebSocket订阅必须取消

例如:

useEffect(() => {
  socket.on(
    "order_updated",
    handleOrderUpdated
  );
}, []);

如果没有:

socket.off(...)

每次进入页面都可能再绑定一次。

最后一条消息到达:

handleOrderUpdated
handleOrderUpdated
handleOrderUpdated

执行多次。

应该成对出现:

useEffect(() => {
  socket.on(
    "order_updated",
    handleOrderUpdated
  );

  return () => {
    socket.off(
      "order_updated",
      handleOrderUpdated
    );
  };
}, []);

需要记住一个很实用的原则:

subscribe
必须对应
unsubscribe

六、第三方组件也可能需要destroy

例如地图、图表、编辑器:

useEffect(() => {
  const chart =
    new Chart(container, options);
}, []);

如果第三方库内部创建了:

  • DOM;

  • Timer;

  • Observer;

  • Canvas;

  • 全局监听;

React卸载组件并不一定会自动帮它全部清理。

很多库会提供:

destroy()
dispose()
disconnect()
remove()

例如:

useEffect(() => {
  const chart =
    createChart(container);

  return () => {
    chart.destroy();
  };
}, []);

使用 Codex 集成第三方 SDK 时,最好明确问:

这个实例卸载时需要调用什么清理方法?


七、Observer同样需要disconnect

例如:

const observer =
  new ResizeObserver(() => {
    updateSize();
  });

observer.observe(element);

离开页面后应该:

observer.disconnect();

类似的还有:

MutationObserver
IntersectionObserver
ResizeObserver

如果这些对象不断累积,会持续持有 DOM 或回调引用。


八、Effect依赖变化时,旧Effect也需要先清理

假设:

useEffect(() => {
  socket.subscribe(roomId);

  return () => {
    socket.unsubscribe(roomId);
  };
}, [roomId]);

roomId从:

room-1

变成:

room-2

React会先执行旧清理:

unsubscribe room-1

然后再执行新Effect:

subscribe room-2

这也是为什么 Effect 的清理不仅发生在组件卸载时。

依赖变化导致Effect重新执行前,也会清理上一轮副作用。


九、不要在Effect里重复创建全局实例

例如:

useEffect(() => {
  const socket =
    new WebSocket(url);

  ...
}, [user]);

只要 user 对象变化,就重新建立连接。

可能出现:

用户状态更新
↓
重建WebSocket
↓
旧连接清理不完整
↓
多个连接存在

如果 WebSocket 属于应用级资源,更适合放到:

Socket Manager
Context
全局服务层

页面组件只做订阅。

不要让一个频繁重渲染的组件决定全局连接生命周期。


十、闭包也可能让旧对象一直无法释放

例如某个回调捕获了巨大对象:

const largeData = ...;

useEffect(() => {
  const handler = () => {
    console.log(
      largeData.someValue
    );
  };

  window.addEventListener(
    "click",
    handler
  );
}, []);

如果监听器永远没有被移除,那么:

handler

仍然引用:

largeData

垃圾回收器就不能安全释放这部分内存。

这也是 JavaScript 内存泄漏中很常见的一种链路:

全局对象
→ Listener
→ Closure
→ 大对象

真正的泄漏不一定是“变量没有删除”,而是仍然存在可达引用。


十一、页面隐藏和组件卸载不是完全一样的概念

一些框架或缓存路由可能会保留组件状态。

例如:

页面暂时不可见

不一定意味着:

组件已经真正Unmount。

如果业务需要在页面不可见时停止:

视频
动画
轮询
实时订阅

还需要结合框架生命周期或:

document.visibilityState

进行控制。

不要只依赖卸载清理处理所有资源问题。


十二、开发环境为什么有时看起来“执行两次”?

React 开发环境的 Strict Mode 可能会帮助暴露副作用问题。

某些 Effect 在开发过程中可能经历类似:

执行
↓
清理
↓
再次执行

如果代码写成:

执行一次就会产生永久副作用

就很容易暴露问题。

不要简单为了“阻止执行两次”关闭检查。

应该确认:

Effect是否可以被安全建立和清理。

理想的 Effect 应该能够:

setup
→ cleanup
→ setup

而不会产生错误状态。


十三、不要用isMounted到处掩盖问题

有些代码会写:

let isMounted = true;

fetchData().then(data => {
  if (isMounted) {
    setData(data);
  }
});

return () => {
  isMounted = false;
};

这种方式有时可以阻止卸载后的状态更新。

但如果请求本身支持取消,更推荐:

直接取消请求。

因为 isMounted 只是:

忽略结果

请求仍然在消耗:

  • 网络;

  • 服务端资源;

  • 浏览器连接。

所以不要把 isMounted 当成所有异步清理问题的通用答案。


十四、缓存也可能让内存一直增长

例如:

const cache = new Map();

每请求一次就:

cache.set(id, result);

却从不:

删除
过期
限制大小

那么它并不是传统的 Effect 泄漏,但同样会让内存持续上涨。

前端或 Node.js 中的缓存都应该考虑:

TTL
LRU
最大条目数

不要让:

Map / Set

无限增长。


十五、如何判断页面是否真的存在内存泄漏?

可以使用 Chrome DevTools Memory。

一个简单测试方式:

打开页面
↓
记录内存
↓
离开页面
↓
手动触发GC / 等待
↓
再次进入
↓
重复5—10次

如果内存:

100MB
→ 130MB
→ 165MB
→ 210MB

并且离开页面后也无法明显下降,就值得进一步检查。


十六、Heap Snapshot能找到谁还持有对象

Chrome Memory 中可以比较:

Snapshot 1
Snapshot 2
Snapshot 3

重点看:

Detached DOM nodes
Listener
Closure
Timer
Array
Map

例如发现:

UserPage组件

已经离开页面,但相关 DOM 仍然被:

window listener

引用。

这就能直接找到泄漏链路。

比仅仅观察任务管理器里的内存数字更有价值。


十七、监听器数量也可以做简单诊断

如果怀疑事件重复,可以在开发过程中观察:

进入页面1次
→ 点击一次执行1次

进入页面5次
→ 点击一次执行5次

这基本已经是非常明显的未清理信号。

类似现象还有:

一次WebSocket消息
→ Toast出现5次

一个resize
→ API调用5次

出现这种倍增规律时,优先检查:

监听器
订阅
Timer

有没有在卸载时释放。


十八、让Codex先做Effect资源审查

遇到页面越来越卡时,可以先这样要求:

请先不要修改代码。

检查当前组件中的所有useEffect,并输出:

1. Effect创建了什么资源;
2. 是否添加事件监听;
3. 是否创建Timer;
4. 是否创建网络请求;
5. 是否创建WebSocket或订阅;
6. 是否创建Observer或第三方实例;
7. 对应cleanup在哪里;
8. 依赖变化后旧资源是否会被释放;
9. 哪些Effect可能造成资源累积。

这种方式比一句:

帮我优化页面内存。

更容易让 Codex 找到真实问题。


十九、一个推荐的Effect检查表

看到:

useEffect(() => {
  // ...
}, []);

可以快速问:

有没有addEventListener?
→ removeEventListener了吗?

有没有setInterval?
→ clearInterval了吗?

有没有fetch?
→ 能abort吗?

有没有subscribe?
→ unsubscribe了吗?

有没有new实例?
→ destroy了吗?

有没有Observer?
→ disconnect了吗?

只要做到“创建和销毁成对出现”,很多内存问题都能提前避免。


二十、把清理规则写进AGENTS.md

# React Effect清理规则

- addEventListener必须对应removeEventListener
- setInterval / setTimeout必须评估清理
- 网络请求必须评估AbortController
- subscribe必须对应unsubscribe
- WebSocket事件监听必须成对解绑
- Observer必须在卸载时disconnect
- 第三方实例必须检查destroy / dispose API
- 全局资源禁止由频繁重建的组件随意创建
- Effect依赖变化时必须确认旧资源会正确释放
- 内存问题必须通过Memory Snapshot或重复挂载测试验证

这样 Codex 后续生成 React 组件时,会更关注副作用完整生命周期,而不是只负责“创建”。


二十一、Plus还是Pro?

如果主要使用 Codex 处理:

单个React组件
普通useEffect
事件监听
简单请求取消

Plus通常已经能够覆盖大多数任务。

如果长期处理:

大型React / Next.js项目
大量自定义Hooks
实时连接
复杂第三方SDK
多页面内存分析
长时间Profiler与Heap Snapshot排查

则可以根据实际开发强度评估 Pro。

对于复杂前端问题,更连续的上下文有利于同时分析组件树、Hooks、订阅和调用关系。

但无论使用哪个版本,都应该记住:

副作用创建成功,只完成了一半。它什么时候、在哪里、由谁销毁,同样属于功能的一部分。

总结

Codex 写 React 组件后,页面切换几次越来越卡,很多时候并不是 React 本身存在性能问题,而是:

事件监听
Timer
请求
订阅
Observer
第三方实例

在组件离开后仍然继续存在。

通过:

useEffect cleanup
AbortController
unsubscribe
clearInterval
disconnect
destroy

把资源创建与清理成对设计,可以显著减少内存增长和重复执行问题。

真正可靠的 React Effect 应该做到:

组件存在时资源正常工作,组件离开后与它相关的不必要资源也能够一起消失。

CSDN文章描述

本文介绍 Codex 编写 React 组件时常见的内存泄漏和重复监听问题,并通过 useEffect Cleanup、AbortController、Timer 清理、订阅解绑和 Heap Snapshot 提升页面长期运行稳定性。

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务,一站式满足开发者需求,帮助企业提升开发效率并创新开发模式。

更多推荐