面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
用 View Transitions + Skeleton 消灭页面跳转的割裂感

用 View Transitions + Skeleton 消灭页面跳转的割裂感

2026年7月8日1,1444分钟
✦AI 生成摘要

Next.js 16 内置了 React 19 的 ViewTransition API,加上 loading.tsx 骨架屏与悬停预取,三件事合在一起就能让页面切换从「瞬移」变成「流动」。


目录
  • 1. 背景问题
  • 2. 解法一:View Transitions API
  • 2.1 三步接入
  • 2.2 浏览器兼容性
  • 3. 解法二:loading.tsx 骨架屏
  • 3.1 动态路由的预取困境
  • 3.2 实现
  • 3.3 Shimmer 动画
  • 4. 解法三:悬停预取
  • 5. 三者的关系
目录
  • 1. 背景问题
  • 2. 解法一:View Transitions API
  • 2.1 三步接入
  • 2.2 浏览器兼容性
  • 3. 解法二:loading.tsx 骨架屏
  • 3.1 动态路由的预取困境
  • 3.2 实现
  • 3.3 Shimmer 动画
  • 4. 解法三:悬停预取
  • 5. 三者的关系
目录
  1. 1. 背景问题
  2. 2. 解法一:View Transitions API
  3. 2.1 三步接入
  4. 2.2 浏览器兼容性
  5. 3. 解法二:loading.tsx 骨架屏
  6. 3.1 动态路由的预取困境
  7. 3.2 实现
  8. 3.3 Shimmer 动画
  9. 4. 解法三:悬停预取
  10. 5. 三者的关系
Next.js性能前端
相关文章
  • 01
    Prefetch 的完整图景2026/07
  • 02
    「批注」功能的设计与演进2026/05
  • 03
    「相关文章」功能的设计与实现2026/05
← 上一篇AI 编程工具的另一面:不是写代码,是交付
下一篇 →Prefetch 的完整图景

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

1. 背景问题

本站用 Next.js App Router 构建。有两个地方体验不好:

  1. 页面切换没有过渡:导航到新页面时旧内容瞬间消失、新内容瞬间出现,两个状态之间没有任何视觉连续性。
  2. 点击文章链接后长时间没有反馈:文章页是 async Server Component,需要在服务端完成 MDX 编译、评论数据请求后才把 HTML 发给浏览器,等待期间页面毫无动静,然后整个新页面突然出现。

两个问题的根源不同,解法也不同,但都指向同一个感受:割裂。


2. 解法一:View Transitions API

浏览器原生的 View Transitions API 允许在 DOM 快照之间插入动画。React 19 在此基础上封装了 <ViewTransition> 组件,Next.js App Router 的路由导航本身就基于 startTransition,因此两者天然契合——路由切换时,<ViewTransition> 包裹的内容会自动触发动画,不需要额外的事件监听。

Next.js 16 通过实验性标志暴露这个能力:

next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    viewTransition: true,
  },
};

2.1 三步接入

  • 第一步:在根 layout 里用 <ViewTransition> 包裹页面内容。
src/app/layout.tsx
+ 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,让它在页面切换时保持静止。
src/components/ui/Nav.tsx
 <header
   className="site-nav"
   style={{
     position: "sticky",
     top: 0,
     zIndex: 50,
+    viewTransitionName: "site-header",
   }}
 >

没有这一步,header 也会参与动画,用户失去空间锚点,反而更晕。

  • 第三步:用 CSS 伪元素定制动画。浏览器把旧页面和新页面各截一张快照,分别用 ::view-transition-old 和 ::view-transition-new 控制。
