Next.js Blog

Building App-like Experiences with Next.js 16.3

8.5内容质量

TL;DR · AI 摘要

Next.js 16.3通过Cache Components和Partial Prefetching实现SPA级响应式导航,无需牺牲Server Components优势。

核心要点

  • Instant Navigations通过Cache Components实现点击即显示的UI壳
  • Partial Prefetching可提前加载可见链接的UI壳并复用
  • cacheLife和cacheTag API控制缓存生命周期和数据失效

结构提纲

按章节快速跳转。

  1. 介绍Next.js 16.3发布Instant Navigations等新特性

  2. 通过Cache ComponentsPartial Prefetching实现点击即显示

  3. Next Beats演示案例

    展示加载状态壳在音乐播放器中的即时显示效果

  4. 使用cacheLife和cacheTag控制缓存生命周期和数据失效

  5. Drop应用示例

    演示缓存路由在社交feed中的复用效果

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Next.js 16.3新特性
    • Instant Navigations
      • Cache Components壳加载
      • Partial Prefetching预加载
    • 缓存机制
      • cacheLife控制生命周期
      • cacheTag实现数据失效

金句 / Highlights

值得收藏与分享的关键句。

#Next.js#前端#Server Components#React
打开原文

使用 Next.js 16.3 构建类应用体验 | Next.js

返回博客

2026年8月18日,星期二

使用 Next.js 16.3 构建类应用体验

作者:

Aurora Scharff

@aurorascharff

我们已于本月早些时候发布了 Next.js 16.3,该版本引入了由缓存组件和部分预取功能驱动的即时导航(Instant Navigations)。缓存组件确保路由能够立即显示 UI,而部分预取功能则在用户点击之前将 UI 传输到浏览器。这两项功能结合,使您能够在不牺牲服务端组件优势的前提下,实现单页应用(SPA)般流畅的导航体验。

让我们通过一组演示应用了解这些功能如何协同工作:音乐播放器 Next Beats、社交流 Drop、日历 Flow 以及团队聊天 Huddle。

即时导航

借助即时导航功能,您可以在点击时立即看到下一页内容,体验类似单页应用的流畅感。

在 Next Beats 中,当选择曲目或播放列表时,加载备用界面会立即出现:

在 Next Beats 中导航曲目和播放列表。

页面仍然在服务器端渲染。缓存组件确保了静态、缓存和备用 UI 的初始预渲染外壳,同时动态内容通过 Suspense 流式传输。

部分预取功能会在点击之前为可见的 <Link> 组件获取外壳,并在相同路由的链接之间复用一个外壳。浏览器可以在服务器完成其余处理时立即显示预取的 UI。

在 Next Beats 中,next.config.ts 启用这两个功能:

next.config.ts

code
import
type
{ NextConfig }
from
'next'
;
const
nextConfig
:
NextConfig
=
{
cacheComponents
:
true
,
partialPrefetching
:
true
,
};
export
default
nextConfig;

阅读即时导航指南了解如何构建点击时即准备就绪的路由 UI。如果您的项目尚未使用缓存组件,请遵循缓存组件迁移指南,或为您的编码代理分配采用技能。

跨导航的缓存

加载备用界面使首次访问变得响应迅速。通过缓存组件,页面背后的数据可以跨导航持久化,因此再次访问时可以跳过备用界面。

在 Drop 中,比较首页和个人资料的首次访问与最后的返回访问:

在 Drop 中重新访问缓存路由。

使用 'use cache' 标记已读内容,使 Next.js 能够复用结果,而不是在每次渲染时都查询数据源。缓存函数的参数会成为缓存键的一部分,而 cacheLife 可以调整结果保持新鲜的时间长度。

浏览器还会缓存预取和访问过的路由负载。只要负载保持新鲜,重新访问页面时就可以在不进行另一次服务器请求的情况下复用它。

在 Drop 中,帖子 ID 成为缓存键的一部分,读取操作会添加标签,供后续的变更操作过期:

features/drop/drop-queries.ts

