NextJS Slow navigation between pages
NextJS 页面间导航缓慢分析与改善思路
NextJS 应用在弱网环境中(地铁之类的),点击跳转链接后会有明显的等待时间,在这期间页面 没有任何变化,会让人很疑惑
TL;DR
一阶段:添加视觉反馈
给页面跳转加上一个进度条展示,给予用户即使的视觉反馈
- 体验优化:对于快速响应的页面(0.3s 以内)不展示进度条
二阶段:开启动态页面的预加载
提前缓存页面资源,弱网(甚至断网)仍然可以秒跳
- 注意:面临缓存状态的问题(后端数据变更、登录态变更等),做好缓存更新
三阶段:优化动态 API
最小粒度的使用动态 API,仅在使用数据的组件中调用,并将组件拆分后,配合 Suspend 进行流式响应
原因分析
简单说:
一旦在服务端组件中使用了 动态 API(eg. cookie()),页面就会变为动态渲染,此时的交互流程如下:
每次 <Link /> 跳转都会发一个 ?rsc=hash 请求
- 请求到服务端后,运行服务端代码
- 流式的传输 React Server Component data
- 客户端接收响应渲染页面
具体细节为
Caching In NextJS

两种缓存:客户端缓存 & 服务端缓存
- 客户端:浏览器内存(单用户)
- 服务端:服务器(多用户)
两种页面:静态页面 & 动态页面
- 静态:路径中没有动态参数
/blog/[slug]& 没有使用 动态 API - 动态:!静态
默认缓存情况:
| - | 客户端缓存 | 服务端缓存 | 预加载 |
|---|---|---|---|
| 静态页面 | ✅(5min) | ✅ | ✅ |
| 动态页面 | ❌ | ❌ | ❌(only loading.js) |
动态页面最多只能在客户端中进行缓存,无法在服务端中进行缓存
这是合理的,访问了动态 API 的大多数场景就是在区别用户 (cookie\header),也就是要根据不同用户的状态选择不同的逻辑,所以必须每次请求都要重新回到服务端执行相关的逻辑
否则一个用户登录后,后面所有用户访问的都是这个用户的页面(逻辑),那就出问题了
预加载
对于动态页面的预加载行为:
- 预加载只会加载
loading.js(Guides: Prefetching) - 如果没有
loading.js或Suspend,页面会被阻塞直到服务端完全响应才会跳转
且,默认情况下,动态渲染没有任何缓存,当再次单击链接时,依然会重复上述过程

预加载测试
// home
<Link href="/api">API</Link>;
// api
export default async function Page() {
await sleep(3000);
return <div>API Page</div>;
}结果符合预期:
- 开发环境需要等 3s
- 生产环境预加载 & 缓存,可以秒进
调整代码加入 cookies()
// api
export default async function Page() {
await sleep(3000);
const cookie = await cookies();
return <div>API Page</div>;
}符合目前线上的情况:
- 生产环境也需要等 3s 才能进入页面
- 如果存在 loading.ts 或者 Suspend,可以秒看到 loading 中的内容
改善方法
TIP
NextJS v16 有了 cacheComponent 功能,可以允许一个页面同时具备动态和静态的行为
但核心没有变化,只是将影响范围从页面缩小到了组件、函数的级别,所以下面的内容依然有效
缓存配置
配置 客户端缓存中 动态渲染的缓存时间 staleTime.dynamic
- 可以改善短时间内反复访问页面的速度
- 但并不能改善首次点击时的加载速度(没有预加载)
- 需要考虑缓存问题,比如用户退出?改名?
预加载
在 Link 中显示配置 prefetch=true 来强制预取动态渲染页面(v15.4.0 默认值变成了 auto)
这同时会使动态页面使用静态页面的缓存配置(默认 5 分钟)
- 可以同时解决首次点击和再次点击的加载速度
- 同上,缓存问题
Suspend
给动态页面加入 loading.js 或 Suspend,这会使页面流式传输,让静态的部分提前返回
loading.js可以在 app 目录中加一个,所有动态路由都可以共享
客户端状态更新
客户端渲染加载状态
- 封装一下 Link 组件:onNavigate+useLinkStatus+useOptimistic
- 或者使用
instrumentation-client.ts触发事件,配合一个组件接受事件做渲染
- 或者使用
export const onRouterTransitionStart = (
url: string,
navigationType: "push" | "replace" | "traverse",
) => {
window?.dispatchEvent?.(
new CustomEvent("router-transition-start", {
detail: {
url,
navigationType,
},
}),
);
};- 在页面顶部展示一个进度条
整体思路
- 可以无脑加上客户端状态更新做兜底
- 强制开启预加载,但需要注意缓存问题
- 针对不同的页面,自行设计 loading.js/ Suspend
- 如果有通用的全局 loading 页面,也可以兜底加上
客户端缓存的清理方法
- 浏览器页面刷新
location.reload()/location.href = "/"
- NextJS 客户端 API
- Server Action API
动态渲染的缓存问题
登录状态
目前退出登录时,会执行 location.href = "/" 刷新页面,这会使浏览器缓存失效,所以不会有问题
- 以防万一(后续重构代码),最好还是在这些地方加入
router.refresh()
后端数据
主要考虑数据变更后(mutate),缓存的旧数据展示问题(query)
CSR
每次都会在客户端请求数据,动态渲染缓存不会引入任何新的问题(因为根本没有缓存什么有意义的东西)
- 抛开 NextJS,单纯使用 RQ/SWC 也会面临缓存问题,常规做法是在 mutate 之后都会重新 refetch/revalidate 来使缓存失效
RQ/SWC SSR
export default async function PostPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
)
}服务器组件中的 prefetch state 会放到 RSC Payload 中,被客户端缓存
但 prefetch state 只是作为初始化的填充数据来使用,最终页面渲染还是会以 RQ/SWC 缓存为准
所以只要做好 CSR 中的事情(mutate 之后重新 refetch/revalidate)来刷新 RQ/SWC,动态渲染缓存也不会带来新的问题
React Query 本身也推荐这种用法,将动态渲染缓存足够长的时间避免服务端渲染,使用客户端加载来完成数据更新
https://tanstack.com/query/latest/docs/framework/react/guides/ssr#tips-tricks-and-caveats
RSC + Server Action
目前项目中应该没有这种用法(后面应该也不会这么去用
这种范式像是 PHP/JSP,数据加载和更新全在服务端
// use server
export async function RSC() {
const data = await getData()
return <RCC data={data} />
}
// use client
function RCC() {
return <button onClick={async () => mutateData()} />
}
// use server
// server action
export const mutateData = async () => {
await sql.update()
revalidatePath("/page")
}后端数据缓存在 RSC Payload 中,需要在数据变更后
- 首选在 Server Action 中执行
revalidatePath - 在浏览器中执行
router.refresh(),但是这会使所有客户端缓存都失效