Next.js: 导航方案:Link 与编程式导航
最后更新:2026-08-26
导航就像地铁系统——有自动运行的线路(Link 组件),也有手动驾驶的选项(编程式导航),还有快速通勤的直达车(soft navigation)和换乘线路(hard navigation)。
1. 你将学到
<Link>组件的核心用法:prefetch、scroll、replaceuseRouter编程式导航:push、replace、back、forward、refreshredirect()函数与<Redirect>组件的使用场景- Soft Navigation 与 Hard Navigation 的原理与区别
- 导航加载指示器的实现方案
2. 一个初级开发者的真实故事
(1) 痛点:页面间导航体验差
Charlie 最近在用 Next.js 开发一个电商网站,发现导航体验很差:
"用户从首页点一个商品链接,页面白屏了 1 秒才显示。每次点"返回"都要重新加载整个页面。而且有个 Bug——用户填了一半的表单,不小心点了商品链接再返回,表单数据全丢了。"
具体问题:
| 问题 | 影响 | 用户反馈 |
|---|---|---|
| 页面切换白屏 | 导航体验生硬 | "点链接要等好久" |
| 表单数据丢失 | 用户重复填写 | "填了一半退回就没了" |
| 预取不智能 | 没有预加载常用页面 | "首页到商品详情最慢" |
| 滚动位置重置 | 列表页翻页后回到顶部 | "每次要重新往下翻" |
(2) Next.js 导航方案的解法
Link 组件自动预取 + Soft Navigation 保持状态 + scroll={false} 保留滚动位置。
TSX
// 优化后的产品列表页导航
<Link
href={`/products/${product.id}`}
prefetch={true}
scroll={false}
className="block p-4 border rounded hover:shadow"
>
{product.title}
</Link>
(3) 收益
| 维度 | 优化前(普通 <a> 标签) |
优化后(<Link> 组件) |
|---|---|---|
| 页面转换速度 | 1-2s 白屏 | 即时(预取缓存) |
| 表单数据保持 | 页面卸载丢失 | Soft Nav 保持 |
| 滚动位置保留 | 回到顶部 | 精确保留 |
| 用户体验评价 | 3.2/5 | 4.8/5 |
3. Link 组件
(1) 基本用法
graph LR
A[Link 组件] --> B[客户端导航<br/>无整页刷新]
A --> C[自动预取<br/>viewport 内的链接]
A --> D[滚动控制<br/>scroll={false}]
A --> E[替换历史<br/>replace]
style A fill:#cce5ff
style B fill:#d4edda
▶ 示例:Link 基本导航
TSX
// ============================================
// Link 组件基本用法
// ============================================
import Link from "next/link";
export default function Navigation() {
return (
<nav className="flex gap-6 p-4 bg-white shadow-sm">
{/* 基本导航 */}
<Link href="/" className="text-blue-600 hover:underline">
Home
</Link>
{/* 动态路由 */}
<Link href="/products/42" className="text-blue-600 hover:underline">
Product 42
</Link>
{/* 带查询参数 */}
<Link
href="/products?category=electronics&sort=price"
className="text-blue-600 hover:underline"
>
Electronics
</Link>
{/* 完整 URL */}
<Link
href="https://help.example.com"
className="text-blue-600 hover:underline"
>
Help Center
</Link>
</nav>
);
}
输出:
TEXT
📖 仅展示
浏览器显示 4 个蓝色链接:
Home → /
Product 42 → /products/42
Electronics → /products?category=electronics&sort=price
Help Center → https://help.example.com(外部链接)
(2) prefetch 预取
Link 组件默认会预取 viewport 内的链接。预取行为在服务器/客户端有区别:
| 环境 | prefetch={true} |
prefetch={false} |
|---|---|---|
| 服务端渲染 | 预取页面数据和 RSC payload | 不预取 |
| 静态页面 | 预取完整页面 | 不预取 |
| viewport 内 | 默认行为 | 从不预取 |
▶ 示例:prefetch 行为控制
TSX
// ============================================
// 控制 Link 的预取行为
// ============================================
import Link from "next/link";
export default function ProductList() {
const products = [
{ id: 1, name: "Wireless Mouse" },
{ id: 2, name: "Keyboard" },
{ id: 3, name: "Monitor" },
];
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Products</h1>
{/* 默认 prefecth:viewport 内的链接自动预取 */}
{products.map(p => (
<Link
key={p.id}
href={`/products/${p.id}`}
className="block p-4 border-b hover:bg-gray-50"
>
{p.name}
</Link>
))}
{/* 关闭预取:适合不常用的页面 */}
<Link
href="/archive"
prefetch={false}
className="block mt-4 text-gray-500"
>
View Archive (older products)
</Link>
{/* 强制预取:适合下一步即将访问的页面 */}
<Link
href="/checkout"
prefetch={true}
className="block mt-4 px-6 py-2 bg-blue-600 text-white text-center rounded"
>
Proceed to Checkout
</Link>
</div>
);
}
输出:
TEXT
📖 仅展示
页面加载后,网络面板可观察到:
1. /products/1, /products/2, /products/3 被预取(在 viewport 内)
2. /archive 没有被预取(prefetch={false})
3. /checkout 被预取(prefetch={true},即使不在 viewport)
用户点击链接时:
- 预取过的页面 → 即时显示(从缓存读取)
- 未预取的页面 → 网络请求后显示
(3) scroll 滚动控制
graph LR
A[导航触发] --> B{scroll 属性}
B -->|scroll=true 默认| C[滚动到新页面顶部]
B -->|scroll=false| D[保持当前滚动位置]
style C fill:#f8d7da
style D fill:#d4edda
▶ 示例:scroll={false} 保持滚动位置
TSX
// ============================================
// 商品列表 + 模态框:scroll=false 保持列表位置
// 用户点击商品打开详情,返回时列表不滚动
// ============================================
import Link from "next/link";
export default function ProductGrid() {
const products = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
name: `Product ${i + 1}`,
}));
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Product Catalog</h1>
<div className="grid grid-cols-4 gap-4">
{products.map(p => (
<Link
key={p.id}
href={`/products/${p.id}`}
scroll={false}
className="border p-4 rounded hover:shadow-lg"
>
<div className="h-32 bg-gray-100 rounded" />
<p className="mt-2 font-medium">{p.name}</p>
</Link>
))}
</div>
</div>
);
}
输出:
TEXT
📖 仅展示
1. 用户浏览到第 3 页商品(已向下滚动 2000px)
2. 点击第 15 个商品进入详情页
3. 按浏览器返回按钮
4. 回到列表页,滚动位置保持在 2000px(没有回到顶部)
5. 对比:如果没有 scroll={false},每次返回都回到顶部
(4) replace 替换历史
| 行为 | push(默认) |
replace |
|---|---|---|
| 浏览器历史 | 新增一条历史记录 | 替换当前历史记录 |
| 返回按钮行为 | 返回到上一个页面 | 跳转到替换前的页面 |
| 表单提交后 | 不可返回表单页 | 可返回(绕过表单页) |
▶ 示例:replace 在表单场景的应用
TSX
// ============================================
// replace 替换历史:提交后不可返回表单页
// ============================================
import Link from "next/link";
export default function CheckoutPage() {
return (
<div className="max-w-md mx-auto p-8">
<h1 className="text-2xl font-bold mb-6">Checkout</h1>
<div className="space-y-4">
<input placeholder="Card number" className="w-full p-3 border rounded" />
<input placeholder="Expiry date" className="w-full p-3 border rounded" />
<input placeholder="CVV" className="w-full p-3 border rounded" />
</div>
{/* 使用 replace:提交后返回不可回到此页面 */}
<Link
href="/order-confirmation"
replace
className="block mt-6 w-full p-3 bg-blue-600 text-white text-center rounded"
>
Place Order
</Link>
<p className="text-sm text-gray-500 mt-2 text-center">
After placing order, back button will skip this page
</p>
</div>
);
}
输出:
TEXT
📖 仅展示
用户流程:
1. 首页 → 购物车 → 结账页 → 确认页
2. 在结账页点击 "Place Order"
3. 浏览器地址变为 /order-confirmation
4. 用户点击后退按钮
5. 跳过结账页,直接回到购物车页面
6. 避免用户误回结账页重复提交订单
4. useRouter 编程式导航
(1) API 速查
graph TB
A[useRouter] --> B[push(url) - 导航到新页面]
A --> C[replace(url) - 替换当前历史]
A --> D[back() - 后退]
A --> E[forward() - 前进]
A --> F[refresh() - 刷新当前页]
A --> G[prefetch(url) - 编程式预取]
style A fill:#cce5ff
| 方法 | 参数 | 说明 | 浏览器行为 |
|---|---|---|---|
push |
href: string |
导航到新 URL | 新增历史记录 |
replace |
href: string |
替换当前历史 | 替换历史记录 |
back |
无 | 浏览器后退 | 同 history.back() |
forward |
无 | 浏览器前进 | 同 history.forward() |
refresh |
无 | 刷新当前页面 | 服务端重新渲染 RSC |
prefetch |
href: string |
预取页面 | 缓存 RSC payload |
▶ 示例:编程式导航
TSX
// ============================================
// useRouter 编程式导航完整示例
// ============================================
'use client';
import { useRouter } from "next/navigation";
export default function NavigationBar({ userId }: { userId: string }) {
const router = useRouter();
return (
<div className="p-4 bg-white shadow-sm">
<div className="flex gap-4 max-w-4xl mx-auto">
{/* push:导航到首页 */}
<button
onClick={() => router.push("/")}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Home
</button>
{/* push:动态路由 */}
<button
onClick={() => router.push(`/users/${userId}`)}
className="px-4 py-2 bg-gray-600 text-white rounded"
>
Profile
</button>
{/* replace:替换当前页 */}
<button
onClick={() => router.replace("/login")}
className="px-4 py-2 bg-red-600 text-white rounded"
>
Logout
</button>
{/* back/forward:导航历史 */}
<button
onClick={() => router.back()}
className="px-4 py-2 border rounded"
>
← Back
</button>
<button
onClick={() => router.forward()}
className="px-4 py-2 border rounded"
>
Forward →
</button>
{/* refresh:刷新当前页(服务端重新渲染) */}
<button
onClick={() => router.refresh()}
className="px-4 py-2 border rounded"
>
Refresh ↻
</button>
</div>
</div>
);
}
输出:
TEXT
📖 仅展示
点击各个按钮:
[Home] → 导航到 /
[Profile] → 导航到 /users/123
[Logout] → 替换当前历史为 /login(不可返回)
[← Back] → 浏览器后退
[Forward] → 浏览器前进
[Refresh] → 当前页面刷新(RSC 重新渲染,不造成整页刷新)
▶ 示例:表单提交后导航
TSX
// ============================================
// 表单提交:验证 → save → 编程式导航
// ============================================
'use client';
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function CreateProjectForm() {
const router = useRouter();
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
// 模拟 API 调用
await new Promise(resolve => setTimeout(resolve, 1500));
// 成功后导航到新项目页面
router.push("/projects/42");
// 并刷新数据缓存
router.refresh();
} catch (err) {
console.error("Failed to create project:", err);
alert("Failed to create project. Please try again.");
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit} className="max-w-lg mx-auto p-8 space-y-4">
<h1 className="text-2xl font-bold">Create Project</h1>
<input
name="name"
placeholder="Project name"
className="w-full p-3 border rounded"
required
/>
<textarea
name="description"
placeholder="Description"
className="w-full p-3 border rounded h-32"
/>
<button
type="submit"
disabled={saving}
className="w-full p-3 bg-blue-600 text-white rounded disabled:opacity-50"
>
{saving ? "Creating..." : "Create Project"}
</button>
</form>
);
}
输出:
TEXT
📖 仅展示
1. 用户填写项目名称和描述
2. 点击 "Create Project" 按钮
3. 按钮变为 "Creating..."(禁用状态)
4. 1.5s 后(模拟 API 延迟)
5. 页面导航到 /projects/42
6. 项目列表数据刷新(包含新创建的项目)
5. redirect() 与 <Redirect>
(1) 两种重定向方式
graph TB
A[重定向需求] --> B[服务端<br/>redirect()]
A --> C[客户端<br/><Redirect>]
B --> D[Server Action / Route Handler]
B --> E[Server Component]
C --> F[Client Component]
C --> G[条件渲染时]
style B fill:#d4edda
style C fill:#cce5ff
| 方式 | 使用位置 | 触发时机 | 性能 |
|---|---|---|---|
redirect() |
Server Actions / Server Components | 服务端响应时 | 服务端重定向,零客户端成本 |
<Redirect> |
Client Components | 渲染时 | 客户端重定向,路由变化 |
▶ 示例:redirect 在 Server Action 中使用
TSX
// ============================================
// redirect() 在 Server Action 中使用
// 登录验证失败 → 重定向到错误页
// ============================================
// app/actions/auth.ts
'use server';
import { redirect } from "next/navigation";
export async function login(formData: FormData) {
const email = formData.get("email");
const password = formData.get("password");
// 模拟验证
if (email !== "alice@example.com" || password !== "password123") {
redirect("/login?error=invalid_credentials");
}
// 成功后重定向到仪表盘
redirect("/dashboard");
}
输出:
TEXT
📖 仅展示
登录表单提交后:
1. 服务端执行 login Server Action
2. 验证失败 → 服务端发送 303 重定向到 /login?error=invalid_credentials
3. 浏览器自动跳转,无需客户端代码
4. URL 变为 /login?error=invalid_credentials
5. 显示错误提示(从 URL 参数读取)
▶ 示例:<Redirect> 在 Client Component 中使用
TSX
// ============================================
// <Redirect> 组件 — 条件重定向
// 未登录用户访问仪表盘 → 重定向
// ============================================
'use client';
import { useRouter } from "next/navigation";
export default function ProtectedPage() {
const router = useRouter();
const isLoggedIn = false; // 模拟未登录
if (!isLoggedIn) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600">
Access Denied
</h1>
<p className="text-gray-600 mt-2">
Please sign in to access this page.
</p>
<button
onClick={() => router.push("/login")}
className="mt-4 px-6 py-2 bg-blue-600 text-white rounded"
>
Go to Login
</button>
</div>
</div>
);
}
return <div>Dashboard Content</div>;
}
输出:
TEXT
📖 仅展示
访问受保护页面:
1. 检测到 isLoggedIn = false
2. 显示 "Access Denied" + "Please sign in" 提示
3. 点击 "Go to Login" 跳转到登录页
4. 如果是服务端重定向,用 redirect("/login") 更合适
6. Soft vs Hard Navigation
(1) 对比
graph TB
subgraph "Soft Navigation(Link/useRouter)"
A[客户端路由] --> B[页面切换<br/>无整页刷新]
B --> C[layout 保持挂载]
B --> D[React 状态保持]
B --> E[RSC Payload 替换]
end
subgraph "Hard Navigation(浏览器整页刷新)"
F[浏览器整页加载] --> G[页面切换<br/>整页刷新]
G --> H[layout 重新挂载]
G --> I[所有状态重置]
G --> J[全量 JS/CSS 加载]
end
style A fill:#d4edda
style F fill:#f8d7da
| 特性 | Soft Navigation | Hard Navigation |
|---|---|---|
| 触发方式 | <Link>、useRouter() |
浏览器刷新、<a> 标签、window.location |
| 整页刷新 | ❌ 否 | ✅ 是 |
| Layout 保持 | ✅ 保持 | ❌ 重新挂载 |
| RSC Payload | 增量更新 | 全量下载 |
| 性能 | 即时 | 500ms-2s |
| 状态保持 | ✅ | ❌ |
▶ 示例:Soft Navigation 演示
TSX
// ============================================
// Soft Navigation vs Hard Navigation 对比
// ============================================
// app/demo/page.tsx
'use client';
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function NavDemo() {
const router = useRouter();
const [formData, setFormData] = useState({
name: "Alice",
email: "alice@example.com",
});
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-4">Navigation Demo</h1>
{/* 表单数据 */}
<div className="bg-yellow-50 border border-yellow-200 p-4 rounded mb-6">
<p className="text-sm font-medium">Form State (check after navigation):</p>
<pre className="mt-2 text-sm">
{JSON.stringify(formData, null, 2)}
</pre>
</div>
<input
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="Name"
className="w-full p-3 border rounded mb-2"
/>
<input
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
className="w-full p-3 border rounded mb-4"
/>
<div className="flex gap-4">
{/* Soft Navigation — 状态保持 */}
<Link
href="/demo/page-a"
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Soft Nav (Link)
</Link>
{/* Hard Navigation — 状态丢失 */}
<a
href="/demo/page-a"
className="px-4 py-2 bg-red-600 text-white rounded"
>
Hard Nav (a tag)
</a>
</div>
<p className="text-sm text-gray-500 mt-4">
Note: Modify the form data, then try both navigation methods.
<br />
<strong>Link</strong> keeps form state. <strong>a tag</strong> loses it.
</p>
</div>
);
}
输出:
TEXT
📖 仅展示
1. 修改 Name 为 "Bob Jenkins"
2. 点击 "Soft Nav (Link)" → 导航到 /demo/page-a
3. 按浏览器返回 → 表单数据保持 "Bob Jenkins" ✅
4. 修改 Name 为 "Charlie"
5. 点击 "Hard Nav (a tag)" → 整页刷新到 /demo/page-a
6. 按浏览器返回 → 表单数据重置为 "Alice" ❌
(2) router.refresh() 的特殊行为
router.refresh() 是一种特殊的 soft navigation——它不改变 URL,但让服务端重新渲染当前页面的 RSC 组件:
graph LR
A[router.refresh] --> B[保留客户端状态<br/>useState/Context]
A --> C[重新获取服务端数据<br/>RSC 重新渲染]
A --> D[UI 更新<br/>新数据替换旧数据]
A --> E[URL 不变]
style A fill:#d4edda
▶ 示例:refresh 刷新数据
TSX
// ============================================
// router.refresh() — 不改变 URL 的页面刷新
// 适用于:Server Action 提交后刷新数据
// ============================================
'use client';
import { useRouter } from "next/navigation";
export default function ProjectActions({ projectId }: { projectId: string }) {
const router = useRouter();
async function deleteProject() {
// 调用 Server Action 删除项目
await fetch(`/api/projects/${projectId}`, { method: "DELETE" });
// 刷新当前页面的服务端数据(不改变 URL)
router.refresh();
// 现在页面重新渲染,项目列表中不再包含已删除的项目
}
return (
<button
onClick={deleteProject}
className="px-4 py-2 bg-red-600 text-white rounded"
>
Delete Project
</button>
);
}
输出:
TEXT
📖 仅展示
1. 页面渲染项目列表(来自服务端 fetch)
2. 用户点击 "Delete Project" 按钮
3. API 调用删除项目
4. router.refresh() 触发服务端重新渲染
5. 项目列表更新(已删除的项目不再显示)
6. 页面不整页刷新,客户端状态保持
7. 导航加载指示器
(1) 实现方案
graph TB
A[导航触发] --> B[加载指示器]
B --> C[顶部进度条<br/>NProgress 风格]
B --> D[Suspense fallback<br/>骨架屏]
B --> E[loading.tsx<br/>页面级加载]
style A fill:#cce5ff
style B fill:#f8d7da
▶ 示例:顶部导航进度条
TSX
// ============================================
// 自定义导航加载指示器(NProgress 风格)
// ============================================
'use client';
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
export function NavigationProgress() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const timerRef = useRef(null);
useEffect(() => {
const originalPush = router.push;
const originalReplace = router.replace;
function startLoading() {
setLoading(true);
timerRef.current = setTimeout(() => setLoading(false), 10000);
}
function stopLoading() {
setLoading(false);
if (timerRef.current) clearTimeout(timerRef.current);
}
// 注意:这是简化演示,实际需要更完善的实现
// 生产环境建议使用 next/navigation 的 useNavigationEvent
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [router]);
return (
<div
className={`fixed top-0 left-0 h-1 bg-blue-600 transition-all duration-300 z-50 ${
loading ? "w-full opacity-100" : "w-0 opacity-0"
}`}
/>
);
}
输出:
TEXT
📖 仅展示
导航触发时:
1. 浏览器顶部出现蓝色细线(高 4px)
2. 进度条从 0% 快速填充到 100%
3. 页面加载完成后进度条消失
4. 整个动画流畅,无闪烁
5. 用户知道正在导航
8. 完整示例:电商导航系统
TSX
// ============================================
// 综合示例:电商完整的导航系统
// 涵盖 Link、useRouter、redirect、导航加载态
// ============================================
// src/app/layout.tsx — 全局导航栏
import Link from "next/link";
import { CartCount } from "./CartCount";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header className="bg-white shadow-sm sticky top-0 z-40">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<Link href="/" className="text-2xl font-bold text-blue-600">
ShopHub
</Link>
<nav className="hidden md:flex gap-6">
<Link href="/products" className="hover:text-blue-600">
Products
</Link>
<Link href="/categories" className="hover:text-blue-600">
Categories
</Link>
<Link href="/deals" prefetch={false} className="hover:text-blue-600">
Deals
</Link>
<Link href="/about" className="hover:text-blue-600">
About
</Link>
</nav>
<div className="flex items-center gap-4">
<Link href="/search" className="text-gray-600 hover:text-blue-600">
Search
</Link>
<Link href="/cart" className="relative text-gray-600 hover:text-blue-600">
Cart
<CartCount />
</Link>
<Link
href="/account"
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm"
>
Sign In
</Link>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-4 py-8">
{children}
</main>
</body>
</html>
);
}
// src/app/products/[id]/page.tsx — 商品详情(含导航逻辑)
'use client';
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function ProductDetail({ params }) {
const router = useRouter();
async function handleAddToCart() {
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId: params.id }),
});
// 添加购物车后刷新购物车数量
router.refresh();
}
async function handleBuyNow() {
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId: params.id, quantity: 1 }),
});
// 立即购买 → 跳到结账页(用 replace 避免返回商品页)
router.replace("/checkout");
}
return (
<div>
{/* 面包屑导航 */}
<nav className="text-sm text-gray-500 mb-6">
<Link href="/" className="hover:text-blue-600">Home</Link>
<span className="mx-2">/</span>
<Link href="/products" className="hover:text-blue-600">Products</Link>
<span className="mx-2">/</span>
<span className="text-gray-900">{params.id}</span>
</nav>
<div className="flex gap-8">
<div className="w-1/2">
<img
src={`https://picsum.photos/seed/${params.id}/400/400`}
alt="Product"
className="w-full rounded-lg"
/>
</div>
<div className="w-1/2">
<h1 className="text-3xl font-bold">Product #{params.id}</h1>
<p className="text-2xl text-green-600 font-bold mt-4">$49.99</p>
<p className="text-gray-600 mt-4">
High-quality product with premium features.
</p>
<div className="flex gap-4 mt-8">
<button
onClick={handleAddToCart}
className="flex-1 px-6 py-3 border-2 border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50"
>
Add to Cart
</button>
<button
onClick={handleBuyNow}
className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Buy Now
</button>
</div>
<div className="mt-8 border-t pt-6">
<Link
href={`/products/${Number(params.id) - 1}`}
scroll={false}
className="text-blue-600 hover:underline"
>
← Previous Product
</Link>
<Link
href={`/products/${Number(params.id) + 1}`}
scroll={false}
className="text-blue-600 hover:underline float-right"
>
Next Product →
</Link>
</div>
</div>
</div>
</div>
);
}
// src/app/CartCount.tsx — 购物车数量显示
export async function CartCount() {
const cart = await fetch("https://api.example.com/cart");
const itemCount = cart?.items?.length ?? 0;
if (itemCount === 0) return null;
return (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
{itemCount}
</span>
);
}
预期输出:
TEXT
📖 仅展示
导航栏(顶部固定):
[ShopHub] Products Categories Deals About [Search] Cart(3) [Sign In]
商品详情页(/products/1):
Home / Products / 1
[商品图片] [商品详情]
Product #1
$49.99
[Add to Cart] [Buy Now]
← Previous Product | Next Product →
导航行为:
- 点击 Products → Soft Nav,页面即时切换
- 点击 Deals → 不预取(prefetch=false),但导航仍快
- 加购物车 → router.refresh(),购物车徽标更新
- Buy Now → router.replace("/checkout"),替换历史
❓ 常见问题
Q Link 组件和
<a> 标签有什么区别?A Link 组件在客户端进行路由切换,不触发整页刷新,保持 layout 状态,支持预取。
<a> 标签会触发整页加载(hard navigation),所有 React 状态丢失。Next.js 内部路由一律使用 Link。Q 为什么使用 useRouter 的组件需要加 'use client'?
A useRouter 是 React Hook,只能在 Client Component 中使用。Server Component 中没有浏览器 API(history、location),所以路由相关的 hooks 都在客户端执行。
Q router.refresh() 和 window.location.reload() 有什么区别?
A router.refresh() 是 Next.js 的软刷新——它重新请求服务端 RSC Payload,只更新变化的部分,保留客户端状态(useState、Context)。window.location.reload() 是整页刷新,所有状态丢失,全量下载 JS/CSS。
Q Link 的 prefetch 在手机上也会触发吗?
A 会。Prefetch 在 viewport 内的链接都会触发,包括移动端。但 Next.js 会考虑用户的数据使用——在慢速网络(2G/3G)下可能不会预取大的页面。你也可以用 prefetch={false} 手动控制。
Q redirect() 和
<Redirect> 应该在什么时候用?A redirect() 在服务端使用(Server Actions、Server Components、Route Handlers),发送 303/307 响应。客户端条件重定向用访问控制逻辑(显示"无权访问"提示 + 导航按钮),而不是
<Redirect> 组件。Q 导航时 URL 变了但页面内容没变怎么办?
A 这种情况通常是因为使用了相同的 React key 或缓存。可以尝试:1) 在页面组件中使用不同的 key 强制重建 2) 调用 router.refresh() 刷新服务端数据 3) 检查是否被 layout 缓存了数据。
📖 小节
<Link>组件是 Next.js 导航首选,支持自动预取和客户端转换prefetch={true}预取视口内的链接,prefetch={false}关闭预取scroll={false}保持滚动位置,适合列表页 + 详情页场景replace替换浏览器历史,防止表单页误回useRouter提供编程式导航:push、replace、back、forward、refreshredirect()在服务端使用,发送 303/307 重定向响应- Soft Navigation 保持 layout 和状态,Hard Navigation 整页刷新
router.refresh()是特殊软导航:不改变 URL,刷新 RSC 数据
📝 作业
-
基础题(⭐):在页面中创建 5 个 Link(首页、关于、联系、商品列表、商品详情 #42),观察浏览器网络面板哪些 URL 被预取了。
-
进阶题(⭐⭐):实现一个"登录 → 仪表盘"的流程:使用 Server Action 验证登录,成功后用 redirect() 重定向到仪表盘,失败时用 redirect() 回到登录页并携带错误参数。
-
挑战题(⭐⭐⭐):实现一个无限滚动列表页。用户点击商品进入详情页时用 scroll={false} 保持滚动位置,返回时列表保持在之前的位置。使用 router.back() 实现返回导航。