普通视图

发现新文章,点击刷新页面。
昨天 — 2025年8月16日首页

Next.js 15 数据获取指南:掌握服务器组件与客户端数据流(七)

2025年8月15日 08:20

为什么“服务器优先”?

在探索具体的数据获取方法之前,我们必须先理解 Next.js App Router 的核心设计理念:服务器优先(Server-First)

在传统的 React 开发(例如 Create React App)中,我们习惯于在浏览器(客户端)加载完页面骨架后,再通过 useEffect 去请求数据。这会导致用户先看到一个加载中的空白状态,然后数据才姗姗来迟,这种体验并不理想,我们称之为“请求瀑布”。

Next.js 彻底改变了这一点。通过引入服务器组件(Server Components),数据获取的默认执行环境从客户端转移到了服务器

这意味着什么?

  1. 更快的初始加载:数据在服务器上获取完成,与页面HTML一同返回给浏览器。用户打开网页时,看到的就是一个内容完整的页面,不再有烦人的加载状态和布局抖动。
  2. 更安全的数据请求:你可以在服务器组件中安全地访问数据库、使用私密的 API 密钥,因为这些代码永远不会泄露到客户端。
  3. 更小的前端包体积:用于数据获取(如 fetch)和相关逻辑都留在了服务器,无需发送到浏览器,减轻了客户端的负担。

简而言之,Next.js 鼓励我们:尽可能地在服务器上获取数据。只有在确实需要交互性、且数据依赖于客户端状态时(例如,根据用户的输入进行搜索),我们才考虑在客户端获取数据。

fetch 的魔法:不仅仅是请求

在 Next.js 中,fetch API 被赋予了"魔法"。它与 React 和 Next.js 的核心渲染、缓存机制深度集成,提供了强大的请求去重和缓存控制能力。

Next.js 15 的重要变化:默认不缓存

从 Next.js 15 开始,fetch 响应默认不再被缓存。这是一个重大的行为变化,意味着:

  • 默认行为:每次请求都会从远程服务器获取最新数据
  • 性能优化:Next.js 仍会预渲染路由,输出结果会被缓存以提升性能
  • 请求去重:在同一个渲染过程中,相同 URL 和选项的 fetch 请求仍会被自动去重(Request Memoization)

请求去重机制(Request Memoization)

虽然默认不缓存响应,但 Next.js 仍提供了请求去重功能。在同一个 React 组件树的渲染过程中,相同的 fetch 请求只会执行一次:

// app/posts/page.tsx
async function getPosts() {
  // Next.js 15: 默认不缓存,每次都获取最新数据
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

// 在同一次渲染中,这两个调用只会发送一次网络请求
async function getPostsAgain() {
  // 这个请求会被去重,不会发送新的网络请求
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

export default async function Page() {
  const posts = await getPosts(); // 第一次:发送网络请求
  const morePosts = await getPostsAgain(); // 第二次:从内存返回
  // ...
}

控制缓存策略

虽然 Next.js 15 默认不缓存 fetch 响应,但你仍然可以通过配置选项来精确控制缓存行为。

1. 启用缓存(force-cache)

如果你希望缓存某些稳定的数据(如配置信息、静态内容),可以显式设置 cache: 'force-cache'

// 启用缓存,数据会被持久化存储
const res = await fetch('https://api.example.com/config', {
  cache: 'force-cache', // 显式启用缓存
});

1. 确保不缓存(no-store)

对于需要实时更新的数据(如股票价格、新闻快讯),你可以显式设置 cache 选项为 'no-store'(虽然这已经是默认行为):

// 确保每次都重新请求(Next.js 15 的默认行为)
const res = await fetch('https://api.example.com/real-time-data', {
  cache: 'no-store', // 显式禁用缓存
});

3. 定期重新验证(增量静态再生 - ISR)

你可以让数据在一定时间后自动更新。例如,一个博客文章列表,每小时更新一次就足够了。这通过 next.revalidate 选项实现:

// 启用缓存并设置重新验证时间
const res = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 }, // 60秒后重新验证
  // 注意:使用 revalidate 时会自动启用缓存
});

重要提示:当你设置 next.revalidate 时,Next.js 会自动启用缓存,无需显式设置 cache: 'force-cache'。这个特性让你的网站兼具静态网站的访问速度和动态网站的内容更新能力。

Next.js 15 缓存行为总结

为了帮助你更好地理解 Next.js 15 的缓存变化,这里是一个快速参考表:

配置 Next.js 14 及之前 Next.js 15 说明
默认行为 自动缓存 不缓存 重大变化:默认获取最新数据
cache: 'force-cache' 缓存 缓存 显式启用缓存
cache: 'no-store' 不缓存 不缓存 显式禁用缓存
next: { revalidate: 60 } 缓存+重新验证 缓存+重新验证 自动启用缓存
请求去重 同一渲染中的相同请求仍会去重

迁移建议

  • 如果你的应用依赖自动缓存,需要显式添加 cache: 'force-cache' 或使用 next.revalidate
  • 对于实时数据,新的默认行为更符合预期,无需额外配置
  • 开发环境中,HMR 缓存仍然有效,避免了频繁的 API 调用

数据获取实战演练

理论说完了,让我们进入实战环节。

场景一:在服务器组件中获取数据(推荐)

这是最常见、也是最推荐的方式。它非常直观,就像写 Node.js 代码一样。

示例:创建一个博客文章列表页面

// app/blog/page.tsx

// 定义文章类型,这是个好习惯
interface Post {
  id: number;
  title: string;
  body: string;
}

// 异步组件,可以直接使用 await
export default async function BlogPage() {
  console.log("正在服务器上获取数据...");

  // 1. 获取数据
  const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10', {
    next: { revalidate: 3600 } // 每小时更新一次,自动启用缓存
  });

  if (!res.ok) {
    // 更好的错误处理方式见后文
    throw new Error('Failed to fetch posts');
  }

  const posts: Post[] = await res.json();

  // 2. 渲染UI
  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-6">我的博客</h1>
      <ul className="space-y-4">
        {posts.map((post) => (
          <li key={post.id} className="p-4 border rounded-md">
            <h2 className="text-xl font-semibold">{post.title}</h2>
          </li>
        ))}
      </ul>
    </main>
  );
}

就这么简单!没有 useState,没有 useEffect,也没有加载状态的管理。你只需要 async/await,剩下的交给 Next.js。

场景二:在客户端组件中获取数据

什么时候需要在客户端获取数据呢?

  • 当数据是用户专属且高度动态的(如购物车内容)。
  • 当数据依赖于用户的实时交互(如搜索框的自动完成建议)。

要在客户端组件中获取数据,你需要使用 "use client" 指令。

传统方式:useEffect + useState

在 React 19 之前,我们通常这样做:

"use client";

import { useState, useEffect } from 'react';

// ... Post 类型定义

