1. 路由卡顿的现象分类与问题定位思路
1.1 先看现象:卡死和慢是两个完全不同的问题
我在接手某跨平台管理后台项目时,用户反馈最多的就是“页面切过去白屏好几秒”“点了菜单像死机一样”,最夸张的一次是切换路由后整整 8 秒没有任何反应。这里要特别提醒一下:卡死和延迟虽然看着像一回事,但排查路径完全不同。延迟通常表现为页面最终能出来,只是等待时间长,时间花在网络、服务端渲染或数据请求上;而卡死往往意味着主线程被长时间占用,页面连交互响应都做不到,滚动、点击全部失效。所以第一件事不是急着改代码,而是先量化现象:是卡死还是延迟?是从哪个路由切换到哪个路由才出现?是首次进入还是每次都出现?这些信息会直接决定后续排查方向。
我建议先用浏览器开发者工具的 Performance 面板录一段切换路由的完整过程。如果看到主线程上出现长任务,单次超过 500ms 甚至好几秒,那基本就是 JavaScript 执行阻塞;如果主线程很空闲,时间主要花在 Network 里的接口请求和文档请求上,那就是数据链路或服务端渲染的问题。这一步相当于给问题定性,后面所有优化都围绕定性结果展开。
1.2 分层拆解:把卡顿拆到协议栈和渲染链路里
Next.js App Router 的页面切换和传统单页应用最大的区别在于:路由切换不再只是浏览器端的组件替换,而是涉及服务端渲染请求、RSC 数据流解析、客户端组件水合、动态导入加载这一整条链路。换句话说,你看到的“切页卡顿”可能是这条链路上任何一个环节出了问题。
我在实际排查中习惯把问题拆成五层:
| 层级 | 关注点 | 典型表现 |
|---|---|---|
| 网络层 | JS 分包请求、RSC 请求、图片资源 | 资源加载瀑布图过长 |
| 服务端层 | 页面组件服务端执行耗时、数据源访问 | 首字节时间过长 |
| 解析层 | RSC Payload 反序列化、客户端组件水合 | 主线程长任务 |
| 渲染层 | 组件树层级、重渲染范围过大 | 帧率下降、输入延迟 |
| 状态层 | 全局状态、缓存策略导致的数据重复请求 | 接口重复调用、等待串行 |
这种分层方式帮我避开了一个常见的坑:很多人一遇到路由慢就去查“是不是代码分割没做好”,但实际上一半以上的情况是某个页面组件里直接调用了慢接口,并且这个调用还是同步阻塞渲染的。所以后面所有方案,我都会沿着这条链路逐个讲排查方法和解决办法。
2. 服务端链路排查:App Router 的“隐形耗时”往往被低估
2.1 RSC 请求链路里,页面组件服务端代码也是要算时间的
App Router 下,页面文件(page.tsx)和布局文件(layout.tsx)默认在服务端执行。很多人意识不到这一点,把数据请求、权限判断、复杂计算全部塞在页面组件里,结果每次路由切换时,服务端要先跑完这些代码才返回 RSC 数据流。如果服务端代码里有慢查询、循环计算、或者依赖外部 API 的同步等待,路由延迟就会直接拉满。
一个很典型的例子:我接手过一个报表类项目,页面组件里直接调用了一个第三方统计接口,这个接口平均耗时 2.5 秒,超时时间设置的是 10 秒。结果就是每次切到报表页,整个 Tab 都要白屏至少 2 秒。这就是典型的“服务端阻塞”,而且它比客户端阻塞更隐蔽——你在浏览器 Performance 面板里几乎看不到长任务,因为时间全花在了 Network 里那个 RSC 请求上。
解决办法有两个方向。第一个方向是缩短服务端执行时间:把数据请求改为并行(Promise.all)、给组件加上缓存,避免每次进入页面都重复计算。第二个方向是把不应该在服务端做的事挪走:比如某些非关键的辅助数据,完全可以在客户端用 useEffect 异步加载,或者在路由切换后再动态拉取,而不是阻塞页面渲染。这里有一个判断准则:用户第一眼必须看到的内容才值得放在服务端阻塞渲染,其余一切都可以后置。
2.2 动态接口千万别滥用:动态渲染会击穿所有缓存优化
App Router 里有一个非常容易踩的坑——动态渲染标记。只要页面组件里调用了cookies()、headers()、searchParams等动态 API,整个页面就会被标记为动态渲染,意味着每次访问都要重新走一遍服务端执行流程,所有静态优化和缓存策略全部失效。更麻烦的是,这种动态标记会顺着组件树向上传播,局部布局也会被连带影响。
我在排查另一个项目时发现,某个侧边栏布局里只是为了显示当前用户名,调用了cookies(),结果整个后台所有页面的路由切换全部变成动态渲染,CDN 缓存形同虚设,每次页面切换都要回源重新渲染。修复方式是把用户信息从 cookies 里读出来后,存放在服务端组件上下文或客户端状态里,避免每个页面都触发动态标记。
需要特别注意的是,在页面组件里读取 searchParams 本身也是动态渲染触发器。有些开发者为了在服务端组件里获取查询参数,直接在 page.tsx 里读取 searchParams,这会导致整个页面无法静态优化。如果只是需要少量参数,建议把参数传递给客户端组件,在客户端用useSearchParams读取,或者干脆把页面拆成静态壳加动态内容的组合。
2.3 布局嵌套层级过深:每层都要等服务端返回一次
App Router 的嵌套布局设计虽然带来了很强的复用能力,但也暗含一个性能陷阱:每次路由切换,URL 匹配到的所有嵌套布局和页面都要在服务端重新执行,哪怕布局本身不依赖动态数据。层级越多,链路越长,延迟自然也就越高。
比如某个后台项目为了做权限控制,把布局嵌套了四层:根布局、业务布局、模块布局、详情页布局。每一层都有服务端组件在跑,每一层都返回一段 RSC 数据流,客户端要等全部数据流到达后才能完成页面渲染。实测下来,即使每层只花 50ms,四层也要 200ms 的额外延迟,而且这还没算网络往返。
解决思路是:把不依赖动态数据的布局全部静态化,利用 Next.js 的缓存机制让它们在构建时或首次访问后缓存下来,后续路由切换直接复用;对于必须动态的部分,尽量收敛到最内层页面附近,而不是放在每个布局里。如果你发现某些布局除了包一层之外什么实质工作都没做,果断合并层级,收益非常直接。
3. 客户端渲染与 JS 执行:水合过程与长任务的真实来源
3.1 水合范围失控:客户端组件树过大导致主线程被塞满
服务端返回的 RSC 数据流到了浏览器后,还需要经历客户端水合(Hydration)过程。所谓水合,就是 Next.js 在浏览器端重新创建组件实例、绑定事件、建立状态的过程。水合的执行开销取决于客户端组件树的大小和复杂度,如果页面顶层有太多 “use client” 组件,水合时就要遍历并实例化整个子树,主线程会被一次性塞满。
我排查过的一个场景非常典型:某个列表页为了做交互效果,把整个表格区域都标记成了客户端组件,表格里每一行又引用了一个体积较大的图表库。虽然服务端渲染很快,但每次切进这个页面,浏览器要花 3 到 4 秒去水合整棵组件树,期间页面完全卡死,点击毫无反应。Performance 面板上能看到清晰的长任务山峰,全部来自水合过程。
解决办法的核心思路是缩小客户端组件边界:只有真正需要交互能力、事件监听、浏览器 API 的叶子节点才标记为 “use client”,上层尽量保持服务端组件。如果组件只是展示数据,没必要变成客户端组件。表格行、图表这类重组件,尽量通过动态导入(next/dynamic)按需加载,并且用 Suspense 包裹,让首屏水合只处理关键交互区域。这个改动往往能带来立竿见影的效果,我在多个项目上实测,水合时间可以从几秒降到几百毫秒。
3.2 useSearchParams 的隐藏阻塞:不只是拿参数那么简单
useSearchParams这个 Hook 在 App Router 里有一个非常隐蔽的性能问题。如果你在客户端组件里使用了它,组件树必须以客户端渲染模式等待搜索参数就绪。在 Next.js 的默认行为下,这会强制页面跳过静态优化,并且如果页面是服务端渲染的,客户端组件要等到整个 RSC 流到达后才能拿到 searchParams。更严重的是,在某些版本中,使用useSearchParams且没有用 Suspense 包裹的客户端组件,会导致整个页面在导航时显示空白,直到数据准备完成,这在视觉上就是“卡死”。
最典型的场景是一个搜索页面:页面组件在渲染时调用了useSearchParams去读搜索关键词,然后触发一连串的数据请求。结果就是切换到这个搜索页时,浏览器要先等待服务端返回完整的 RSC 数据流,再在客户端解析出 searchParams,最后才渲染出页面。整个流程耗时很久,而且期间页面完全空白。
推荐的写法是:把依赖 searchParams 的部分拆成一个独立的客户端组件,用<Suspense>包裹,并提供一个 loading 占位。这样即使 searchParams 还没就绪,页面其他部分也能先展示出来,不会出现整页空白。另外要注意,读取 searchParams 的操作越靠近使用位置越好,不要在一进门的地方就读取,否则会扩大等待范围。
3.3 第三方库和包体过大:客户端 Bundle 里藏着整头“大象”
如果水合问题排查完,卡顿依旧,接下来就要看 JS 包体。App Router 模式下,客户端包体膨胀的原因通常是:第三方库被打进了客户端 Bundle、动态导入没有真正生效、或者依赖了某个大体积库但只用了其中一小部分功能。
我见过一个项目,只是为了格式化日期引入了整个日期库,客户端 Bundle 直接多了 200KB 体积。另一个项目把图表库放在了全局布局里,导致所有页面切换时都要加载同一份图表代码。这些看起来都不算什么大问题,但叠加起来,会让每个路由切换都要下载几百 KB 甚至上兆的 JavaScript,在弱网环境下,延迟惨不忍睹。
排查方法很简单:在构建时留意构建输出里每个路由的 JS 体积,如果某个路由的首次加载 JS 超过 300KB(gzip 前),就需要认真对待了。常用的优化手段包括:
- 用
next/dynamic把非首屏组件改为懒加载,并设置ssr: false让它们只在客户端加载。 - 把第三方库按需引入(比如日期库只用某个函数,就单独引那个函数)。
- 利用
import()动态加载重量级库,并且拆成独立 chunk。 - 对重复引入的库做单例化处理,避免同一份代码在多处重复打包。
这里我想额外强调一个点:动态导入不是加了就完事,还要看触发时机。如果动态导入是在页面渲染主流程里触发的(比如点击按钮后才加载),用户会有明确的加载等待;如果动态导入放在首屏渲染路径上,反而会因为多一次请求而让首次渲染变慢。所以动态导入的粒度既要看体积,也要看是否真的“非首屏必需”。
4. 预加载与缓存策略:让路由切换“秒开”的关键
4.1 Link 的预取机制:用好了是神器,用不好是负担
App Router 的next/link自带预取功能:当 link 出现在视口内(或用户即将点击)时,Next.js 会在空闲时间预取目标路由的 RSC 数据和相关 chunk。这理论上能让路由切换接近瞬间完成。但在实际项目中,这个预取机制经常被误用或没有充分利用。
一个常见的误用场景是:项目里很多菜单是用div加onClick配合router.push()实现的,完全绕过了next/link。这样做的后果就是预取机制完全不生效,每次路由切换都要从头请求,延迟自然高。我接手过一个项目就是这种情况——全站导航全部用router.push,没有任何预取,首个页面虽然很快,但后续每次切页都要等一两秒。
正确的做法是:凡是站内导航,优先使用<Link>组件,让预取机制自然生效。同时可以根据实际情况调整预取行为:<Link prefetch={false}>用于那些访问频率低、体积大的页面,避免浪费带宽预取;<Link prefetch={true}>用于下一跳几乎必然发生的高频导航页面。这里要特别提醒:预取是“尽力而为”的机制,在弱网或移动端可能会被节能模式打断,所以不能把全部希望寄托在预取上,核心的优化还是要把页面本身的渲染链路做快。
4.2 缓存策略调优:App Router 的四级缓存别再只会用默认值
App Router 的缓存体系非常丰富:构建时静态缓存(Static)、按需重新验证(ISR)、路由缓存(Router Cache)、以及 fetch 的默认持久化缓存。很多项目从 Pages Router 迁移过来后,完全没有意识到这些缓存的存在,一直在用默认值,自然拿不到性能红利。
举一个我实际调优过的例子:某个内容列表页的数据来自接口,但内容更新频率很低(每天一次)。最初代码里直接用fetch调用,没做任何缓存配置,结果每个用户每次切页都要重新请求接口。优化时给 fetch 加了next: { revalidate: 3600 },让数据在服务端缓存一小时,配合路由缓存,后续同一用户切换页面几乎是秒开,接口压力也降了一个量级。
这里给出一个简单的选择思路:
| 页面类型 | 推荐缓存策略 | 预期效果 |
|---|---|---|
| 内容基本不变的页面 | 静态生成或 ISR 定期刷新 | 页面切换瞬间完成 |
| 数据变化频繁但用户可接受短暂延迟 | fetch 级 revalidate + 路由缓存 | 切换接近秒开 |
| 强实时数据(如库存、监控) | 不使用持久缓存,但要配合客户端状态 | 保证数据新鲜 |
需要注意的是,缓存策略和动态 API 有冲突:一旦页面组件里使用了动态 API 的读取逻辑,缓存往往会被绕过。所以上一节提到的动态标记排查,同样会影响缓存的生效范围。
4.3 客户端缓存与状态管理:避免切换页面时重复请求和串行等待
路由切换卡顿还有一个非常普遍但容易被忽略的原因:每次切换到某个页面时,页面组件重新挂载,所有数据请求全部重新触发,并且这些请求之间还存在串行依赖。比如先请求用户信息,拿到再请求列表,列表拿到再请求详情——三步串行,每一步都要等待前一步完成,总耗时就是三个接口耗时的总和。
解决办法一是用客户端缓存把高频数据存起来:比如用户信息、配置项、公共字典,首次加载后放入全局状态(或缓存),后续路由切换直接读缓存,不再重新请求。解决办法二是把串行请求改为并行:把没有依赖关系的接口用Promise.all并发发起,不要一个个 await。我在一个数据看板项目里就是这么做的,原来切换到看板页要 4 秒多,改成并行请求后缩到了 1 秒出头,只是把三个串行的 await 换成并行。
还要注意一个细节:路由切换时组件卸载会取消还没完成的请求吗?默认情况下,fetch 在组件卸载后并不会自动中断,它依然会执行完并更新状态。但如果你用了 AbortController 并在卸载时中断请求,反而可能导致页面快速切换时部分数据缺失,出现闪烁。所以这里我的建议是:对于频繁切换的页面,不要过早中断请求,让数据在后台拉完并写入缓存,下次进入时直接复用,这样反而能提升体验。
5. 实战排查工具与方法:从经验判断到数据驱动
5.1 用 Performance 面板定位长任务:看清主线程上的“时间黑洞”
前面多次提到 Performance 面板,这里细讲一下怎么用。在 Chrome 开发者工具里,切换路由之前先开启 Performance 录制,然后执行一次路由切换,等待页面完全渲染后停止录制。重点看两部分:
一是主线程上的长任务。任何超过 50ms 的任务在时序图上都会标记为红色,如果某个任务持续几百毫秒甚至几秒,点进去看调用栈,基本就能定位是水合、事件处理、还是某个组件的渲染函数在消耗时间。二是在Summary 标签页看时间分布:脚本执行、渲染、绘制、网络各占多少比例。如果脚本执行占比超过 60%,说明 JS 执行是主要瓶颈,重点排查水合和组件重渲染;如果网络时间占比很高,重点排查 RSC 请求和静态资源加载。
这套方法帮我在一个复杂的后台项目里定位到过一个非常隐蔽的问题:某个列表页的每一行都内联了一个大型的 JSON 数据,并在渲染时对该 JSON 做了深度复制,导致整页渲染时间被拉到 800ms 以上。这类问题不看调用栈根本不可能猜到,而 Performance 面板直接在长任务调用栈里把深拷贝函数标了出来。
5.2 开启 Web Vitals 数据采集:把卡顿从“感觉”变为“指标”
除了开发阶段的 Performance 分析,线上环境需要持续监控。Next.js 有一个内置的分析钩子useReportWebVitals,可以自定义上报指标到日志平台或监控系统。其中navigation-to-next-page(路由切换耗时)和nextjs-hydration(水合耗时)这两个指标,对定位线上路由卡顿问题非常关键。
实现方式很简单:在app/_components/web-vitals.tsx里创建一个客户端组件,用useReportWebVitals接收指标并异步发送到后端日志接口(注意别阻塞任何渲染流程,用navigator.sendBeacon或者把上报放到 requestIdleCallback 里)。这样线上出现卡顿问题,就能通过指标量化为数据,而不是靠用户抱怨“好卡”。
我有一个经验分享:光看平均值会掩盖问题。线上指标建议按页面维度、网络环境、设备类型分组统计。很多时候平均路由切换耗时只有 300ms,但某类低端设备上的 P95 可能是 5 秒。如果不分组统计,这类严重的问题就被平均值掩盖了。分组过滤之后,你才能针对特定设备、特定页面做优化。
5.3 用构建分析定位包体问题:别再靠肉眼猜体积
包体相关的优化,用构建分析工具来决策是最靠谱的。Next.js 构建时会在终端输出每个路由的首次 JS 加载体积,但这只是汇总数据,找不到具体是哪个库占了大头。这时候可以用@next/bundle-analyzer插件,在构建时生成依赖体积分析报告,按 chunk 和依赖分组查看体积分布。
我处理过一个比较典型的包体膨胀问题:项目里同时引入了两个表格库,只因为不同页面用了不同的封装风格,结果多打包了 600KB。用分析工具一看,两个库的代码都进了主 chunk。把其中一个库统一替换成另一个后,主 chunk 体积直接减了三分之一,路由切换的下载时间也明显缩短。
做包体优化时要记住一个原则:不是所有库都需要首屏加载。图表库、编辑器组件、大表格组件,超过 80% 的场景都是用户进入页面之后才用得到,全部做成动态导入是合理的。首屏只保留对直接渲染至关重要的代码,其余让浏览器在空闲时去拉取。
6. 常见问题速查表:对着症状直接找到方案
| 症状 | 最可能的根因 | 解决方案 |
|---|---|---|
| 切页后长时间白屏,主线程空闲 | RSC 请求耗时过长,服务端执行慢 | 并行化服务端数据请求,收敛动态 API 使用,缓存服务端计算结果 |
| 切页后页面卡死,点击无效 | 水合过程主线程长任务 | 缩小客户端组件边界,动态导入重组件,用 Suspense 拆分水合 |
| 首次进入快,后续切页都慢 | next/link未使用,预取失效 | 导航替换为 Link 组件,按需配置 prefetch |
| 所有路由切换都很慢,连首页也慢 | 页面被标记为动态渲染,缓存失效 | 排查 cookies()/headers()/searchParams 在布局中的滥用 |
| 某个特定页面切进去特别慢 | 该页面内嵌大量第三方库未分包 | 用 bundle analyzer 定位体积,动态导入非首屏库 |
| 切换页面时数据接口重复请求 | 组件重新挂载,缓存策略缺失 | 客户端缓存公共数据,服务端 fetch 配置 revalidate |
| 页面能显示但要等很久所有接口返回 | 串行请求过多 | 无依赖接口改并行请求,非关键数据改懒加载 |
| 快速连续切换页面时出现闪烁或错乱 | 请求竞态导致状态覆盖 | 避免过早中断请求,全局状态保存上次数据,切换时先渲染缓存数据 |
这张速查表是我在实际项目中总结出来的高频问题对应关系,但你要记住:它只是排查起点,不是答案。每个项目都有自己的特殊情况,最终还是要落到性能工具和代码层面的验证上。
7. 最后分享一点实际体会
App Router 的路由性能优化,本质上是一个“链路压缩”的过程。很多人以为 Next.js 卡顿是框架本身的性能问题,但我在大量项目中验证下来的结果是:绝大多数卡顿都源于开发时的隐性误用——动态渲染触发、水合边界失控、串行请求、包体膨胀,这些问题每一个都在可排查和可解决的范围内。
我个人比较推荐的做法是,每次做路由性能优化时,先给项目建立一个最低性能基线:以某一台基准电脑、固定网络环境为标准,定义“路由切换后页面可交互的最长耗时”上限(比如 800ms 以内)。超过基线才启动排查流程,每次优化后重新测一遍基线,用数据验证收益,而不是凭感觉认为“好像变快了”。这个方法可能听起来很简单,但实际操作中真的能帮你迅速判断优化有没有落到实处。
最后再分享一个小技巧:如果你的项目里路由切换卡顿总是跟某个第三方库强相关,但无法轻易替换,试着把该库的初始化逻辑从组件渲染阶段挪到“用户即将使用前”的交互动作里触发。虽然这不会缩短第一次使用的等待,但至少不会拖累每次路由切换的完整渲染。很多时候,用户的耐心是在高频切换的过程中被消耗掉的,优化好这个过程,体验就会有一个明显的提升。