Lex's Blog

NextJS Slow navigation between pages

NextJS 页面间导航缓慢分析与改善思路

NextJS 应用在弱网环境中(地铁之类的),点击跳转链接后会有明显的等待时间,在这期间页面 没有任何变化,会让人很疑惑

TL;DR

一阶段:添加视觉反馈

给页面跳转加上一个进度条展示,给予用户即使的视觉反馈

  • 体验优化:对于快速响应的页面(0.3s 以内)不展示进度条

二阶段:开启动态页面的预加载

提前缓存页面资源,弱网(甚至断网)仍然可以秒跳

  • 注意:面临缓存状态的问题(后端数据变更、登录态变更等),做好缓存更新

三阶段:优化动态 API

最小粒度的使用动态 API,仅在使用数据的组件中调用,并将组件拆分后,配合 Suspend 进行流式响应

原因分析

简单说:
一旦在服务端组件中使用了 动态 API(eg. cookie()),页面就会变为动态渲染,此时的交互流程如下:

每次 <Link /> 跳转都会发一个 ?rsc=hash 请求

  1. 请求到服务端后,运行服务端代码
  2. 流式的传输 React Server Component data
  3. 客户端接收响应渲染页面

具体细节为

Caching In NextJS

f1e3f2e6eda73b746142326724e0442878d48655a863df4750d86a05f8113aac7309618ab4165fdbeccd3f9d5b709ef424ca958cc2ea24cb5f1c08af53add730

两种缓存:客户端缓存 & 服务端缓存

  • 客户端:浏览器内存(单用户)
  • 服务端:服务器(多用户)

两种页面:静态页面 & 动态页面

  • 静态:路径中没有动态参数 /blog/[slug] & 没有使用 动态 API
  • 动态:!静态

默认缓存情况:

-客户端缓存服务端缓存预加载
静态页面✅(5min)
动态页面❌(only loading.js

动态页面最多只能在客户端中进行缓存,无法在服务端中进行缓存

这是合理的,访问了动态 API 的大多数场景就是在区别用户 (cookie\header),也就是要根据不同用户的状态选择不同的逻辑,所以必须每次请求都要重新回到服务端执行相关的逻辑

否则一个用户登录后,后面所有用户访问的都是这个用户的页面(逻辑),那就出问题了

预加载

对于动态页面的预加载行为:

  • 预加载只会加载 loading.js (Guides: Prefetching)
  • 如果没有 loading.jsSuspend,页面会被阻塞直到服务端完全响应才会跳转

且,默认情况下,动态渲染没有任何缓存,当再次单击链接时,依然会重复上述过程

c9cb0f7c73629511f8c7578ee8ec6c4f2b3bb00503232380422b85c496f4a8cf2baaeaaf7ded9f3e78dfd684a2a458fc7411b6f602806ffa235545bbe90b00e0

预加载测试

// 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 功能,可以允许一个页面同时具备动态和静态的行为
但核心没有变化,只是将影响范围从页面缩小到了组件、函数的级别,所以下面的内容依然有效

缓存配置

next-config/staleTimes

配置 客户端缓存中 动态渲染的缓存时间 staleTime.dynamic

  • 可以改善短时间内反复访问页面的速度
  • 但并不能改善首次点击时的加载速度(没有预加载)
  • 需要考虑缓存问题,比如用户退出?改名?

预加载

Link 中显示配置 prefetch=true 来强制预取动态渲染页面(v15.4.0 默认值变成了 auto)

这同时会使动态页面使用静态页面的缓存配置(默认 5 分钟)

  • 可以同时解决首次点击和再次点击的加载速度
  • 同上,缓存问题

Suspend

给动态页面加入 loading.jsSuspend,这会使页面流式传输,让静态的部分提前返回

  • 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,
      },
    }),
  );
};

整体思路

  1. 可以无脑加上客户端状态更新做兜底
  2. 强制开启预加载,但需要注意缓存问题
  3. 针对不同的页面,自行设计 loading.js/ Suspend
  4. 如果有通用的全局 loading 页面,也可以兜底加上

客户端缓存的清理方法

动态渲染的缓存问题

登录状态

目前退出登录时,会执行 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 中,需要在数据变更后

  1. 首选在 Server Action 中执行 revalidatePath
  2. 在浏览器中执行 router.refresh(),但是这会使所有客户端缓存都失效

觉得有帮助?给个 Star 支持一下吧!

Star on GitHub

On this page