从一次丝滑菜单切换,我学到了什么
从一次几乎无感的菜单切换出发,理解预取、客户端路由与状态反馈,并记录 sprigh.com 的实现和验证。
我最初只是在一个前端学习网站里注意到一组很丝滑的菜单:切换栏目时,内容几乎立刻出现,地址栏也会变化,但没有传统网页跳转时的加载感。
这件事吸引我的地方,不是动画有多华丽,而是我几乎意识不到网页正在工作。于是我想弄清楚这种体感从哪里来,再把适合个人博客的部分用到 sprigh.com。
先确认它到底做了什么
我一开始以为它可能在加载不同子站。实际检查后发现,菜单只是把地址从首页更新为 /topics/backend 一类路径,浏览器没有发生整页导航。
点击菜单时,页面主要做了四件事:
- 更新 React 中的当前分类状态;
- 用
history.pushState()同步可分享、可前进后退的 URL; - 使用浏览器原生 View Transition API 替换内容;
- 在空闲时间、鼠标悬停或键盘聚焦时提前准备其他分类的数据。
我用 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 自动预取和客户端导航最擅长的场景。
这次真正学到的东西
丝滑导航不是给页面补一个淡入动画。它至少包含三个顺序:
- 点击前,提前准备最可能需要的内容;
- 点击时,只替换真正变化的部分;
- 等待超过感知阈值时,再给出克制的反馈。
动画排在最后。前两项没有做到,动画往往只是把慢包装得更复杂。
这次改动很小,但它给我的前端学习找到了一个更具体的方法:从真实体验出发,先观察和验证,再理解框架提供的能力,最后只把适合自己站点的部分留下来。