export default function UserProfile() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch('/api/user/posts') // 假设有一个获取用户文章的 API
      .then(res => res.json())
      .then(data => {
        setPosts(data);
      })
      .finally(() => {
        setLoading(false);
      });
  }, []); // 空依赖数组,仅在组件挂载时执行一次

  if (loading) {
    return <p>加载中...</p>;
  }

  return (
    // ... 渲染 posts
  )
}

这种方式代码量多,且需要手动管理 loadingerror 状态,比较繁琐。

现代方式:使用 React 19 use Hook

use hook 是 React 19 带来的革命性新特性,它极大地简化了在客户端组件中处理异步操作(如 fetch)的方式。

前提:你需要一个包裹 fetch 的函数,它会处理 Promise。

// lib/data.ts
import { cache } from 'react';

// `cache` 函数可以包装数据请求,确保在一次渲染中,即使多次调用 `getUserPosts`,也只执行一次。
export const getUserPosts = cache((userId: string) =>
  fetch(`https://api.example.com/users/${userId}/posts`).then((res) => res.json())
);

现在,在你的客户端组件中:

"use client";

import { use } from 'react';
import { getUserPosts } from '@/lib/data';

interface UserPostsProps {
  userId: string;
}

// ... Post 类型定义

function PostsList({ userId }: { userId: string }) {
  // 1. 使用 `use` Hook 获取数据
  // 当 `getUserPosts` 的 Promise 还在 pending 状态时,`use` 会自动抛出这个 Promise,
  // 这会被最近的 <Suspense> 边界捕获。
  const posts: Post[] = use(getUserPosts(userId));

  // 2. 渲染UI
  // 代码能执行到这里,说明数据已经成功获取
  return (
    <ul>
      {posts.map(post => <li key={post.id}>{post.title}</li>)}
    </ul>
  );
}

export default function UserProfilePage({ params }: { params: { userId: string } }) {
  return (
    <div>
      <h1 className="text-2xl">用户文章</h1>
      {/* 必须用 Suspense 包裹使用 `use` 的组件 */}
      <Suspense fallback={<p>正在加载文章列表...</p>}>
        <PostsList userId={params.userId} />
      </Suspense>
    </div>
  )
}

看到了吗?use hook 让客户端数据获取变得和服务器端一样直观简洁。它内置了对 Suspense 的支持,你不再需要手动管理 loading 状态。use 会自动“暂停”组件的渲染,直到数据准备就绪。

加载中与错误处理

一个健壮的应用必须优雅地处理加载和错误状态。Next.js 提供了专门的文件约定来解决这个问题。

使用 loading.tsx 处理加载状态

当你在服务器组件中获取数据时,Next.js 会自动寻找与你的页面平级的 loading.tsx 文件,并将其作为加载指示器。

示例:为博客页面添加入场动画

app/blog/ 目录下,创建一个 loading.tsx 文件:

// app/blog/loading.tsx
export default function Loading() {
  // 你可以在这里设计任何酷炫的加载动画
  return (
    <div className="flex justify-center items-center h-screen">
      <div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
    </div>
  );
}

现在,当用户访问 /blog 页面时,在数据加载完成前,会首先看到这个旋转动画,而不是一个空白页面。这与 React 的 Suspense 边界协同工作,提供了无缝的加载体验。

使用 error.tsx 处理错误

如果数据获取失败(例如,API 服务器宕机),Next.js 会自动捕获错误,并渲染与页面平级的 error.tsx 文件。

注意error.tsx 必须是一个客户端组件 ("use client")。

示例:为博客页面添加错误边界

app/blog/ 目录下,创建一个 error.tsx 文件:

"use client"; // 错误组件必须是客户端组件

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // 你可以在这里记录错误日志
    console.error(error);
  }, [error]);

  return (
    <div className="text-center py-10">
      <h2 className="text-2xl font-bold text-red-600">糟糕,出错了!</h2>
      <p className="my-4">获取文章列表时遇到了问题,请稍后再试。</p>
      <button
        onClick={
          // 尝试重新渲染该路由段
          () => reset()
        }
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        重试
      </button>
    </div>
  );
}

现在,如果 app/blog/page.tsx 中的 fetch 抛出错误,用户将看到这个友好的错误界面,而不是一个崩溃的应用。他们还可以通过点击“重试”按钮来尝试重新加载。

进阶技巧:数据变更与更新

获取数据只是故事的一半,我们还需要更新数据。Server Actions 是 Next.js 用于在服务器上执行数据变更(创建、更新、删除)的利器。

当一个 Server Action 执行后,我们通常需要更新页面上显示的数据。Next.js 提供了两种强大的方式来重新验证缓存:

  1. revalidatePath:使特定路径下的数据缓存失效,下次访问时会重新获取。
  2. revalidateTag:更精细的控制。你可以在 fetch 时给数据打上标签,然后只让带有特定标签的数据缓存失效。

这是一个简化的示例,让你感受一下:

// app/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function addPost(data: FormData) {
  // 1. 调用 API 创建新文章
  await fetch('https://api.example.com/posts', {
    method: 'POST',
    body: JSON.stringify({ title: data.get('title') }),
  });
  
  // 2. 让所有标记为 'posts' 的数据缓存失效
  revalidateTag('posts');
}

// 在 fetch 时打上标签
fetch('https://api.example.com/posts', { next: { tags: ['posts'] } });

进一步提升:结合数据获取库

尽管 Next.js 对 fetch API 进行了强大扩展,满足了大部分数据获取需求,但在复杂的客户端数据管理场景中(例如,需要频繁更新、离线模式、请求重试、缓存过期等),专业的客户端数据获取库能提供更强大的能力和更优雅的开发体验。

目前最受欢迎的两个库是 SWR (Stale-While-Revalidate) 和 React Query (现已更名为 TanStack Query)。它们都基于一个核心思想:“旧数据,新验证”。这意味着它们会立即返回缓存中的旧数据(如果存在),同时在后台发起新的数据请求进行验证和更新。这种模式极大地提升了用户感知的性能。

为什么使用数据获取库?

  1. 自动缓存和去重:自动管理数据缓存,避免重复请求。
  2. 自动重新验证(Revalidation):在窗口重新聚焦、网络重连等场景下自动重新请求数据,确保数据新鲜度。
  3. 错误处理和重试机制:内置完善的错误捕获和自动重试策略。
  4. 加载状态和分页/无限滚动:提供了简单的方式来管理加载状态,并支持高级的分页和无限滚动模式。
  5. 乐观更新:在数据变更时,可以先更新 UI,再等待服务器响应,提升用户体验。

SWR 示例

SWR 由 Vercel (Next.js 的创造者) 团队开发,与 Next.js 的配合非常默契。

// app/dashboard/client-data-fetcher.tsx
"use client";

import useSWR from 'swr';

interface UserData {
  id: number;
  name: string;
  email: string;
}

// 定义一个 fetcher 函数,SWR 会用它来实际请求数据
const fetcher = (url: string) => fetch(url).then(res => res.json());