code
import
{ cacheLife
,
cacheTag }
from
'next/cache'
;
async
function
getDrop
(id
:
string
) {
'use cache'
;
cacheLife
(
'minutes'
);
cacheTag
(
'drops'
,
`drop-
${
id
}
`
);
const
row
=
await
prisma
.
drop
.findUnique
({ where
:
{ id } });
if
(
!
row)
notFound
();
return
toDrop
(row);
}

了解更多关于 Next.js 中缓存的内容,包括缓存数据如何被复用和重新验证。

在 Next Beats 中,注意到在推荐内容继续加载时,第二次点击期间轨道标题头已经显示:

Next Beats 中默认预取与 URL 特定预取的对比。

默认情况下,可见的 <Link> 会为每个目标路由预取一个 App Shell,该 Shell 被指向该路由的所有链接共享。静态和缓存内容可以作为 Shell 的一部分,而动态或依赖 URL 的内容会在导航后流式加载。

当特定链接需要在点击前解析其 params、searchParams 或完整 URL 时,添加 prefetch={true}。标记为 'use cache' 的 URL 依赖读取可以包含在该链接的预取中,因此产品或详情页面可以带着内容就绪的状态加载。

带有 prefetch={true} 的可见链接在进入视口时即可触发服务器请求,因此在需要内容就绪的场景中使用该特性。Next Beats 中的轨道链接采用此方式:

code
import Link from 'next/link';
<Link 
  href={`/track/${track.id}`}
  prefetch={true}
>
  {track.title}
</Link>;

阅读预取指南了解默认行为、意图触发模式,以及针对 URL 特定内容的预取优化和 prefetch={true} 的权衡。要更新现有应用,请遵循部分预取采用指南,或让您的编码代理通过采用技能来处理。

添加客户端交互

快速页面仍需要响应式控件。通过客户端组件,可以让页面的交互部分立即响应,同时数据获取保留在服务器端。

在 Next Beats 中,观察播放按钮、当前播放条和轨道控件在播放器开始、暂停和跳过时保持同步:

Next Beats 中跨路由的播放控制。

用 'use client' 标记交互模块。其组件可以使用状态、事件处理程序和浏览器 API,而路由的其余部分保持服务端渲染并减少 JavaScript 传输量。

共享状态可以存在于上下文提供者中,并通过钩子读取,从而保持树中所有交互部分的同步。将提供者放置在共享布局中,可以在路由变化时保持挂载状态,而其子组件可以保持为服务端组件。

Next Beats 中的共享布局将路由内容和持久控件包裹在提供者中:

app/(app)/layout.tsx

code
import { NowPlayingBar } from '@/components/now-playing-bar';
import { PlayerProvider } from '@/providers/player-provider';
export default function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <PlayerProvider>
      {/* ...导航... */}
      <main>{children}</main>
      <NowPlayingBar />
    </PlayerProvider>
  );
}

阅读如何结合服务端和客户端组件,添加交互式模块而无需将整个应用迁移到浏览器。

对已标记数据的下一次请求会获取最新的结果。通过部分预取功能,一个可见的 <Link prefetch={true}> 可以在点击之前提前获取该更新,从而在导航时内容已准备就绪。

需要过期的标签取决于更改后的数据出现的位置。在 Drop 中,切换重发操作会同时修改 Drop 和已登录用户的个人资料,因此在写入操作后,Action 会同时使这两个标签过期:

features/drop/drop-actions.ts

code
'use server'
;
import
{ updateTag }
from
'next/cache'
;
import
{ verifyAuth }
from
'@/features/user/user-queries'
;
export
async
function
toggleRepost
(dropId
:
string
) {
const
me
=
await
verifyAuth
();
// ...在数据库中创建或删除重发...
updateTag
(
`drop-
${
dropId
}
`
);
updateTag
(
`user-drops-
${
me
}
`
);
// ...使其他受影响的视图过期...
return
{ ok
:
true
as
const
};
}

