起因
在开发个人网站时,我注意到一个奇怪的现象:运行 npm run dev 启动项目后,打开首页并保持不动,过一段时间后内存占用持续攀升。更令人困惑的是,CPU 占用率直接飙到了 100%。
第一轮排查:怀疑浏览器端组件
初步分析
首先,我将注意力集中在首页可见的所有组件上。首页是一个典型的个人作品集首屏,包含以下动态元素:
| 组件 | 动态行为 | 可疑程度 |
|---|---|---|
| FloatingParticles | Canvas 粒子动画,每帧创建/销毁对象 | ⭐⭐⭐⭐⭐ |
| HeroSection | 打字机效果,~12.5 次/秒重渲染 | ⭐⭐⭐⭐ |
| MouseGlow | requestAnimationFrame 持续循环 | ⭐⭐⭐ |
| TiltCard | 鼠标跟随倾斜效果 | ⭐⭐ |
发现的问题
1. FloatingParticles:每帧都在制造垃圾
原始实现中,每一帧都会执行以下操作:
// 每帧 filter 创建新数组
particles = particles.filter(p => p.life > 0);
// 死亡粒子用新对象替代
if (particles.length < maxParticles) {
particles.push(createParticle()); // 新建对象
}
这意味着每秒 60 帧 × N 个粒子 = 大量短生命周期对象,给 GC 带来巨大压力。此外,字体大小使用随机浮点数:
const fontSize = Math.random() * 20 + 10; // 10.0001, 15.2345...
每个不同的浮点数值都会导致浏览器生成新的字体缓存条目,字体缓存无限增长。
2. HeroSection:打字机效应引发级联重渲染
useTypingEffect Hook 每 80ms 更新一次状态,导致整个 HeroSection 组件树(包括 AnimatedName、TechTags、SocialLinks 等)全部重新渲染。而子组件都没有使用 React.memo 进行优化。
3. TiltCard:缺少 RAF 清理
组件卸载时没有调用 cancelAnimationFrame,导致卸载后动画帧回调仍在执行。
4. Header:fetch 无 AbortController
认证请求没有设置 AbortController,组件卸载后 fetch 可能仍在进行。
第一轮修复
针对以上问题,我做了以下修改:
- FloatingParticles:改用对象池模式,粒子通过
alive标志复用而非销毁重建;预定义 10 个整数字号;增加字体字符串缓存 Map - HeroSection:将 TypingText 提取为独立组件;AnimatedName 和 TechTags 包裹
React.memo - TiltCard:添加 useEffect 清理 RAF
- Header:fetch 添加 AbortController
结果:问题依旧
重启项目后,CPU 仍然 100%。第一轮修复虽然减少了浏览器端的内存分配和无效渲染,但显然不是根本原因。
第二轮排查:转向 CSS 渲染管线
既然浏览器端 JavaScript 不是主因,那会不会是 CSS 渲染导致的?
分析渲染管线
首页存在多层视觉效果的叠加:
- MouseGlow:
blur-[100px]的超大模糊光晕 + 60fps RAF 循环 - hero-gradient-bg:
background-position动画(无法 GPU 合成) - dot-matrix-glass:
backdrop-filter: saturate(50%) blur(4px)(毛玻璃效果)
这些效果形成了一个渲染管线的恶性循环:RAF 触发重绘 → backdrop-filter 强制 CPU 合成 → background-position 动画触发全层重绘 → blur 效果再次合成 → 循环往复。
第二轮修复
- MouseGlow:移除持续 RAF 循环,改为仅在 mousemove 时更新
- hero-gradient-bg:将
background-position动画改为transform: translate3d()动画(可 GPU 合成) - dot-matrix-glass:将
backdrop-filter替换为半透明背景色
关键转折点
这次我回退了代码。原因是发现了一个重要线索:
即使打开博客列表页(非首页),CPU 占用依然为 100%!
这说明问题不在某个特定页面的渲染效果上,而是全局性的。第二轮修复方向错误,我立即回退了所有改动。
第三轮排查:全局组件 + 进程监控
排查所有页面共有的组件
通过分析路由结构,我发现所有页面都包裹在 PortfolioPageWrapper 中,它包含以下全局组件:
- MouseGlow — 全局鼠标光晕
- Header — 导航栏(含 fetch 认证请求)
- Footer — 页脚
- ChatWidget — AI 聊天挂件(含 setInterval 打字机、useSyncExternalStore)
逐一审查后发现,这些组件虽然有可优化的地方,但都不足以导致 100% CPU。
决定性的一步:监控 Node.js 进程
既然浏览器端找不到原因,我把目光转向了服务端进程。使用 PowerShell 监控 Node.js 进程的资源占用:
# Turbopack 模式
Get-Process node | Select-Object Id, CPU, WorkingSet64
# 结果:PID 38420, CPU: 107%, Memory: 2755MB → 3089MB(持续增长)
# 对比:Webpack 模式
Get-Process node | Select-Object Id, CPU, WorkingSet64
# 结果:PID 4748, CPU: ~0%, Memory: 1016MB(稳定)
数据一目了然:
| 模式 | CPU 占用 | 内存占用 | 变化趋势 |
|---|---|---|---|
| Turbopack | 107% | 2755 → 3089 MB | ⬆️ 持续增长 |
| Webpack | ~0% | 1016 MB | ➡️ 稳定 |
根本原因:Turbopack 官方 Bug
经过三轮排查,最终确认:CPU 100% 和内存持续增长的罪魁祸首是 Next.js 16 的 Turbopack 开发服务器本身存在的 Bug。
已知的官方 Issue
在 GitHub 上可以找到多个相关 issue:
| Issue | 描述 | 版本 |
|---|---|---|
| #92246 | Turbopack dev server OOM:空闲时堆增长至 17GB+ | 16.2.1 |
| #93069 | 内存泄漏修复已合并 canary 但 45 天未发布到 16.x 稳定版 | 16.2.4 |
| #86893 | next-server 进程变为僵尸,400-700% CPU | - |
| #87322 | Turbopack 无限编译循环 | - |
技术根因
两个核心修复 PR 已经合并到 canary 分支,但未包含在 16.2.4 稳定版中:
- PR #88577:tee'd response 的两个 clone 未注册到
FinalizationRegistry,导致 ArrayBuffer/WriteWrap 泄漏 - PR #89040:LRU 缓存条目未设最小 size 下限,导致缓存无界增长
最终修复
解决方案非常简单——在 package.json 中切换到 Webpack 模式:
{
"scripts": {
"dev": "next dev --webpack"
}
}
验证结果:CPU 从 107% 降至 ~0%,内存从 3GB+ 且持续增长稳定在 ~837MB。
经验总结
调试思路的演变
Round 1: Browser JS layer → Found real issues but not the root cause
↓
Round 2: Browser CSS rendering layer → Wrong direction, corrected by user feedback
↓
Round 3: Global components + Server-side process → Found the true root cause
关键教训
- 跨层面思考:当单一层面的排查无果时,要敢于跨越边界(浏览器 → Node.js 进程)
- 控制变量验证:「博客列表页也 100% CPU」这个观察是关键的转折点,排除了页面特定因素
- 数据说话:不要猜测,直接测量进程级别的 CPU/内存指标,数据不会撒谎
- 官方 Bug 是常态:即使是成熟框架的最新版本也可能有严重 Bug,遇到异常现象时优先查 Issue Tracker
- 快速回退:当发现修复方向错误时,果断回退,避免引入不必要的改动
附带收益
虽然 Turbopack Bug 才是主因,但第一轮的浏览器端优化仍然是有价值的改进:
- 对象池模式减少 GC 压力
- React.memo 减少无效渲染
- RAF 清理防止内存泄漏
- AbortController 防止悬挂请求
这些优化在生产环境(使用 Webpack 构建的 production build)中同样有效。