export default function UserDashboard() {
  // useSWR 的第一个参数是请求的 key (通常是 URL),第二个参数是 fetcher 函数
  const { data, error, isLoading } = useSWR<UserData>('/api/me', fetcher);

  if (error) return <div className="text-red-500">加载失败</div>;
  if (isLoading) return <div className="text-blue-500">加载中...</div>;
  if (!data) return null; // 确保数据存在

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">欢迎, {data.name}!</h1>
      <p>邮箱: {data.email}</p>
      {/* 更多用户数据展示 */}
    </div>
  );
}

如何集成:

  1. 安装 SWRnpm install swryarn add swr
  2. 创建客户端组件:确保你的组件有 "use client" 指令。
  3. 包裹 SWRConfig (可选但推荐):在应用的根组件(例如 layout.tsx 或自定义 _app.tsx)中使用 SWRConfig 提供全局配置,如默认的 fetcher 或错误处理。

React Query (TanStack Query) 示例

React Query 提供了非常丰富的功能和更细粒度的控制。

// app/products/client-product-list.tsx
"use client";

import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';

interface Product {
  id: number;
  name: string;
  price: number;
}

const queryClient = new QueryClient(); // 创建 QueryClient 实例

// 假设的 API 请求函数
async function getProducts(): Promise<Product[]> {
  const res = await fetch('https://api.example.com/products');
  if (!res.ok) {
    throw new Error('Failed to fetch products');
  }
  return res.json();
}

function ProductsList() {
  // useQuery 的第一个参数是查询键 (一个数组,用于缓存识别),第二个是查询函数
  const { data, error, isLoading } = useQuery<Product[], Error>({
    queryKey: ['products'],
    queryFn: getProducts,
  });

  if (isLoading) return <div className="text-blue-500">加载产品中...</div>;
  if (error) return <div className="text-red-500">错误: {error.message}</div>;

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">产品列表</h1>
      <ul>
        {data?.map(product => (
          <li key={product.id}> {product.name} - ¥{product.price.toFixed(2)}</li>
        ))}
      </ul>
    </div>
  );
}

export default function ProductsPage() {
  return (
    // 必须用 QueryClientProvider 包裹,才能在子组件中使用 useQuery
    <QueryClientProvider client={queryClient}>
      <ProductsList />
    </QueryClientProvider>
  );
}

如何集成:

  1. 安装 React Querynpm install @tanstack/react-queryyarn add @tanstack/react-query
  2. 创建 QueryClientProvider:在应用的根组件或需要使用 React Query 的组件树顶层提供 QueryClientProvider

总结:在处理客户端数据时,如果仅仅是简单的展示,Next.js fetch + use Hook 可能已经足够。但对于需要高级缓存、优化交互、错误重试、数据同步等功能的场景,SWR 或 React Query 将是你的最佳选择。它们能让你以更声明式、更强大的方式管理客户端数据流。

优化用户体验:流式渲染与 Suspense

在现代 Web 应用中,用户体验至关重要。即使后端数据响应较慢,我们也希望用户能够尽快看到页面的骨架内容,而不是长时间的白屏。Next.js 15 结合 React 18+ 的并发特性,通过**流式渲染(Streaming)**和 Suspense 为我们带来了极致的用户体验优化。

什么是流式渲染?

想象一下,你正在访问一个包含多个独立部分(例如,一个显示产品列表,一个显示用户评论)的页面。在传统模式下,即使产品列表数据已经就绪,浏览器也必须等待所有部分的数据都加载完毕,才能开始渲染整个页面。

流式渲染改变了这一点。它允许服务器将页面的 HTML 分块发送到浏览器。

  1. 先发送“外壳”HTML:服务器可以立即发送页面布局(例如,导航栏、页脚)的 HTML,而不必等待所有数据加载完成。这让浏览器可以立即开始解析和渲染页面。
  2. 数据就绪时“流”入内容:当某个部分的数据加载完成后,服务器会以 script 标签的形式,将该部分的 HTML 和相关 JavaScript 流式地发送给浏览器。浏览器接收到这些内容后,会将其插入到页面的正确位置。

这意味着用户可以更快地看到页面内容,即使数据尚未完全加载,他们也能够感受到页面正在逐步填充。这显著提升了用户感知的性能。

Suspense 在 Next.js 中的作用

Suspense 是 React 的一个内置组件,它允许你“暂停”组件的渲染,直到其内部的异步操作(例如数据获取)完成。当异步操作处于 pending 状态时,Suspense 会渲染一个 fallback 属性提供的备用内容(例如加载指示器)。

在 Next.js 的 App Router 中,loading.tsx 文件实际上就是 Suspense 的一个应用。

// app/dashboard/layout.tsx (示例)

import { Suspense } from 'react';
import DashboardNav from './DashboardNav';
import DashboardContent from './DashboardContent';

export default function DashboardLayout({
  children,
}: { children: React.ReactNode }) {
  return (
    <section>
      <DashboardNav />
      {/* 这个 Suspense 边界会捕获 DashboardContent 内部可能出现的异步操作 */}
      <Suspense fallback={<p>加载仪表盘内容...</p>}>
        <DashboardContent />
      </Suspense>
      {children}
    </section>
  );
}

loading.tsx 是如何工作的?

当你在一个路由段中定义 loading.tsx 时,Next.js 会自动将其包裹在对应的 Suspense 边界中。例如,对于 /app/blog/page.tsx/app/blog/loading.tsx,Next.js 内部会将其处理为:

// 概念上类似于 Next.js 的内部处理
<Suspense fallback={<Loading />}>
  <Page />
</Suspense>

分层级加载:让页面内容渐进显示

利用流式渲染和 Suspense,我们可以实现页面的分层级加载。这意味着我们可以将页面划分为多个独立的部分,每个部分在自己的数据准备就绪后独立渲染。

示例:一个复杂的用户主页

假设一个用户主页包含:

  • 顶部用户信息 (快速加载)
  • 文章列表 (可能较慢)
  • 好友推荐 (独立加载,可能最慢)
// app/profile/[userId]/page.tsx

import { Suspense } from 'react';
import UserInfo from './UserInfo'; // 假设这里不需要异步数据或数据非常快
import Articles from './Articles'; // 需要异步获取文章列表
import FriendsRecommendations from './FriendsRecommendations'; // 需要异步获取好友推荐

export default async function UserProfilePage({ params }: { params: { userId: string } }) {
  const userId = params.userId;

  return (
    <div className="p-8">
      {/* 用户信息部分,快速渲染 */}
      <UserInfo userId={userId} />

      <h2 className="text-2xl font-bold mt-8 mb-4">我的文章</h2>
      {/* 文章列表,使用 Suspense 边界包裹,数据加载时显示加载状态 */}
      <Suspense fallback={<p>加载文章中...</p>}>
        <Articles userId={userId} />
      </Suspense>

      <h2 className="text-2xl font-bold mt-8 mb-4">好友推荐</h2>
      {/* 好友推荐,独立 Suspense 边界,即便文章列表加载慢,它也可以在自己的数据就绪后显示 */}
      <Suspense fallback={<p>加载好友推荐中...</p>}>
        <FriendsRecommendations userId={userId} />
      </Suspense>
    </div>
  );
}