了解重新验证如何在突变操作后保持缓存数据的最新状态。

处理连接中断

类似应用的体验还应能承受临时的网络中断。当会话进行中网络中断时,您的应用可以等待网络恢复后继续运行。

在 Next Beats 中,观察在离线状态下打开的歌曲和播放列表中仍可见的内容,然后查看未完成的播放列表在重新连接后如何恢复:

通过临时网络中断导航 Next Beats。

启用离线重试后,失败的软导航、React 服务器组件获取、预取或服务器操作将保持挂起状态而非抛出异常,然后会自动重试。useOffline 钩子允许您在等待时显示一个重新连接条。

由于 App Shell 已经被预取,软导航仍然可以渲染它以及预取中包含的任何数据。

Next Beats 在启用缓存组件和部分预取功能的同时启用了离线重试:

code
import
type
{ NextConfig }
from
'next'
;
const
nextConfig
:
NextConfig
=
{
cacheComponents
:
true
,
partialPrefetching
:
true
,
experimental
:
{
useOffline
:
true
,
}
,
};
export
default
nextConfig;

阅读我们的连接中断处理指南,了解支持的请求、重试行为和重新连接反馈。

使用 Suspense 流式传输

根据缓存和预取的内容,路由的不同部分可能在不同时间准备就绪。通过 Suspense,您可以控制它们的显示方式,使页面加载速度更快(LCP)并保持稳定(CLS)。

在 Drop 中,比较长帖子和短帖子下方的回复显示方式:

在 Drop 中长帖子和短帖子下方流式传输回复。

有时您在加载内容之前无法知道其大小。如果将其拆分为单独的边界,它们可以独立解析并随着内容到达而相互调整位置。

相反,您可以嵌套这些边界。工作仍然可以并行执行,但嵌套边界会等待上方的边界就位后再显示部分。页面从顶部到底部逐步稳定,而不会延迟工作。

Drop 的帖子路由将回复放在上方帖子的边界内:

app/drop/[id]/page.tsx

code
import
{ Suspense }
from
'react'
;
<
Suspense
fallback
=
{<
DropDetailSkeleton
/>}>
{
params
.then
(({ id })
=>
(
<>
<
DropDetail
id
=
{id} />
<
Suspense
fallback
=
{<
RepliesSkeleton
/>}>
<
Replies
id
=
{id} />
</
Suspense
>
</>
))}
</
Suspense
>;

阅读流式传输指南,了解如何使用 Suspense 以更多方式揭示内容。

乐观更新

流式传输在数据加载时保持导航的响应性。对于数据变更,React 的过渡(transitions)和乐观更新(optimistic updates)等功能可以立即提供反馈,即使网络速度很慢。

在 Next Beats 中,可以在每次保存完成之前预览播放列表和收藏夹的变化,包括更改被拒绝时的情况:

更新播放列表和收藏夹的 Next Beats 示例。

useTransition 会将服务器操作(Server Action)及其引发的服务器更新视为一个待处理操作。在 startTransition 内部启动操作可将更新保留在同一个过渡中。

在该过渡中使用 useOptimistic 设置乐观值以立即渲染。如果操作失败,React 会回退到上次确认的值,你可以显示一个错误提示。

Next Beats 的收藏按钮在调用服务器操作(Server Action)前会先应用乐观值:

features/track/components/track-interactions.tsx

code
'use client'
;
import
{ useOptimistic
,
useTransition }
from
'react'
;
import
{ toggleFavorite }
from
'@/features/track/track-actions'
;
export
function
FavoriteButton
({ trackId
,
isFavorite }
:
FavoriteButtonProps
) {
const
[
,
startTransition
]
=
useTransition
();
const
[
optimisticFavorite
,
setOptimisticFavorite
]
=
useOptimistic
(isFavorite);
function
handleToggle
() {
startTransition
(
async
()
=>
{
setOptimisticFavorite
(
!
optimisticFavorite);
await
toggleFavorite
(trackId);
});
}
return
(
<
button
aria-pressed
=
{optimisticFavorite}
onClick
=
{handleToggle}>
Favorite
</
button
>
);
}

