Next.js: 导航方案:Link 与编程式导航

最后更新:2026-08-26

导航就像地铁系统——有自动运行的线路(Link 组件),也有手动驾驶的选项(编程式导航),还有快速通勤的直达车(soft navigation)和换乘线路(hard navigation)。

1. 你将学到


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

(1) 基本用法

100%
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
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 滚动控制

100%
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 速查

100%
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) 两种重定向方式

100%
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) 对比

100%
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 组件:

100%
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) 实现方案

100%
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 缓存了数据。

📖 小节


📝 作业

  1. 基础题(⭐):在页面中创建 5 个 Link(首页、关于、联系、商品列表、商品详情 #42),观察浏览器网络面板哪些 URL 被预取了。

  2. 进阶题(⭐⭐):实现一个"登录 → 仪表盘"的流程:使用 Server Action 验证登录,成功后用 redirect() 重定向到仪表盘,失败时用 redirect() 回到登录页并携带错误参数。

  3. 挑战题(⭐⭐⭐):实现一个无限滚动列表页。用户点击商品进入详情页时用 scroll={false} 保持滚动位置,返回时列表保持在之前的位置。使用 router.back() 实现返回导航。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