// app/profile/[userId]/Articles.tsx (服务器组件)
async function Articles({ userId }: { userId: string }) {
  // 模拟较慢的数据获取
  await new Promise(resolve => setTimeout(resolve, 2000)); 
  const res = await fetch(`https://api.example.com/users/${userId}/articles`);
  const articles = await res.json();
  return (
    <ul>
      {articles.map(article => <li key={article.id}>{article.title}</li>)}
    </ul>
  );
}

// app/profile/[userId]/FriendsRecommendations.tsx (服务器组件)
async function FriendsRecommendations({ userId }: { userId: string }) {
  // 模拟最慢的数据获取
  await new Promise(resolve => setTimeout(resolve, 4000)); 
  const res = await fetch(`https://api.example.com/users/${userId}/recommendations`);
  const recommendations = await res.json();
  return (
    <ul>
      {recommendations.map(friend => <li key={friend.id}>{friend.name}</li>)}
    </ul>
  );
}

在这个例子中,UserInfo 会立即显示。同时,ArticlesFriendsRecommendations 组件会并行请求数据,并在数据返回后,通过流式渲染逐步填充到页面中。这种方式极大地提升了用户感知的加载速度,因为他们不必等待最慢的数据。

总结:何时使用 Suspense?

  • 服务器组件loading.tsx 提供了页面的 Suspense 边界。
  • 客户端组件:当你需要在客户端组件内部进行异步数据获取,并希望在数据加载时显示加载状态,同时避免手动管理 loading 状态时,可以使用 React 19 的 use hook 结合 <Suspense> 组件。
  • 分层级加载:当页面包含多个独立且加载时间可能不同的部分时,为每个异步部分包裹 Suspense 边界,可以实现更平滑的渐进式加载体验。

理解并善用流式渲染和 Suspense,是构建高性能 Next.js 应用的关键一步。

服务器组件与客户端组件:数据传递与交互

Next.js App Router 引入了服务器组件(Server Components)和客户端组件(Client Components)的概念,这在提供强大功能的同时,也带来了新的数据流和交互模式。理解它们之间如何通信是掌握 Next.js 的关键。

从服务器到客户端:Props 传递

最常见的数据传递方式是通过组件的 props。服务器组件可以在渲染时获取数据,然后将这些数据作为 props 传递给嵌套的客户端组件。

重要原则:传递给客户端组件的 props 必须是**可序列化(Serializable)**的。这意味着你不能直接传递函数、Symbol、Date 对象(需要转换为字符串或时间戳)、Class 实例等非基本类型数据。如果需要传递这些类型的数据,通常需要进行转换。

示例:服务器组件传递数据给客户端组件

// app/dashboard/page.tsx (服务器组件)

import UserGreeting from './UserGreeting'; // 这是一个客户端组件

interface UserProfile {
  name: string;
  lastLogin: string; // 假设是 ISO 格式字符串
}

async function getUserProfile(): Promise<UserProfile> {
  // 在服务器上获取用户数据
  const res = await fetch('https://api.example.com/user/profile', { cache: 'no-store' });
  if (!res.ok) {
    throw new Error('Failed to fetch user profile');
  }
  return res.json();
}

export default async function DashboardPage() {
  const userProfile = await getUserProfile();

  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-6">仪表盘</h1>
      {/* 将服务器获取的数据作为 props 传递给客户端组件 */}
      <UserGreeting userName={userProfile.name} lastLogin={userProfile.lastLogin} />
      {/* 其他仪表盘内容 */}
    </main>
  );
}

// app/dashboard/UserGreeting.tsx (客户端组件)
"use client";

import { formatDistanceToNow } from 'date-fns'; // 客户端库

interface UserGreetingProps {
  userName: string;
  lastLogin: string; // 接收字符串,客户端再处理
}

export default function UserGreeting({ userName, lastLogin }: UserGreetingProps) {
  const loginTime = new Date(lastLogin); // 在客户端将字符串转换为 Date 对象
  const timeAgo = formatDistanceToNow(loginTime, { addSuffix: true, locale: 'zh-CN' });

  return (
    <div className="mb-4 p-4 bg-green-100 rounded-md">
      <p className="text-lg">你好, <span className="font-semibold">{userName}</span>!</p>
      <p className="text-sm text-gray-600">上次登录: {timeAgo}</p>
    </div>
  );
}

在这个例子中,DashboardPage (服务器组件) 获取用户数据,然后将 userNamelastLogin 作为 props 传递给 UserGreeting (客户端组件)。UserGreeting 在客户端利用 date-fns 库格式化时间,这是只有在客户端才能执行的操作。

从客户端到服务器:Server Actions

客户端组件需要与服务器端逻辑交互时,Server Actions 是最佳选择。它们允许你在客户端组件中直接调用服务器端函数,而无需手动创建 API 路由。

Server Actions 可以在任何服务器组件或 "use server" 文件中定义。

示例:客户端组件触发服务器行为

// app/comments/add-comment-form.tsx (客户端组件)
"use client";

import { useRef } from 'react';
import { addComment } from '@/app/actions'; // 引入服务器动作

export default function AddCommentForm() {
  const formRef = useRef<HTMLFormElement>(null);

  // 使用 bind 来预设参数,或者直接在 action 属性中使用箭头函数
  const handleSubmit = async (formData: FormData) => {
    await addComment(formData); 
    formRef.current?.reset(); // 提交后清空表单
  };

  return (
    <form ref={formRef} action={handleSubmit} className="p-4 border rounded-md shadow-sm">
      <h2 className="text-xl font-semibold mb-3">添加评论</h2>
      <textarea
        name="commentText"
        rows={4}
        placeholder="留下你的评论..."
        className="w-full p-2 border rounded-md mb-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
        required
      ></textarea>
      <button
        type="submit"
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
      >
        发布评论
      </button>
    </form>
  );
}

// app/actions.ts (服务器文件)
"use server";

import { revalidatePath } from 'next/cache';

export async function addComment(formData: FormData) {
  const commentText = formData.get('commentText') as string;

  if (!commentText) {
    throw new Error("评论内容不能为空。");
  }

  try {
    // 模拟数据存储到数据库或调用外部 API
    console.log(`正在保存评论: "${commentText}"`);
    await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟网络延迟
    
    // 重新验证路径,以便客户端能看到新评论
    revalidatePath('/comments'); 
    console.log("评论保存成功并重新验证了 /comments 路径。");

  } catch (error) {
    console.error("保存评论时出错:", error);
    throw new Error("评论发布失败,请稍后再试。");
  }
}

在这个示例中:

  1. AddCommentForm 是一个客户端组件,因为它处理用户交互和表单提交。
  2. 它通过 action 属性直接引用了 addComment 这个 Server Action。
  3. addComment 是一个在服务器上运行的异步函数,负责处理实际的业务逻辑(保存评论)。
  4. revalidatePath('/comments') 会在评论成功保存后,强制 Next.js 重新获取 /comments 路径下的最新数据,从而更新 UI。