交互式应用指南逐步讲解了过渡、乐观更新和服务器操作的结合使用。

构建复杂应用

按需或按用户获取数据并不意味着导航会被阻塞或出现无尽的加载动画。这些模式可以组合成复杂的应用,在立即响应的同时仍然在服务器端渲染和获取数据。

在 Flow 中,注意切换视图时如何通过已有的内容实现即时导航,以及如何立即更新已登录用户的日历和事件编辑:

Flow 中的日历和事件管理。

服务器组件验证已登录用户并授权数据访问,而客户端组件负责交互逻辑。客户端提供者可以在客户端组件之间共享交互状态,同时其服务器端渲染的子组件继续在服务器端获取和渲染用户数据。

Flow 日历将提供者包裹在流式传输的月视图或周视图周围:

app/(workspace)/calendar/[date]/page.tsx

code
<
CalendarEventsProvider
>
<
Suspense
fallback
=
{<
CalendarViewFallback
/>}>
{
Promise
.all
([params
,
searchParams])
.then
(([{ date }
,
{ view }])
=>
toView
(view)
===
'month'
?
(
<
CalendarMonth
date
=
{date} />
)
:
(
<
CalendarWeek
date
=
{date} />
)
,
)}
</
Suspense
>
</
CalendarEventsProvider
>

部分预取(Partial Prefetching)会在点击前准备路由,动态数据通过 Suspense 的占位符进行流式传输。当在前一次保存完成前发生其他更改时,提供者可以使用 useActionState 按顺序执行保存操作,并通过 useOptimistic 在屏幕上保留待处理的更改。

提供者会在过渡中处理每个更改:

providers/calendar-events-provider.tsx

code
'use client';
import
{
startTransition
,
type
ReactNode
,
useActionState
,
useOptimistic
,
}
from
'react';
import
{ toast }
from
'sonner';
import
{ saveEventChange }
from
'@/features/calendar/calendar-actions';
import
type
{ EventChange }
from
'@/features/calendar/types/calendar';
// ...context declarations...
export
function
CalendarEventsProvider
({ children }
:
{ children
:
ReactNode
}) {
const
[
,
dispatch
]
=
useActionState
(
async
(_
:
void
,
change
:
EventChange
)
=>
{
const
result
=
await
saveEventChange
(change);
if
(
result
.error) {
toast
.error
(
result
.error);
}
// ...success toasts...
}
,
undefined
);
const
[
pendingChanges
,
addOptimisticChange
]
=
useOptimistic
<
EventChange[]
,
EventChange
>
([]
,
(changes
,
change)
=>
[
...
changes
,
change]);
function
mutate
(change
:
EventChange
) {
startTransition
(()
=>
{
addOptimisticChange
(change);
dispatch
(change);
});
}
return
(
<
CalendarEventsContext
value
=
{{ pendingChanges
,
mutate }}>
{children}
</
CalendarEventsContext
>
);
}

了解更多关于构建单页应用的内容,包括如何使用 useActionState 和 useOptimistic 协调重复的突变操作。

客户端获取数据

Server Components 可以负责大部分数据获取,但某些交互需要浏览器在状态变化时保持与服务器状态同步。客户端数据库可以轮询新数据、在获得焦点时重新验证、去重请求,并在不放弃初始服务器渲染的情况下跨组件协调更新。

在 Huddle 中,注意活动和未读标记如何清除,命令面板如何按需搜索,然后在两个 Huddle Bot 线程之间切换时如何保持回复可用:

在 Huddle 中跟踪未读活动和实时回复。

对于按需数据,在交互需要结果时从 Client Component 发起获取。在组件内部使用 useSWR 或 useQuery 处理加载状态,或在 Suspense 边界使用 suspense: true 或 useSuspenseQuery。

当初始视图需要数据时,从 Server Component 开始请求并通过 SWRConfig 或 HydrationBoundary 提供数据。这可以避免客户端瀑布流,当数据到达时,浏览器可以接管轮询、按需查询和乐观更新。

