Next.js 16 内置了 React 19 的 ViewTransition API,加上 loading.tsx 骨架屏与悬停预取,三件事合在一起就能让页面切换从「瞬移」变成「流动」。
本站用 Next.js App Router 构建。有两个地方体验不好:
两个问题的根源不同,解法也不同,但都指向同一个感受:割裂。
浏览器原生的 View Transitions API 允许在 DOM 快照之间插入动画。React 19 在此基础上封装了 <ViewTransition> 组件,Next.js App Router 的路由导航本身就基于 startTransition,因此两者天然契合——路由切换时,<ViewTransition> 包裹的内容会自动触发动画,不需要额外的事件监听。
Next.js 16 通过实验性标志暴露这个能力:
const nextConfig: NextConfig = {
experimental: {
viewTransition: true,
},
};<ViewTransition> 包裹页面内容。+ import { ViewTransition } from "react";
export default function RootLayout({ children }) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>
<ThemeProvider>
<Nav />
- <main>{children}</main>
+ <main>
+ <ViewTransition>
+ {children}
+ </ViewTransition>
+ </main>
</ThemeProvider>
</body>
</html>
);
}<header> 分配一个 viewTransitionName,让它在页面切换时保持静止。 <header
className="site-nav"
style={{
position: "sticky",
top: 0,
zIndex: 50,
+ viewTransitionName: "site-header",
}}
>没有这一步,header 也会参与动画,用户失去空间锚点,反而更晕。
::view-transition-old 和 ::view-transition-new 控制。/* 旧内容:150ms 快速淡出+轻微下移 */
::view-transition-old(root) {
animation: 150ms ease-in both vt-fade reverse,
150ms ease-in both vt-slide-y reverse;
}
/* 新内容:200ms 稍慢淡入+从下方浮入,等旧内容退场后再出现 */
::view-transition-new(root) {
animation: 200ms ease-out 150ms both vt-fade,
200ms ease-out 150ms both vt-slide-y;
时序上的不对称是刻意的:旧内容应该快速离场,不和新内容争夺注意力;新内容稍慢出现,给用户时间定位。150ms 的延迟让两段动画形成接力而不是叠加。
View Transitions API 目前支持 Chrome/Edge,Safari 和 Firefox 上动画直接跳过,页面功能完全正常。这是标准的渐进增强——不支持的浏览器得到无动画的体验,而不是报错。
Next.js 对静态路由(○)会在视口中预取完整内容;对动态路由(ƒ),除非有 loading.tsx,否则默认不预取。文章页是动态路由,点击链接后浏览器才开始向服务端请求,服务端还要跑完所有 async 工作才响应——这就是那段空白。
loading.tsx 的作用是给页面加一个 <Suspense> 边界。有了这个边界,Next.js 会预取到 layout + loading shell,点击链接后立刻渲染骨架,文章内容流式替换进来。
在 app/notes/[slug]/ 下新建 loading.tsx,结构和文章页对应:
export default function NoteLoading() {
return (
<div style={{ maxWidth: "56rem", margin: "0 auto", padding: "3rem 1.5rem 6rem" }}>
{/* 返回链接 */}
<div style={{ marginBottom: "2rem" }}>
<div className="skeleton" style={{ width: "4rem", height: "0.875rem", borderRadius:
骨架的宽度序列不是随机的——刻意让行宽错落,最后一行明显短于前面,模拟真实段落结束的视觉特征。如果每行都是 100%,反而看起来假。
骨架本身只是占位块,加上扫光动画才有「加载中」的语义:
.skeleton {
background: linear-gradient(
90deg,
var(--border) 25%,
var(--bg-secondary) 50%,
var(--border) 75%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4
用 CSS 变量而不是硬编码颜色,深色/浅色模式自动适配。background-size: 200% 让渐变的宽度是容器的两倍,动画才有扫过去的感觉。
loading.tsx 解决了「点击后有反馈」的问题,但如果能在用户点击之前就开始预取,等他真正点击时内容可能已经在路由缓存里了。
Next.js 的 <Link> 默认在元素进入视口时预取静态路由,动态路由则不预取(节省请求)。可以用 prefetch={false} 关闭、prefetch={null} 恢复默认,利用这个机制实现悬停触发:
"use client";
import Link from "next/link";
import { useState } from "react";
interface Props extends React.ComponentProps<typeof Link> {}
export function HoverPrefetchLink({ href, ...props }: Props) {
const [prefetch, setPrefetch]
初始 prefetch={false},鼠标悬停(或 Tab 聚焦)时切换为 null(即恢复 Next.js 默认行为),此时 Next.js 开始预取。用户从悬停到点击通常有几百毫秒,足够完成预取。
在文章列表里替换 <Link>:
+ import { HoverPrefetchLink } from "@/components/ui/HoverPrefetchLink";
- <Link href={`/notes/${note.slug}`} style={...}>
+ <HoverPrefetchLink href={`/notes/${note.slug}`} style={...}>
{/* 标题 */}
- </Link>
+ </HoverPrefetchLink>注意:prefetch 只在生产构建(next start)下生效,next dev 下不会触发。
这三个方案解决的是不同层次的问题,叠加之后形成完整的体验链:
| 场景 | 没有这些改动 | 有了之后 |
|---|---|---|
| 切换页面 | 内容瞬移,无连续感 | 旧内容淡出,新内容浮入 |
| 点击文章(冷启动) | 空白等待约 1 秒 | 立即显示骨架屏 |
| 点击文章(悬停后) | 等待服务端渲染 | 数据可能已在缓存,接近即时 |
View Transitions 改变的是「已有内容」之间的切换感;loading.tsx 改变的是「等待期间」的反馈;hover prefetch 改变的是「等待时长」本身。三件事都不大,但缺了任何一个,整体体验就有明显短板。