总结

  • 服务器到客户端:通过 props 传递可序列化的数据。
  • 客户端到服务器:通过 Server Actions 触发服务器端逻辑,实现数据变更或复杂操作。Server Actions 提供了一种安全、高效的方式,将前端交互与后端逻辑紧密结合。

掌握服务器组件与客户端组件的协作机制,是构建高性能、可扩展 Next.js 应用的关键。通过合理地划分组件职责,并在必要时进行数据传递和交互,你可以充分发挥 Next.js 在服务器端渲染和客户端交互方面的优势。

健全的错误处理策略:不止是 error.tsx

在构建任何健壮的应用时,错误处理是不可或缺的一环。Next.js 提供了 error.tsx 作为路由级别的错误边界,但实际开发中,我们可能需要更细致、更灵活的错误处理方案。

error.tsx:路由级别的错误边界

我们已经在文章前面提到过 error.tsx。它是一个 React 错误边界,能够捕获其子组件树中发生的运行时错误,并提供一个备用 UI。记住,它必须是客户端组件("use client")。

适用场景:捕获整个路由段或页面渲染过程中的非预期错误。

局限性

  • 无法捕获布局组件(layout.tsx)中的错误。
  • 无法捕获同级或父级 error.tsx 中的错误。
  • 默认情况下,它会重置页面状态并刷新,可能不是所有错误场景都希望的行为。

在异步组件内部处理错误

对于服务器组件中的数据获取,你可以直接使用标准的 try...catch 语句来处理异步操作中可能发生的错误。这允许你更精确地控制错误发生时的行为,而不是简单地抛出到 error.tsx

示例:细粒度错误处理

// app/products/page.tsx

interface Product {
  id: number;
  name: string;
  price: number;
}

async function getProducts() {
  try {
    const res = await fetch('https://api.example.com/products', { cache: 'no-store' }); // Next.js 15 中可省略,默认不缓存

    if (!res.ok) {
      // 如果响应状态码不是 2xx,手动抛出错误
      throw new Error(`Failed to fetch products: ${res.status} ${res.statusText}`);
    }

    const products: Product[] = await res.json();
    return products;
  } catch (error) {
    console.error("获取产品数据时出错:", error); // 记录错误
    // 你可以选择返回空数组,或者抛出更友好的错误信息
    throw new Error("抱歉,暂时无法加载产品列表。请稍后再试。"); 
  }
}

