返回博客
前端实践Next.js导航体验性能

从一次丝滑菜单切换,我学到了什么

从一次几乎无感的菜单切换出发,理解预取、客户端路由与状态反馈,并记录 sprigh.com 的实现和验证。

6 min read

我最初只是在一个前端学习网站里注意到一组很丝滑的菜单:切换栏目时,内容几乎立刻出现,地址栏也会变化,但没有传统网页跳转时的加载感。

这件事吸引我的地方,不是动画有多华丽,而是我几乎意识不到网页正在工作。于是我想弄清楚这种体感从哪里来,再把适合个人博客的部分用到 sprigh.com。

先确认它到底做了什么

我一开始以为它可能在加载不同子站。实际检查后发现,菜单只是把地址从首页更新为 /topics/backend 一类路径,浏览器没有发生整页导航。

点击菜单时,页面主要做了四件事:

  1. 更新 React 中的当前分类状态;
  2. history.pushState() 同步可分享、可前进后退的 URL;
  3. 使用浏览器原生 View Transition API 替换内容;
  4. 在空闲时间、鼠标悬停或键盘聚焦时提前准备其他分类的数据。

我用 Performance API 对比点击前后的资源记录。分类切换后没有新增页面资源请求,只有统计上报。也就是说,它给人的“快”并不只是动画遮住了等待,而是把大部分工作放到了点击之前。

它还会检查 prefers-reduced-motion。如果用户不希望看到动效,就直接同步更新界面,而不是强制播放过渡。

真正值得学的不是 pushState

参考站是一个以本地术语数据为核心的图鉴,适合把分类数据装进客户端,再直接切换状态。sprigh.com 是博客和项目站,文章应该继续享受静态生成、独立 URL 和服务端内容组织,因此没有必要照搬一套手写 SPA 路由。

对本站来说,更合适的基础已经由 Next.js 提供:

  • <Link> 在生产环境预取静态路由;
  • App Router 执行客户端导航,不刷新共享布局;
  • 博客列表、项目页和文章详情都可以在构建时生成;
  • 浏览器前进、后退和独立页面访问仍由正式路由负责。

因此这次没有新增缓存、没有手写 history.pushState(),也没有为了动效启用仍处于实验状态的 Next.js View Transition 集成。

sprigh.com 做了哪些改动

第一处改动是让导航记得“我在哪里”。

usePathname() 用来判断当前路径;首页只匹配 /,而 /blogs/blogs/文章名 都归属“博客”。当前入口通过 aria-current="page" 同时向样式和辅助技术表达状态。

第二处改动是区分“快”和“真的需要等待”。

Next.js 的 useLinkStatus() 可以知道某个链接是否还在导航。加载提示延迟 120ms 才出现:预取命中时,用户什么都不会看到;只有导航确实没有立即完成时,链接角落才出现一个很小的脉冲点。

第三处改动是保持动作连续。

当前栏目使用导航原有的底部横线,不再添加另一套视觉组件。悬停仍然是黑白反色,当前位置则在鼠标离开后继续保留。prefers-reduced-motion 开启时,脉冲和过渡都会关闭。

如何确认它不是“看起来能用”

这次验证分成三层:

  • 单元测试检查首页、栏目页、文章子页面和相似前缀的路径匹配;
  • ESLint 与 Next.js 生产构建检查实际 API、类型和静态生成结果;
  • 本地生产包执行首页 → 博客 → 文章详情导航,确认 URL、标题和当前栏目同步更新。

构建结果显示首页、博客和项目页都是静态路由,文章详情通过 generateStaticParams() 生成。它们正好符合 Next.js 自动预取和客户端导航最擅长的场景。

这次真正学到的东西

丝滑导航不是给页面补一个淡入动画。它至少包含三个顺序:

  1. 点击前,提前准备最可能需要的内容;
  2. 点击时,只替换真正变化的部分;
  3. 等待超过感知阈值时,再给出克制的反馈。

动画排在最后。前两项没有做到,动画往往只是把慢包装得更复杂。

这次改动很小,但它给我的前端学习找到了一个更具体的方法:从真实体验出发,先观察和验证,再理解框架提供的能力,最后只把适合自己站点的部分留下来。