我们继续完成前几章搭好的项目协作台。现在它有项目列表、项目详情和动态布局,但还有一个很现实的问题:用户从“项目列表”点进“Atlas 项目”时,浏览器究竟发生了什么?
如果把这个问题只回答成“Next.js 是单页应用,所以不会刷新”,后面的很多现象都会解释不通:为什么链接还没点击就出现网络请求?为什么共享侧边栏没有重新挂载?为什么动态页面有时立即出现骨架,有时要等?为什么调用了 router.refresh(),数据库里的新值却不一定回来?
本章用一条贯穿始终的 URL 来拆开这些问题:
/workspace/projects/atlas?view=board&sort=due#comments
当前课程项目实际使用 Next.js 16.1.6,并且没有开启 Cache Components。下面的代码和行为都以这个版本为基线。Next.js 16.2 之后新增的转场类型 API,以及更新预览版本里的实验性导航选项,不会出现在本章代码里。

路由和导航经常被混成一个词。我们先把它拆成四层,后面遇到 API 才不会靠背诵来选。
文件系统决定 URL 能匹配到哪个页面。比如:
app/
└── workspace/
├── layout.tsx
├── projects/
│ ├── page.tsx
│ ├── loading.tsx
│ └── [slug]/
│ ├── page.tsx
│ └── not-found.tsx
└── activity/
└── page.tsx这棵树如何映射 URL、动态段如何命名,我们已经在文件路由章节学过。本章不再重复目录语法,只关心用户从一个已存在的地址切换到另一个地址时,框架怎样工作。
同一条 URL 可以拆成不同职责:
/workspace/projects/atlas ? view=board&sort=due # comments
└────── 路径 ──────────┘ └──── 查询参数 ──────┘ └ 片段 ┘/workspace/projects/atlas 指向 Atlas 项目,是资源身份。view=board&sort=due 描述当前看板视图和排序方式。#comments 指向页面内的评论区域。URL 片段由浏览器处理,不会随普通 HTTP 请求发送给服务器。Server Component 若要知道它,只能由客户端读取后再决定如何表现;页内锚点通常根本不需要服务器参与。
可选择的工具包括:
<Link>。push、replace 和 back。redirect()、permanentRedirect() 与 notFound()。它们都可能改变用户看到的页面,但历史记录、HTTP 语义、可访问性和安全边界不同。
一次导航不只有“成功打开页面”这一种结局。目标片段可能先显示 loading.tsx,资源可能不存在,服务端也可能决定把用户送往另一个地址。选 API 时,应先判断这是用户主动导航、程序执行后的跳转,还是服务器对资源状态作出的结论。
可以记住一句短话:文件路由定义地图,URL 保存可分享状态,Link 和 Router 负责移动,redirect 与 notFound 表达服务器得出的结果。
用户点击 Link 时,Next.js 通常不会像传统多页网站那样销毁当前文档,再下载一份完整 HTML。它会请求目标路由需要的 React Server Component Payload,并让客户端 Router 把新结果合并进现有 React 树。
用户第一次直接打开项目列表时,服务器会为首屏准备 HTML 与 RSC Payload。HTML 让浏览器尽早显示内容,RSC Payload 描述 Server Component 的渲染结果、Client Component 占位以及组件之间的关系。客户端 JavaScript 到达后,再为需要交互的部分完成水合。
随后从项目列表进入详情页,浏览器已经有 Workspace Layout 和客户端运行时。此时框架只需要取得目标导航缺少的路由片段:
首次访问:
服务器 → HTML + RSC Payload → 浏览器显示并水合
后续导航:
Link 预取或点击 → 新的 RSC 路由片段 → 合并现有 React 树所以“客户端导航”不代表服务器退出了流程。Server Component 仍在服务器渲染,只是交付单位从整份文档变成了可以合并的路由片段。
有些资料把这种“不重新加载整份文档、只合并路由片段”的过程称为 soft navigation。它描述的是过渡效果,不是一个需要调用的独立 API,也不能据此推断服务器没有参与。
从 /workspace/projects 进入 /workspace/projects/atlas 时,app/workspace/layout.tsx 仍是共享祖先。Next.js 会复用它,只替换发生变化的 Page 子树。侧边栏的展开状态、未受影响的 Client Component 状态和浏览器滚动状态因此可以保留。
这也解释了为什么 Layout 不能依赖“每次导航都会重新执行”来做安全判断。布局的复用是性能特性,不是授权机制。
如果 Atlas 页面正在加载,而用户立即点击“活动”,新的导航可以取代尚未完成的旧导航。共享布局依旧可操作,用户不用等待前一次请求结束才能离开。
“没有整页刷新”不等于“没有网络请求”,也不等于“所有组件永远保留”。目标路由缺少的 RSC 片段仍要从服务器取得;只有共享且可复用的部分会留下。
站内页面之间的常规跳转,首选 Link。它在底层仍渲染为链接,因此保留右键打开、新标签页、复制地址、键盘聚焦和搜索引擎发现等能力,同时增加客户端导航和预取。
import Link from 'next/link'
export default function ProjectCard() {
return (
<article>
<h2>Atlas</h2>
<p>移动端发布计划</p>
<Link href="/workspace/projects/atlas">打开项目</Link>
</article>
)
}判断标准不是“长得像文字还是长得像按钮”,而是点击后发生什么:
不要用 href="#" 或 javascript: 把链接伪装成按钮。它既破坏语义,也可能把危险字符串带进导航链路。
项目 slug 来自数据时,不能直接拼接:
import Link from 'next/link'
type ProjectLinkProps = {
slug: string
name: string
}
export function ProjectLink({ slug, name }: ProjectLinkProps) {
const href = `/workspace/projects/${encodeURIComponent(slug)}`
return <Link href={href}>{name}</
encodeURIComponent 防止 slug 里的 /、?、# 改变 URL 结构。编码只解决结构问题,不会验证这个项目是否存在、当前用户是否有权访问;这些仍要在服务器数据层检查。
Link 的 prefetch 在当前版本有三种常用选择:
自动预取只在生产环境生效。开发环境里观察不到,不代表部署后没有预取。

Next.js 16 重写了客户端预取实现。共享布局只需下载一次,后续链接按 Router Cache 缺少的片段增量请求。因此网络面板里可能出现更多、但更小的请求。这通常不是重复浪费,而是框架在补齐不同目标的缓存缺口。
预取任务也会根据用户意图调整优先级。链接离开视口后,低价值任务可以取消;用户悬停或重新进入目标时,任务会重新获得优先级;已失效的预取结果也可以再次准备。应用通常无需自己维护这套调度。
项目列表有二十张卡片时,默认预取通常能带来更快的点击响应。若是一万行虚拟列表,或每个详情都很重,再根据真实流量关闭部分预取。先测量,再调整;把所有链接一律设成 false,会同时失去框架最直接的导航优化。
replace 会替换当前历史记录,适合不希望“后退”回到旧 URL 的场景:
<Link href="/workspace/projects?view=board" replace>
切换到看板
</Link>scroll={false} 会阻止这次导航自动调整滚动位置,适合只改变同页筛选条件:
<Link
href="/workspace/projects?view=board&sort=due"
scroll={false}
>
按截止时间排序
</Link>onNavigate 只在同源客户端导航发生时触发。它适合拦截“表单还有未保存内容”这类导航决策;带修饰键的新标签页、外站链接和下载链接不属于同一类事件。
'use client'
import Link from 'next/link'
export function EditorLink({ dirty }: { dirty: boolean }) {
return (
<Link
href="/workspace/projects"
onNavigate={(event) => {
if (dirty && !window.confirm('更改尚未保存,仍要离开吗?')) {
event.
这里不能替代服务器端保存确认。它只是当前浏览器里的离开提醒。
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const items = [
{ href: '/workspace', label: '概览' },
{ href: '/workspace/projects', label: '项目' },
{ href: '/workspace/activity', label: '活动' },
]
export function WorkspaceNav() {
const pathname = usePathname()
根路径或栏目首页必须单独比较。若直接用 pathname.startsWith('/workspace'),三个菜单都会被误判为当前项。
下面的实验台会模拟链接进入视口、预取和点击后的时间线。切换静态/动态路由、loading 边界与 prefetch 模式,观察请求单位怎样变化。
useRouter 适合“先执行逻辑,再决定去哪”的客户端事件。它只能在 Client Component 中使用,并从 next/navigation 导入。
'use client'
import { type FormEvent, useState } from 'react'
import { useRouter } from 'next/navigation'
export function ProjectSearch() {
const router = useRouter()
const [keyword, setKeyword] = useState('')
function handleSubmit(event: FormEvent<HTMLFormElement>) {
如果只是显示一个“打开项目”入口,不需要写点击处理器;直接用 Link 会更短,也更符合浏览器习惯。
push 和 replace 都可以传 { scroll: false }:
router.replace('/workspace/projects?view=list', { scroll: false })手动 router.prefetch('/workspace/projects/atlas') 只适合程序已经知道用户下一步很可能去哪里时使用。普通可见链接已有自动预取,不需要再在每次渲染中重复调用。
router.back() 走的是真实浏览器历史,不是“回到当前路由的父目录”。用户若从外部网站直接进入详情,它可能离开应用;新标签页里也可能没有可用的站内上一页。产品若要求按钮始终回到项目列表,应直接使用指向 /workspace/projects 的 Link。
router.refresh() 会为当前路由发起新的服务器请求,重新渲染 Server Components,并把新 RSC Payload 合并到现有树中。未受影响的 useState 和浏览器滚动位置会保留。
浏览器硬刷新则重新加载整个文档和前端资源,客户端内存状态通常丢失。
还有第三件事必须分开:服务端数据缓存失效。若服务端读取仍命中有效 Data Cache,router.refresh() 可能取得与刚才一样的数据。要改变服务器缓存,需要在数据变更处使用相应的 revalidation 策略;那是数据获取章节的职责。
Next.js 16 里还有一个从 next/cache 导入、用于 Server Action 的 refresh()。它与客户端 useRouter().refresh() 同名,但运行位置和调用方式不同。看代码时先看 import,不能只看函数名。
下面这种代码看起来灵活,实际上把导航执行权交给了输入:
// 错误示例:next 可能是 javascript:alert(document.cookie)
router.push(next)当前 Router 会执行传入的 javascript: URL。安全做法是只接受预先允许的站内路径,或解析后检查同源和路径前缀。
function toAllowedWorkspacePath(raw: string): string | null {
try {
const url = new URL(raw, window.location.origin)
if (url.origin !== window.location.origin) {
return null
}
const allowed =
url.pathname === '/workspace' ||
url.pathname.startsWith('/workspace/')
站外导航应使用普通 <a href>,或在确实需要命令式离站时使用 window.location.assign()。不要把外站地址与站内 Router 混在一个模糊的字符串入口里。
App Router 的 useRouter 没有 Pages Router 那套 router.events。需要记录页面浏览时,可以组合 usePathname 与 useSearchParams:
'use client'
import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
export function NavigationObserver() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
const query = searchParams.toString()
const url = query ? `${
如果所在路由会静态预渲染,这个观察器也应放在 Suspense 中,避免它把更大的静态区域带入客户端渲染。分析函数还要自己去重,不能假设每次 Effect 都代表一次用户可见的新页面。
状态放不放进 URL,可以用三个问题判断:
三个答案大多是“是”,就适合放进 URL。项目筛选、排序、分页和标签页通常符合;输入法组合状态、悬停和未提交草稿通常不符合。

usePathname() 是 Client Component Hook,返回当前 pathname,不包括 ? 后的查询参数和 # 片段。
'use client'
import { usePathname } from 'next/navigation'
export function CurrentPath() {
const pathname = usePathname()
return <p>当前路径:{pathname}</p>
}Server Component 不能读取“浏览器当前 pathname”是有意设计。这样共享 Layout 才能在导航时保持,而不是每次因客户端地址变化被迫重新建立。
如果应用配置了 rewrite 或 Proxy,服务器预渲染时认识的路径可能和浏览器最终路径不同。只让一个小型 Client Component 在挂载后显示依赖真实 pathname 的内容,能减少 hydration mismatch 的影响。
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
export function ViewSwitcher() {
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const view = searchParams.get('view') ?? 'list'
function setView(nextView: 'list'
不能对 searchParams 本身调用 set。先用 toString() 复制成新的 URLSearchParams,再修改。
同名参数可以重复出现,例如 ?tag=frontend&tag=urgent。此时 get('tag') 只返回第一个值,getAll('tag') 才会返回完整数组。
在静态预渲染路由中,useSearchParams 会使最近的 Suspense 边界以下切换为客户端渲染。生产构建要求存在这个边界;开发模式可能看起来正常,因此必须用生产构建验证。
// app/workspace/projects/page.tsx
import { Suspense } from 'react'
import { ProjectFilters } from './project-filters'
export default function ProjectsPage() {
return (
<main>
<h1>项目</h1>
<Suspense fallback={<p>正在读取筛选条件…</p>}>
<ProjectFilters />
</Suspense>
动态渲染的路由可以在初次服务器渲染 Client Component 时取得查询参数,但保持一个边界通常仍有利于明确局部回退范围。
Page 若要根据查询参数在服务器取数,应使用 Page Prop:
type ProjectsPageProps = {
searchParams: Promise<{
view?: string | string[]
sort?: string | string[]
tag?: string | string[]
}>
}
function first(value: string | string[] | undefined) {
return Array.
Next.js 16 中 searchParams 是 Promise。它在 Page 中是普通对象,不是 URLSearchParams;重复键可能变成数组。使用它还意味着页面依赖每次请求的 URL,不能把结果简单当成与请求无关的静态输出。
Layout 不接收 searchParams,因为 Layout 在客户端导航中会复用,直接持有旧查询值会产生陈旧界面。需要实时查询状态时,用 Page Prop 或放在小型 Client Component 里的 useSearchParams。
搜索框每输入一个字符就 push,会把 a、at、atl 全塞进历史记录。用户按一次后退只删掉一个字符,体验很差。可以在防抖后使用 replace 更新当前条目,按下“搜索”或选择一个明确结果时再 push。
Next.js 会把 window.history.pushState 和 replaceState 与 App Router 同步。调用后,usePathname 与 useSearchParams 能看到新地址。
'use client'
import { useSearchParams } from 'next/navigation'
export function DensityControl() {
const searchParams = useSearchParams()
const density = searchParams.get('density') ?? 'comfortable'
function setDensity(nextDensity: 'compact' | 'comfortable') {
const next = new URLSearchParams(searchParams.toString

History API 更接近浏览器底层,适合只想调整地址和历史栈、并明确理解后果的局部控件。常规页面导航仍用 Link 或 Router,代码意图更清楚。
pushState 会增加记录,replaceState 会替换当前记录;二者都要求新 URL 与当前页面同源。用户按后退或前进时浏览器才会移动历史指针。
下面的实验台不会真的离开当前课程。你可以编辑 slug、查询参数和片段,再比较 push、replace、back、forward 与 refresh 对历史栈的影响。
路由文件名告诉框架如何提取参数,但不会证明参数合法。
// app/workspace/projects/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getProjectForViewer } from '@/app/data/projects'
type ProjectPageProps = {
params: Promise<{ slug: string }>
}
export default async function ProjectPage({ params }: ProjectPageProps) {
const { slug } = await params
正则只负责输入形状,getProjectForViewer 还应校验当前用户是否能读取该项目,并只返回页面需要的字段。
'use client'
import { useParams } from 'next/navigation'
export function ProjectShortcut() {
const { slug } = useParams<{ slug: string }>()
return <p>当前项目:{slug}</p>
}TypeScript 泛型只帮助当前代码理解类型,不会在运行时检查地址栏。若路由改成 Catch-all,对应类型也要改成 string[]。所有真正影响查询和权限的参数仍需在服务端验证。
隐藏一个无权限链接、在客户端把用户推回首页,都不构成授权。URL 可以手输,请求也可以绕过界面。权限检查应靠近数据读取,并在 Server Action 和 Route Handler 中各自执行;Layout 只负责导航体验。
浏览器里的 Router Cache 保存按路由段组织的 RSC Payload。它的目的,是让后续导航复用已经取得的共享片段。

从项目列表依次打开 Atlas 和 Nova 时,共享 Workspace Layout 不必为每个目标重复下载。Next.js 16 会对布局预取去重,并为每个目标补齐缓存中缺少的片段。
这和服务器 Data Cache 不是同一个层次:
不要写一个固定的“Router Cache 永远保存多少秒”作为业务契约。版本、路由类型、预取方式和配置都会影响生命周期。应用应把它看成框架导航优化,而不是数据正确性的唯一来源。
共享 Layout 被保留,所以它可能不会在每次客户端导航时重新执行。把权限检查只写在 Layout 中,用户仍可能访问更深层数据入口。正确做法是:
router.refresh() 会清理并重新获取当前客户端路由结果,再与未受影响的 Client Component 状态合并。它不等同于清空所有已访问路由,也不会替你决定服务器数据是否过期。
如果变更发生在 Server Action 中,通常先在服务端执行相应的缓存失效,再重定向或让客户端刷新。不要在未知数据策略上连续调用 refresh(),那只会制造额外请求。
导航体验不该只靠一个全屏转圈。更自然的层级是:能预取就提前准备;目标数据仍慢时显示路由骨架;某个未预取链接确实等待时,再显示链接级 pending。
// app/workspace/projects/[slug]/loading.tsx
export default function ProjectLoading() {
return (
<main aria-busy="true" aria-label="正在加载项目">
<div className="project-title-skeleton" />
<div className="project-board-skeleton" />
<p>正在加载项目…</p>
</main>
)
}loading.tsx 会自动包住同一段的 Page 和更深内容。它的 fallback 可以被预取,所以动态目标即使正文尚未完成,也能在点击后立即进入有反馈的界面。外层 Layout 仍保持可操作,导航也可以被用户打断。
骨架应尽量贴近最终布局。一个与内容尺寸完全不同的巨大转圈,会在结果到达时造成明显跳动。
'use client'
import Link, { useLinkStatus } from 'next/link'
function PendingLabel() {
const { pending } = useLinkStatus()
if (!pending) {
return null
}
return (
<span role="status" aria-live="polite">
正在打开…
</span>
)
useLinkStatus 必须在 Link 的后代组件中调用。目标已经预取完成时,pending 可能快到不出现;用户连续点击多个链接时,只有最后一次有效导航应成为主要反馈。
实践中可以延迟大约一小段时间再显示微型提示,并为提示预留固定宽度。这样快速导航不会闪一下,慢导航又有明确反馈。若页面已有足够清楚的 loading.tsx,不要再叠一个全屏 pending。

可以按这个顺序判断:
loading.tsx。useLinkStatus。跳转 API 不是语法偏好,它们表达的是不同事实。

307 和 308 会保留原请求方法,避免 POST 被旧式 301/302 语义意外改成 GET。Server Action 完成后采用 303,是明确让浏览器用 GET 打开结果页面。
在当前版本中,Server Action 的响应传输层会采用 303;即使业务调用 permanentRedirect(),这次 Action 响应也按 303 完成跳转。资源后续的规范地址仍应在普通请求和链接层保持永久语义。
import { redirect } from 'next/navigation'
export default async function BillingPage() {
const viewer = await getViewer()
if (!viewer) {
redirect('/login?next=%2Fworkspace%2Fbilling')
}
return <h1>账单</h1>
}redirect() 会抛出 NEXT_REDIRECT,终止当前路由段,因此调用后不需要 return。不要把它放进会吞掉所有错误的 try/catch。
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function createProject(formData: FormData) {
let slug: string
try {
const name = String(formData.get('name') ?? '').trim()
slug =
真正可能失败的数据库操作放在 try 中,重定向放在外面。否则 catch 会把成功导航误当成业务错误。
项目 slug 被管理员从 atlas-mobile 改成 atlas,旧地址仍有外部链接时,可以在查到别名映射后永久重定向:
import { notFound, permanentRedirect } from 'next/navigation'
export default async function ProjectPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const resolution = await resolveProjectSlug(slug)
if (!
永久重定向通常返回 308,告诉客户端和搜索引擎以后使用新地址。临时登录回跳、实验分流等场景不要误用永久语义。
notFound() 终止当前段并显示最近的 not-found.tsx,同时加入 noindex,避免不存在的资源被索引。
// app/workspace/projects/[slug]/not-found.tsx
import Link from 'next/link'
export default function ProjectNotFound() {
return (
<main>
<h1>没有找到这个项目</h1>
<p>项目可能已删除、已更名,或你没有可见的公开结果。</p>
<Link href="/workspace/projects">返回项目列表</Link>
</main>
)
}非流式响应在开始发送前触发 notFound(),HTTP 状态为 404。如果页面已经开始流式输出,响应头不能再改,最终 HTTP 状态可能是 200,但页面仍显示 Not Found,并带 noindex。监控系统不能只看正文,也不能假设所有 404 UI 都一定在传输层返回 404。
响应尚未开始时,服务端可以直接返回重定向状态。若流式内容已经发送,Next.js 会在输出中插入客户端 meta redirect,让浏览器完成跳转。业务代码仍调用同一个 redirect(),但抓包时看到的传输形态可能不同。
下面的决策实验台会根据执行环境、资源状态和历史意图选择 API,并解释对应状态码。
客户端导航不重新加载文档,所以页面作者要更明确地处理当前项、标题和等待状态。
默认情况下,Next.js 会先检查新 Page 是否已经位于视口中。若可见,会尽量保留滚动位置;若不可见,则寻找新 Page 中第一个可滚动的页面元素并滚到顶部。固定定位、不可见元素等不会被当成普通目标。
只改变项目排序时可以用 scroll={false} 保持阅读位置;进入全新详情页通常保留默认行为。
片段导航仍可使用:
<Link href="/workspace/projects/atlas#comments">
查看评论
</Link>页面有固定顶部栏时,可为滚动容器设置 scroll-padding-top,避免锚点标题被遮住。
如果全局 CSS 使用:
html {
scroll-behavior: smooth;
}Next.js 16 默认尊重它,不再像旧版本那样在路由导航时临时强制瞬间滚动。若项目明确需要框架在导航时覆盖平滑行为,可以在根 <html> 上增加:
<html lang="zh-CN" data-scroll-behavior="smooth">属性名容易误解:它是在告诉 Next.js “项目确实声明了 smooth,请在必要的导航滚动中应用兼容处理”,不是单独开启 CSS 平滑滚动。
Next.js 自带路由播报器。导航完成后,它依次寻找:
document.title<h1>因此每个页面应有独立、准确的 Metadata title 和唯一主标题。只换卡片内容、却让所有页面都叫“Workspace”,使用屏幕阅读器的用户很难知道导航是否成功。
路由播报不等于焦点一定移到了新标题。只更新筛选查询参数时,焦点通常应留在刚操作的筛选控件上;关闭拦截式弹层时,应回到打开它的链接或按钮。若一次完整导航确实需要把键盘用户带到正文,可以让主标题或 <main> 具备 tabIndex={-1},并在导航完成后有条件地调用 focus()。
不要在每次查询参数变化时都抢走焦点。用户正连续勾选标签时,突然把焦点移到结果标题,会让键盘操作中断。焦点策略应由“用户完成了什么任务”决定,而不是由“URL 是否发生变化”决定。
活动链接增加 aria-current="page";加载文字可用 role="status" 或 aria-live="polite";骨架区域可使用 aria-busy="true"。颜色和动画可以辅助,但不能是唯一线索。
所有 Link、Button 和输入控件还应有清楚的键盘 focus 样式。若动画不是理解流程所必需,应尊重 prefers-reduced-motion。
用户失去复制链接、新标签页、浏览器状态栏预览和链接语义。只有事件必须先执行程序逻辑时才用 Router。
这可能执行 javascript: URL。应验证同源、允许的路径前缀和参数结构;动态路径段使用 encodeURIComponent。
'?q=' + keyword + '&tag=' + tag 会在空格、&、重复键和 Unicode 上出错。使用 URLSearchParams。
它是只读视图。复制成新的 URLSearchParams 后再改,并决定使用 push 还是 replace。
静态预渲染页面使用 useSearchParams 时,开发模式可能没有暴露问题,生产构建却会失败。为 URL 感知的客户端区域加最近 Suspense,并跑生产编译。
router.refresh() 重新请求当前 RSC 树,但服务端数据仍可能命中缓存。缓存变更应在写入发生处明确 revalidate。
Layout 会在局部导航中复用。它可以隐藏菜单,不能替代数据层、Server Action 和 Route Handler 的授权。
redirect() 通过抛出控制流异常工作。通用 catch 会把成功跳转吞掉。捕获业务操作错误后,在 try/catch 外重定向。
这会让许多原本可立即切换的目标退化为点击后等待。只针对有证据的昂贵场景关闭。
路由已有骨架时,Link pending 应保持轻量;不要再叠全屏遮罩。反馈应靠近真正等待的位置。
把本章内容合起来,项目协作台可以按下面的顺序实现。
用 Link 建立工作区侧边栏和项目卡片,动态 slug 使用 encodeURIComponent。当前项用 usePathname 判断,并设置 aria-current="page"。
把 view、sort、tag 和页码写进查询参数。服务端 Page await searchParams 并做白名单归一化;客户端筛选器复制只读 SearchParams 后更新。
高频输入经过防抖后用 replace,明确提交或进入详情时用 push。若直接使用 History API,保持同源并确认后退行为符合用户预期。
为动态项目详情增加 loading.tsx。只有未预取链接确实需要局部反馈时,再加入 useLinkStatus,避免与路由骨架重复。
URLSearchParams 构造。push 与 replace 的历史行为符合预期。最后用四个问题做决定即可:
答案明确后,Link、Router、History API、redirect 和 notFound 就不再是一组需要死记的函数,而是一套各自负责不同语义的工具。
在服务器数据层验证 slug、身份和项目权限。不存在的公开资源调用 notFound;客户端隐藏菜单只处理体验。
创建项目后先完成数据写入和缓存失效,再在 try/catch 外 redirect。slug 永久变更使用 permanentRedirect,并保留旧地址解析。
最后检查滚动、标题、H1、键盘焦点、路由播报和生产环境预取。用生产构建验证 useSearchParams 的 Suspense 边界。