export default async function ProductsPage() {
  let products: Product[] = [];
  let errorMessage: string | null = null;

  try {
    products = await getProducts();
  } catch (error: any) {
    errorMessage = error.message;
  }

  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-6">产品目录</h1>
      {errorMessage ? (
        <div className="text-red-600 text-center py-4">{errorMessage}</div>
      ) : (
        <ul className="space-y-4">
          {products.map((product) => (
            <li key={product.id} className="p-4 border rounded-md">
              <h2 className="text-xl font-semibold">{product.name}</h2>
              <p>价格: ¥{product.price.toFixed(2)}</p>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

这种方式的优势在于:

  • 更精确的控制:你可以在数据获取函数内部直接处理错误,而不是让它冒泡到整个页面。这对于不同类型的错误需要不同反馈时非常有用。
  • 用户友好反馈:可以在组件内部显示更具体、更友好的错误消息,而不是统一的错误页面。
  • 数据回退:在某些情况下,你可能希望在数据获取失败时,显示一部分默认数据或缓存数据,而不是完全的错误页面。

全局错误日志与监控

对于生产环境的应用,仅仅在 UI 上显示错误是不够的,你还需要将错误日志发送到外部服务进行监控和分析(如 Sentry、Datadog 等)。

  1. error.tsx 中记录error.tsx 组件的 useEffect 是一个很好的地方来记录客户端捕获的错误。

    // app/blog/error.tsx
    // ...
    useEffect(() => {
      // 将错误发送到你的日志服务
      console.error(error);
      // Sentry.captureException(error); // 示例:集成 Sentry
    }, [error]);
    // ...
    
  2. 在 Server Actions 或 API 路由中记录:由于 Server Actions 和 API 路由在服务器端运行,你可以直接使用 Node.js 环境的日志库(如 winstonpino),或者将其错误发送到云服务提供商的日志系统(如 AWS CloudWatch、Google Cloud Logging)。

    // app/actions.ts
    'use server';
    import { revalidateTag } from 'next/cache';
    
    export async function addPost(data: FormData) {
      try {
        await fetch('https://api.example.com/posts', { /* ... */ });
        revalidateTag('posts');
      } catch (error) {
        console.error("新增文章失败:", error);
        // 可以在这里返回一个错误状态给客户端
        return { success: false, message: "新增文章失败,请稍后再试。" };
      }
    }
    

总结

  • 路由级错误:使用 error.tsx 作为全局错误边界,捕获渲染期间的意外错误。
  • 组件内错误:在异步组件(尤其是服务器组件)内部使用 try...catch 进行细粒度的错误处理,提供更友好的用户反馈或回退机制。
  • 日志监控:将客户端和服务器端的错误都发送到集中式日志服务,以便及时发现和解决问题。

总结与最佳实践

  1. 服务器优先:默认在服务器组件中获取数据,以获得最佳性能和安全性。
  2. 理解 Next.js 15 的 fetch 变化:默认不再缓存响应,确保数据新鲜度。通过 cache: 'force-cache'next.revalidate 选项精细控制缓存行为。请求去重机制仍然有效,避免同一渲染中的重复请求。
  3. 拥抱 async/await:在服务器组件中,直接使用 async/await 就能获取数据,代码简洁明了。
  4. use Hook 简化客户端获取:当必须在客户端获取数据时,优先使用 React 19 的 use hook,它能与 Suspense 无缝集成,告别手动的 loading 状态管理。
  5. 专业处理边界情况:使用 loading.tsx 提供流畅的加载体验,使用 error.tsx 创建优雅的错误边界。
  6. Server Actions + Revalidation:使用 Server Actions 处理数据变更,并用 revalidatePathrevalidateTag 来保持UI与数据同步。
昨天以前首页

Next.js 中间件:掌握请求拦截与处理的核心机制(六)

2025年8月12日 23:58

Next.js 中间件是什么?

Next.js 中间件是一个在 请求处理 管道中运行的函数,它能够在请求到达页面或 API 路由之前 拦截请求 并对其进行处理。这种机制允许开发者执行各种操作,如 身份验证、重定向、请求修改、日志记录 等,而无需在每个路由中重复编写相同的代码。

核心原理:

Next.js 中间件基于 Edge Runtime(边缘运行时)构建,这意味着它们可以在全球的 CDN 边缘节点上运行。这种设计带来了显著的性能优势:

  1. 低延迟: 中间件在离用户最近的边缘节点执行,减少了请求到达源服务器的往返时间,从而加快了响应速度。
  2. 高扩展性: 边缘运行时能够轻松处理大量并发请求,无需担心服务器过载。
  3. 安全性: 可以在请求到达你的应用核心逻辑之前,进行身份验证、授权和安全检查。

中间件的生命周期与执行流程

Next.js 中间件的生命周期与请求处理流程紧密相连,它在请求到达应用程序代码之前或之后执行。理解其生命周期有助于更好地设计和实现中间件逻辑。

生命周期阶段

  1. 请求进入 (Incoming Request)

    • 当客户端向 Next.js 应用发送请求时,中间件是第一个接收到请求的组件。
    • 此时,中间件可以访问原始的 NextRequest 对象,其中包含了请求的所有信息(URL、Headers、Cookies 等)。
  2. 中间件执行 (Middleware Execution)

    中间件函数开始执行。在这个阶段,可以根据业务逻辑对请求进行处理。你可以执行以下操作:

    • 读取请求信息:检查请求路径、查询参数、请求头、Cookie 等。
    • 修改请求:例如,添加或修改请求头,或者根据逻辑重写 URL。
    • 执行逻辑判断:进行身份验证、权限检查、A/B 测试分流等。
    • 生成响应:直接返回一个 NextResponse 对象,从而终止请求并发送响应给客户端(例如,重定向到登录页,或返回错误信息)。
  3. 响应生成与传递 (Response Generation and Passing)

    • 如果中间件没有直接返回响应(即调用了 NextResponse.next()NextResponse.rewrite()),请求会继续传递。
    • NextResponse.next():请求继续流向匹配的 Next.js 页面或 API 路由。这是最常见的操作,表示中间件完成了它的任务,允许请求继续正常处理。
    • NextResponse.rewrite(url):请求被内部重写到另一个 URL。浏览器地址栏不会改变,但服务器会处理重写后的路径。这常用于美化 URL、国际化路由或将旧路径映射到新路径。
    • NextResponse.redirect(url):向客户端发送一个重定向响应(HTTP 307 或 308)。浏览器会收到重定向指令并加载新的 URL。这常用于未授权访问、强制 HTTPS 或处理旧链接。
  4. 页面/API 路由处理 (Page/API Route Handling)

    • 如果请求通过中间件并被允许继续,它将到达 Next.js 应用程序中匹配的页面组件或 API 路由处理程序。
    • 这些组件会生成最终的 HTML 页面或 API 响应。
  5. 响应返回 (Response Return)

    • 最终的响应(无论是中间件直接生成的,还是页面/API 路由生成的)会返回给客户端。
    • 在返回之前,中间件有机会再次修改响应头(例如,添加安全策略头、设置新的 Cookie 等)。

执行流程图解

上述流程可以用以下图表概括:

A[客户端请求] --> B{中间件 (middleware.ts)};
B -- 读取/修改请求 --> C{执行业务逻辑}; 
C -- 返回 NextResponse.next() --> D[匹配的页面/API路由];
C -- 返回 NextResponse.rewrite(url) --> D;
C -- 返回 NextResponse.redirect(url) --> E[客户端重定向];
D -- 生成响应 --> F[响应返回给客户端];
E -- 新请求 --> A;
B -- 直接返回响应 --> F;

结合使用场景分析

  • 身份验证:在中间件中检查用户会话或认证令牌。如果用户未登录且尝试访问受保护路由,中间件可以直接 redirect 到登录页。
  • A/B 测试:根据用户 ID 或其他条件,在中间件中 rewrite 请求到不同的页面版本,实现无感知的 A/B 测试。
  • 国际化 (i18n):根据用户浏览器语言偏好或 Cookie,在中间件中 rewrite URL 以包含语言前缀,例如将 /about 重写为 /en/about/zh/about,而用户在浏览器中看到的 URL 不变。
  • 日志记录与监控:在中间件中记录所有传入请求的元数据(如 IP 地址、User-Agent、请求时间等),用于后续的分析和监控,然后调用 NextResponse.next() 让请求继续。
  • 安全头部注入:在中间件中获取 NextResponse.next() 返回的响应对象,然后向其添加或修改安全相关的 HTTP 头部(如 Content-Security-Policy, X-Frame-Options 等),增强应用安全性。

同时,也需要认识到中间件并非适用于所有场景。以下是一些不适合在中间件中执行的任务:

  • 复杂的数据获取和操作:中间件不适合直接进行复杂的数据获取或操作。这些任务应在路由处理程序(Route Handlers)或服务器端工具函数中完成。
  • 繁重的计算任务:中间件应保持轻量级并快速响应,否则可能导致页面加载延迟。繁重的计算任务或长时间运行的进程应在专门的路由处理程序中完成。
  • 广泛的会话管理:虽然中间件可以处理基本的会话任务,但更广泛的会话管理应由专门的身份验证服务或在路由处理程序内部进行。
  • 直接数据库操作:不建议在中间件中执行直接的数据库操作。数据库交互应在路由处理程序或服务器端工具函数中完成。

理解这些限制有助于你更有效地利用 Next.js 中间件,并避免潜在的性能瓶颈。

如何使用 Next.js 中间件?

在 Next.js 15 中,你只需要在项目的根目录下(与 apppages 目录同级)创建一个名为 middleware.ts (或 middleware.js) 的文件即可。这个文件需要导出一个默认函数,该函数接收一个 NextRequest 对象作为参数,并返回一个 NextResponse 对象。

基本结构

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // ... 中间件逻辑 ...
  return NextResponse.next();
}

export const config = {
  matcher: [
    /*
     * 匹配所有请求路径,除了以下划线开头的内部路径(如 _next/static)
     * 和文件扩展名(如 .ico, .png)
     */
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

路径匹配 (Matching Paths)

Next.js 中间件默认会匹配项目中的所有路径。然而,在实际应用中,你通常需要让中间件只在特定的路径上运行。Next.js 提供了两种主要方式来定义中间件的运行路径:自定义 matcher 配置条件语句

matchermiddleware.ts 文件中 config 对象的一个属性,它允许你使用路径匹配模式来过滤请求。matcher 的值必须是常量,以便在构建时进行静态分析。它支持完整的正则表达式语法,因此非常灵活。

基本用法:

// middleware.ts
export const config = {
  matcher: '/about/:path*', // 匹配 /about 及其所有子路径,例如 /about/a, /about/a/b
};

匹配多个路径:

可以使用数组来匹配一个或多个路径。

// middleware.ts
export const config = {
  matcher: ['/about/:path*', '/dashboard/:path*'], // 同时匹配 /about 和 /dashboard 及其子路径
};

反向匹配(排除特定路径):

matcher 支持正则表达式,可以用来匹配除特定路径外的所有路径。这对于排除静态文件、API 路由等非常有用。

// middleware.ts
export const config = {
  matcher: [
    /*
     * 匹配所有请求路径,除了以下划线开头的内部路径(如 /_next/static, /_next/image)
     * 和根目录下的 favicon.ico 文件。
     * `?!` 是一个负向先行断言,表示不匹配紧随其后的模式。
     */
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

matcher 配置规则:

  • 必须以 / 开头。
  • 可包含命名参数:/about/:path 可以匹配 /about/a/about/b,但不包含 /about/a/c
  • 可对命名参数使用修饰符(以 : 开头):
    • *:表示零个或多个,例如 /about/:path* 可匹配 /about/a/b/c
    • ?:表示零个或一个,例如 /about/:path? 可匹配 /about/about/a
    • +:表示一个或多个。
  • 可以使用括号中的正则表达式:/about/(.*)/about/:path_ 作用相同。

NextResponse API

NextResponse 扩展了标准的 Web Response API,用于创建、修改和返回响应。它提供了 next()redirect()rewrite() 等便捷方法,以及用于操作请求和响应头、Cookie 的功能。

1. NextResponse.next()

NextResponse.next() 允许请求继续流向匹配的 Next.js 页面或 API 路由。这是最常见的操作,表示中间件完成了它的任务,允许请求继续正常处理。你也可以通过 NextResponse.next({ request: newRequest }) 来修改请求对象并传递给下一个处理程序。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // 可以在这里读取请求信息,例如记录日志
  console.log('请求路径:', request.nextUrl.pathname);

  // 继续请求,不进行任何修改
  return NextResponse.next();
}

2. NextResponse.redirect(url, status?)

NextResponse.redirect() 用于向客户端发送一个重定向响应(HTTP 307 或 308)。浏览器会收到重定向指令并加载新的 URL。这常用于未授权访问、强制 HTTPS 或处理旧链接。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const isAuthenticated = false; // 假设用户未认证
  if (!isAuthenticated && request.nextUrl.pathname !== '/login') {
    // 如果用户未认证且不在登录页,则重定向到登录页
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

3. NextResponse.rewrite(url)

NextResponse.rewrite() 允许你将一个传入路径内部重写到另一个 URL,而不会改变浏览器地址栏中的 URL。这对于创建更友好的 URL、国际化路由或将旧路径映射到新路径非常有用。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname === '/old-page') {
    // 将 /old-page 内部重写到 /new-page,用户浏览器地址栏不变
    return NextResponse.rewrite(new URL('/new-page', request.url));
  }
  return NextResponse.next();
}

4. 操作请求/响应头 (Headers)

NextResponse 提供了 headers 属性,允许你设置响应头。你也可以通过 request.headers 访问请求头。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // 设置响应头
  response.headers.set('X-Custom-Header', 'Hello from Middleware');
  response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');

  // 也可以读取请求头
  const userAgent = request.headers.get('user-agent');
  console.log('User-Agent:', userAgent);

  return response;
}

5. 操作 Cookie (Cookies)

NextRequestNextResponse 都提供了方便的 API 来获取和操作 Cookie。request.cookies 用于读取请求中的 Cookie,response.cookies 用于设置响应中的 Cookie。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // 从请求中获取 Cookie
  const theme = request.cookies.get('theme');
  console.log('Current theme:', theme?.value);

  const response = NextResponse.next();

  // 在响应中设置 Cookie
  response.cookies.set('last_visit', new Date().toISOString(), { path: '/' });

  // 删除 Cookie
  // response.cookies.delete('some_old_cookie');

  return response;
}

NextResponse 总结:

NextResponse 是中间件中进行响应控制的核心。通过灵活运用 next()redirect()rewrite() 以及对 Headers 和 Cookies 的操作,你可以实现各种复杂的请求处理逻辑,从而增强 Next.js 应用的功能和用户体验。

NextRequestNextResponse

  • NextRequest 扩展了标准的 Web Request API,提供了更多 Next.js 特有的属性和方法,例如 nextUrl(包含解析后的 URL 信息)、cookies 等。
  • NextResponse 扩展了标准的 Web Response API,用于创建、修改和返回响应。它提供了 next()redirect()rewrite() 等便捷方法。

常见应用场景与实践

1. 身份验证与重定向

假设你有一个需要登录才能访问的 /dashboard 页面。你可以使用中间件来检查用户是否已认证,如果未认证则重定向到登录页。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const isAuthenticated = request.cookies.has('auth_token'); // 假设通过 cookie 判断认证状态
  const loginUrl = new URL('/login', request.url);

  // 如果用户尝试访问 /dashboard 且未认证,则重定向到登录页
  if (request.nextUrl.pathname.startsWith('/dashboard') && !isAuthenticated) {
    return NextResponse.redirect(loginUrl);
  }

  // 如果用户已认证且尝试访问 /login,则重定向到 /dashboard
  if (request.nextUrl.pathname.startsWith('/login') && isAuthenticated) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/login'], // 匹配 /dashboard 及其所有子路径,以及 /login 路径
};

实践要点:

  • 使用 request.cookies 访问请求中的 Cookie。
  • NextResponse.redirect(url) 用于执行客户端重定向(HTTP 307 或 308)。
  • new URL('/login', request.url) 构造完整的 URL,确保在不同环境下都能正确重定向。

2. URL 重写 (Rewriting)

重写允许你将一个传入路径映射到另一个内部路径,而不会改变浏览器地址栏中的 URL。这对于创建更友好的 URL 或处理内部路由非常有用。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // 将 /old-page 重写到 /new-page,用户浏览器地址栏仍显示 /old-page
  if (request.nextUrl.pathname === '/old-page') {
    return NextResponse.rewrite(new URL('/new-page', request.url));
  }

  // 示例:将 /blog/post-slug 重写到 /blog/[slug] 的实际页面
  // 假设你的博客文章页面是 /app/blog/[slug]/page.tsx
  if (request.nextUrl.pathname.startsWith('/blog/')) {
    const slug = request.nextUrl.pathname.split('/').pop();
    if (slug) {
      return NextResponse.rewrite(new URL(`/blog/${slug}`, request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/old-page', '/blog/:path*'],
};

实践要点:

  • NextResponse.rewrite(url) 用于执行内部重写,URL 不变。
  • 重写通常用于将外部友好的 URL 映射到内部组件结构。

3. 设置响应头 (Setting Headers)

你可以在中间件中修改响应头,例如添加安全策略头(CSP)、设置 Cookie 或修改缓存控制。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // 添加一个自定义响应头
  response.headers.set('X-Custom-Header', 'Hello from Middleware');

  // 设置一个 Cookie
  response.cookies.set('my_cookie', 'some_value', { path: '/', maxAge: 3600 });

  // 移除一个 Cookie
  // response.cookies.delete('another_cookie');

  // 设置内容安全策略 (CSP) 头
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
  );

  return response;
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

实践要点:

  • 首先调用 NextResponse.next() 获取一个可修改的响应对象。
  • 通过 response.headers.set()response.cookies.set() 来操作响应头和 Cookie。

4. 国际化 (i18n) 路由

中间件是实现国际化路由的理想场所,你可以根据用户偏好或浏览器设置来重写 URL,以显示不同语言的内容。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const PUBLIC_FILE = /\.(.*)$/;

const locales = ['en', 'zh', 'fr']; // 支持的语言
const defaultLocale = 'en';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 检查路径是否包含文件扩展名(如 .js, .css, .png 等),如果是则跳过中间件处理
  if (PUBLIC_FILE.test(pathname)) {
    return NextResponse.next();
  }

  // 检查路径是否已经包含语言前缀
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) {
    return NextResponse.next();
  }

  // 如果路径不包含语言前缀,则根据用户偏好或默认语言重写 URL
  const locale = request.cookies.get('NEXT_LOCALE')?.value || defaultLocale;

  request.nextUrl.pathname = `/${locale}${pathname}`;
  // 重写 URL,但浏览器地址栏不变
  return NextResponse.rewrite(request.nextUrl);
}

export const config = {
  matcher: [
    // 匹配所有路径,除了 API 路由、Next.js 内部文件和公共文件
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

实践要点:

  • 通过 request.nextUrl.pathname 获取当前请求路径。
  • 使用 request.cookies.get() 获取用户语言偏好。
  • NextResponse.rewrite() 用于在不改变浏览器 URL 的情况下,将请求重写到带有语言前缀的内部路径。

5. 错误处理与调试技巧

// middleware.ts
export function middleware(request: NextRequest) {
  try {
    // 业务逻辑
  } catch (error) {
    console.error('中间件错误:', error);
    return NextResponse.json(
      { error: '服务器内部错误' },
      { status: 500 }
    );
  }
}

调试工具

  1. 使用 console.log 查看边缘运行时日志
  2. 在响应头添加调试信息:
response.headers.set('X-Middleware-Debug', 'executed');
  1. 通过 ?__middlewareDebug=1 URL参数触发详细日志

Next.js 中间件的代码维护

如果项目比较简单,中间件的代码通常不会写很多,将所有代码写在一起倒也不是什么太大问题。可当项目复杂了,比如在中间件里又要鉴权、又要控制请求、又要国际化等等,各种逻辑写在一起,中间件很快就变得难以维护。如果我们要在中间件里实现多个需求,该怎么合理的拆分代码呢?

一种简单的方式是拆分为多个函数:

import { NextResponse } from 'next/server'

async function middleware1(request) {
  console.log(request.url)
  return NextResponse.next()
}

async function middleware2(request) {
  console.log(request.url)
  return NextResponse.next()
}

export async function middleware(request) {
  await middleware1(request)
  await middleware2(request)
}

export const config = {
  matcher: '/api/:path*',
}

一种更为优雅的方式是借助高阶函数:

import { NextResponse } from 'next/server'

function withMiddleware1(middleware) {
  return async (request) => {
    console.log('middleware1 ' + request.url)
    return middleware(request)
  }
}

function withMiddleware2(middleware) {
  return async (request) => {
    console.log('middleware2 ' + request.url)
    return middleware(request)
  }
}

async function middleware(request) {
  console.log('middleware ' + request.url)
  return NextResponse.next()
}

export default withMiddleware2(withMiddleware1(middleware))

export const config = {
  matcher: '/api/:path*',
}

请问此时的执行顺序是什么?试着打印一下吧。是不是感觉回到了学 redux 的时候?

但这样写起来还是有点麻烦,让我们写一个工具函数帮助我们:

import { NextResponse } from 'next/server'

function chain(functions, index = 0) {
  const current = functions[index];
  if (current) {
    const next = chain(functions, index + 1);
    return current(next);
  }
  return () => NextResponse.next();
}

function withMiddleware1(middleware) {
  return async (request) => {
    console.log('middleware1 ' + request.url)
    return middleware(request)
  }
}

function withMiddleware2(middleware) {
  return async (request) => {
    console.log('middleware2 ' + request.url)
    return middleware(request)
  }
}

export default chain([withMiddleware1, withMiddleware2])

export const config = {
  matcher: '/api/:path*',
}

请问此时的执行顺序是什么?答案是按数组的顺序,middleware1、middleware2。

如果使用这种方式,实际开发的时候,代码类似于:

import { chain } from "@/lib/utils";
import { withHeaders } from "@/middlewares/withHeaders";
import { withLogging } from "@/middlewares/withLogging";

export default chain([withLogging, withHeaders]);

export const config = {
  matcher: '/api/:path*',
}

具体写中间件时:

export const withHeaders = (next) => {
  return async (request) => {
    // ...
    return next(request);
  };
};

开发一个支持支付功能的微信小程序的注意事项,含泪送上

2025年8月12日 17:49

介绍

从来没有做过该功能,只是最近想做一个股票的小工具。顺便想收点费用支撑下自己的荷包。所以就做了一个支付功能。然后就开始了自己的踩坑之旅....

我的痛,你不知道...

服务器

阿里云、腾讯云、华为云,其实都可以。因为开发微信小程序,所以优先选了腾讯云。

开通云服务器的同时,买了一个SSL。知道小程序需要https。

开发代码

前端使用了Taro、后端使用的nodejs。完成了基础功能的开发。微信开发者工具中基本能够使用,除了支付功能。

踩坑之旅

域名的痛

本来以为买了一个域名后,这个域名跟我服务器的IP做绑定后,就能直接使用了。本地使用ping都能通的。结果下面:

image.png

访问XXXX.asia

image.png

坑爹的是,访问XXXX.asia,结果能通! 问了下deepseek,说是加密了,所以是否备案无法拦截。

但是,如果发布小程序后,所有的访问,都是不通的,接口返回的信息是 request:fail。

没经验的我,伤的不要不要的。

然后去,备案。。

这个还好是跟腾讯云一起的,因为备案过程中,没经验的,一定选择【强烈推荐】的选项,去做,不然检验死活不过。我也是醉了。

然后等10+天.....

小程序 与 商户号

如果小程序需要开通支付功能,是需要跟商户号绑定的。然后商户平台开通相关的支付模式,小程序中调用相关的支付。

问题是,小程序的主体,必须是这个商户,其次,小程序的类目,必须跟商户一致,不然,小程序连微信支付的菜单都看不到。

第一次做支付的我,伤的不要不要的....

因为 商户的科目与我小程序的不一致,小程绑定的主体,不是商户号的主体,我咋办哦....

总结

如果开发一个小程序支持,支付功能,注意事项如下:

  1. 服务器、域名、SSL
  2. 开发前端、后端
  3. 有一个商户号,他是你小程序的主体,开通相关支付功能。绑定小程序。
  4. 注册一个小程序,与商户号绑定。配置相关的支付能力。
  5. 如果你是个人,无法实现支付功能,请知道.....

最后的最后

后来,找了一个服务商平台,注册了一个小微商户,然后用这个小微商户,登录微信的商户平台,做了相关的配置,与小程序做了绑定,做了相关实现。(被收费了,还很贵啊)(灬ꈍ ꈍ灬)。

❌
❌