在 Huddle 的 SWR 分支中,Server Component 预加载消息并通过 SWRConfig 将其传递给客户端树:

features/message/components/message-thread.tsx

code
import
{ preload
,
SWRConfig }
from
'swr';
import
{ messageKeys }
from
'@/features/message/message-cache';
import
{ getMessagesForUser }
from
'@/features/message/message-queries';
export
async
function
MessageThread
({ channelId }
:
{ channelId
:
string
}) {
const
user
=
await
getCurrentUser
();
const
messageData
=
preload
(
messageKeys
.channel
(channelId)
,
()
=>
getMessagesForUser
(channelId
,
user
.id)
,
);
return
(
<
SWRConfig
value
=
{{ cacheData
:
{
...
messageData } }}>
<
MessageList
channelId
=
{channelId} />
</
SWRConfig
>
);
}

Client Component 使用 suspense 启用的相同密钥并从那里继续轮询:

features/message/hooks/use-messages.ts

code
'use client';
import
useSWR
from
'swr';
import
{ messageKeys }
from
'@/features/message/message-cache';
import
{ fetchJson }
from
'@/lib/fetch-json';
export
function
useSuspenseMessages
(channelId
:
string
) {
return
useSWR
(
messageKeys
.channel
(channelId)
,
fetchJson
,
{
refreshInterval
:
10_000
,
suspense
:
true
,
});
}

请阅读客户端数据获取指南以查看完整的 SWR 和 React Query 示例。

使用视图转换进行动画

一旦导航、数据和突变能够立即响应,动画可以使这些变化更易于跟随。通过 React 的 <ViewTransition>,你可以对流式加载的显示、列表变化和路由转换进行动画处理,使内容平滑地移动到正确位置。

在 Drop 中观看流式内容淡入,在 Next Beats 中观看列表和附近内容移动到正确位置,在 Flow 中观看日历随着导航滑动:

Drop 中的动画显示、Next Beats 中的列表变化以及 Flow 中的日历导航。

1. Suspense 显示

在 Drop 中,流式加载的动态、帖子和回复都包裹在 <ViewTransition> 中,当 Suspense 替换骨架屏时,它们会淡入显示。每个显示都由一个小包装器处理:

components/ui/crossfade.tsx

code
import
{ ViewTransition
,
type
ReactNode }
from
'react'
;
export
function
Crossfade
({ children }
:
{ children
:
ReactNode
}) {
return
(
<
ViewTransition
enter
=
"auto"
default
=
"none"
>
{children}
</
ViewTransition
>
);
}

在帖子路由中,一个 <Crossfade> 包裹帖子详情,另一个包裹回复,这样嵌套的 Suspense 边界可以独立动画:

code
import
{ Suspense }
from
'react'
;
<
Suspense
fallback
=
{<
DropDetailSkeleton
/>}>
<
Crossfade
>
<
DropDetail
id
=
{id} />
<
Suspense
fallback
=
{<
RepliesSkeleton
/>}>
<
Crossfade
>
<
Replies
id
=
{id} />
</
Crossfade
>
</
Suspense
>
</
Crossfade
>
</
Suspense
>;

2. 形态变化

在 Next Beats 中,移除收藏会缩短列表,并为下方的剩余行和推荐赋予新的位置。视图转换会动画处理这些布局变化,而不是让内容突然跳动。收藏更新已经在转换中运行,因此 React 可以在项目移除前后捕获布局。

将每个带键的收藏包裹在 <ViewTransition> 中,这样 React 可以将剩余行移动到新位置:

features/track/components/favorites-feed.tsx

code
import
{ ViewTransition }
from
'react'
;
{
tracks
.map
((track
,
i)
=>
(
<
ViewTransition
key
=
{
track
.id}>
<
div
className
=
"transition-opacity has-data-removing:opacity-50"
>
<
TrackRow
track
=
{track}
index
=
{i}
queue
=
{tracks} />
</
div
>
</
ViewTransition
>
));
}