globals.css
/* 旧内容: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 的延迟让两段动画形成接力而不是叠加。

2.2 浏览器兼容性

View Transitions API 目前支持 Chrome/Edge,Safari 和 Firefox 上动画直接跳过,页面功能完全正常。这是标准的渐进增强——不支持的浏览器得到无动画的体验,而不是报错。


3. 解法二:loading.tsx 骨架屏

3.1 动态路由的预取困境

Next.js 对静态路由(○)会在视口中预取完整内容;对动态路由(ƒ),除非有 loading.tsx,否则默认不预取。文章页是动态路由,点击链接后浏览器才开始向服务端请求,服务端还要跑完所有 async 工作才响应——这就是那段空白。

loading.tsx 的作用是给页面加一个 <Suspense> 边界。有了这个边界,Next.js 会预取到 layout + loading shell,点击链接后立刻渲染骨架,文章内容流式替换进来。

3.2 实现

在 app/notes/[slug]/ 下新建 loading.tsx,结构和文章页对应:

src/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%,反而看起来假。

3.3 Shimmer 动画

骨架本身只是占位块,加上扫光动画才有「加载中」的语义:

globals.css
.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% 让渐变的宽度是容器的两倍,动画才有扫过去的感觉。


4. 解法三:悬停预取

loading.tsx 解决了「点击后有反馈」的问题,但如果能在用户点击之前就开始预取,等他真正点击时内容可能已经在路由缓存里了。

Next.js 的 <Link> 默认在元素进入视口时预取静态路由,动态路由则不预取(节省请求)。可以用 prefetch={false} 关闭、prefetch={null} 恢复默认,利用这个机制实现悬停触发:

src/components/ui/HoverPrefetchLink.tsx
"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>:

src/app/notes/NotesListClient.tsx
+ 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 下不会触发。


5. 三者的关系

这三个方案解决的是不同层次的问题,叠加之后形成完整的体验链:

场景没有这些改动有了之后
切换页面内容瞬移,无连续感旧内容淡出,新内容浮入
点击文章(冷启动)空白等待约 1 秒立即显示骨架屏
点击文章(悬停后)等待服务端渲染数据可能已在缓存,接近即时

View Transitions 改变的是「已有内容」之间的切换感;loading.tsx 改变的是「等待期间」的反馈;hover prefetch 改变的是「等待时长」本身。三件事都不大,但缺了任何一个,整体体验就有明显短板。

}
@keyframes vt-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes vt-slide-y {
from { transform: translateY(8px); }
to { transform: translateY(0); }
}
/* header 保持静止 */
::view-transition-group(site-header) {
animation: none;
z-index: 100;
}
::view-transition-old(site-header) { display: none; }
::view-transition-new(site-header) { animation: none; }
/* 尊重用户的减弱动效偏好 */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}
"4px"
}} />
</div>
{/* 标题(两行,错落宽度更自然) */}
<div className="skeleton" style={{ width: "72%", height: "2rem", borderRadius: "6px", marginBottom: "0.75rem" }} />
<div className="skeleton" style={{ width: "48%", height: "2rem", borderRadius: "6px", marginBottom: "1.5rem" }} />
{/* meta 行 */}
<div style={{ display: "flex", gap: "1.5rem", marginBottom: "2.5rem" }}>
<div className="skeleton" style={{ width: "6rem", height: "0.8125rem", borderRadius: "4px" }} />
<div className="skeleton" style={{ width: "4rem", height: "0.8125rem", borderRadius: "4px" }} />
<div className="skeleton" style={{ width: "5rem", height: "0.8125rem", borderRadius: "4px" }} />
</div>
{/* 正文段落(宽度错落,模拟真实文字排布) */}
{[100, 88, 95, 72, 100, 83, 91, 60].map((w, i) => (
<div key={i} className="skeleton"
style={{ width: `${w}%`, height: "1rem", borderRadius: "4px", marginBottom: "0.6rem" }} />
))}
</div>
);
}
s
ease
infinite
;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
=
useState
(
false
);
return (
<Link
href={href}
prefetch={prefetch ? null : false}
onMouseEnter={() => setPrefetch(true)}
onFocus={() => setPrefetch(true)} // 键盘导航也要覆盖
{...props}
/>
);
}