较短的收藏列表还会将下方的推荐向上移动。第二个 <ViewTransition> 动画处理该部分的新位置:

app/(app)/favorites/page.tsx

code
<
ViewTransition
>
<
section
>
<
h2
>You Might Also Like</
h2
>
<
Discover
/>
</
section
>
</
ViewTransition
>

3. 页面转换

页面转换可以显示导航是向前还是向后移动。Flow 的日历链接为两个方向添加了转换类型:

features/calendar/components/calendar-controls.tsx

code
import
Link
from
'next/link'
;
<
Link
href
=
{
calendarHref
(previous
,
view)}
prefetch
=
{
true
}
transitionTypes
=
{[
'nav-back'
]}
>
Previous {period}
</
Link
>
<
Link
href
=
{
calendarHref
(next
,
view)}
prefetch
=
{
true
}
transitionTypes
=
{[
'nav-forward'
]}
>
Next {period}
</
Link
>

Flow 日历将这些转换类型映射到内容变化的动画名称上,将转换作用域限定在日历面板中:

components/ui/directional-slide.tsx

code
import
{ ViewTransition }
from
'react'
;
import
type
{ ReactNode }
from
'react'
;
const
directionalSlide
=
{
'nav-back'
:
'nav-back'
,
'nav-forward'
:
'nav-forward'
,
default
:
'none'
,
};
export
function
DirectionalSlide
({
children
,
name
,
}
:
{
children
:
ReactNode
;
name
:
string
;
}) {
return
(
<
ViewTransition
default
=
"none"
name
=
{name}
share
=
{directionalSlide}>
{children}
</
ViewTransition
>
);
}

这些类名用于样式化旧视图和新视图的快照。在向前导航时,旧内容向左移动,而新内容从右侧进入:

app/globals.css

code
@keyframes
slide {
from {
translate
:
var
(--slide-offset)
;
}
}
::view-transition-old(
.nav-forward
) {
--slide-offset
:
-60
px
;
animation
:
200
ms
ease-in-out both slide reverse
;
}
::view-transition-new(
.nav-forward
) {
--slide-offset
:
60
px
;
animation
:
200
ms
ease-in-out both slide
;
}

向后导航会使用 nav-back 类名镜像偏移量。

阅读我们的《视图过渡设计指南》以了解更多的模式、动画选择建议,以及如何隔离页眉和固定控件等持久元素,或为你的编码代理添加 React 视图过渡技能以自动实现。

示例应用

本文中的视频来自基于 Next.js 16.3 构建的开源应用,你可以克隆并探索:

  • Next Beats:一个带有音乐库、播放列表、收藏夹和跨导航播放的音乐播放器。
  • Drop:一个开发者主题的社交网络,包含帖子、关注、个人资料、标签动态和缓存的路由数据。
  • Flow:一个日历和预订工具。事件在客户端创建、拖动和删除,而日历周则通过标签进行缓存和重新验证。
  • Huddle:一个类似 Slack 的团队聊天应用,包含频道、线程、反应、未读状态和提及自动补全。它同时提供等效的 TanStack Query 和 SWR 实现版本。

这些应用包含使用 instant() 工具的 Playwright 端到端测试,该工具将断言作用域限定在预获取的 UI 上。来自 Next Beats 的一个示例断言在打开库页面时标题已经可见:

tests/navigation.spec.ts

code
import
{ instant }
from
'@next/playwright'
;
await
page
.goto
(
'/'
);
await
instant
(page
,
async
()
=>
{
await
page
.getByRole
(
'link'
,
{ name
:
'Library'
})
.click
();
await
page
.waitForURL
(
'/library'
);
await
expect
(
page
.getByRole
(
'heading'
,
{ name
:
'Library'
}))
.toBeVisible
();
});

反馈与社区

分享你的反馈,共同塑造 Next.js 的未来:

  • GitHub 讨论
  • GitHub 问题
  • Discord 社区