首页 / 文章 / 现代 Next.js Mapped:各功能替代了什么以及何时使用它们

现代 Next.js Mapped:各功能替代了什么以及何时使用它们

带你了解 Next.js 的八项核心功能,从服务器组件到元数据 API,逐一说明它们取代了哪些旧有模式,以及可能带来的隐患。

3113 词

Next.js 现已涵盖了路由、数据获取、缓存、渲染策略以及大部分 API 功能。然而许多团队在采用它后仍保留旧习惯:在 useEffect 中进行数据获取,为每个表单手动编写 API 路由,以及使用没人能解释清楚的缓存规则。本指南介绍了这八项功能、它们所替代的旧有模式,以及在实际项目中会出问题的细节,帮助你逐项判断哪些功能应该纳入代码库。

为何该框架能覆盖整个技术栈

沃尔玛、耐克、TikTok、OpenAI 和 Airbnb 都使用 Next.js 运行 Web 应用,其优势在于功能整合:路由、打包、渲染模式、数据访问和服务器端点都共享同一个代码库和一套规范。选择路由器和配置打包工具已不再是项目启动的必要步骤,因此更多时间可以投入到产品开发中。

1. 服务器组件使服务器成为默认的渲染场所

React服务器组件(RSC)是此列表中最大的架构变革。服务器组件仅在服务器端执行,然后将渲染后的输出发送到浏览器,因此其代码永远不会成为客户端JavaScript包的一部分。

数据驱动页面中会消失什么

在传统的客户端渲染式React中,即使是一个仅用于读取数据并显示列表的组件,也会将其代码、数据获取逻辑以及依赖项添加到包中,再在浏览器中进行初始化。而使用RSC时,组件会在服务器端读取数据,仅传输结果。在App Router中,除非另有说明,否则每个组件都是服务器组件,这就是为什么下面的文件完全不需要任何指令的原因:

// app/products/page.tsx
// This component runs ONLY on the server. No "use client" directive needed.

该页面是一个async函数,它直接查询数据库并返回标记代码。中间没有API路由,也没有客户端请求:

import { db } from "@/lib/db";export default async function ProductsPage() {
  // Direct database access. No API route. No fetch boilerplate.
  const products = await db.product.findMany({ take: 20 });  return (
    <main>
      <h1>Our Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            <h2>{product.name}</h2>
            <p>${product.price}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

没有useState,没有useEffect,没有加载状态标志,也没有向自身后端发起的fetch请求。浏览器中运行的代码更少,HTML内容会完整送达(有利于搜索引擎),且维护成本更低。由于这段代码直接操作数据库,切勿将其导入到客户端文件中;仅服务器端可用的数据模块能明确界定这一边界。

客户端组件仍适用的场景

对于任何需要交互的功能,仍然需要使用客户端组件:事件处理程序、状态管理、效果处理以及localStorage等浏览器API。只需在文件顶部添加"use client"指令即可标识此类文件:

// components/AddToCartButton.tsx
"use client";

下面的按钮会保存一段本地状态并响应点击操作,这正是需要在浏览器中完成的类型的工作:

import { useState } from "react";export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);  return (
    <button onClick={() => setAdded(true)}>
      {added ? "Added!" : "Add to Cart"}
    </button>
  );
}

从服务器组件开始,仅在需要交互性时再做调整,并尽可能将"use client"放在层级较深的位置:一个嵌入了小型客户端按钮的服务器页面所加载的JavaScript量,远少于从顶层就是客户端组件的页面。如需更深入地了解渲染模型背后的工作原理,请参阅零包渲染背后的架构

2. 服务器动作替代API路由用于数据变更

Server Actions允许你编写在服务器上运行的函数,并直接从组件中调用它。Next.js会生成HTTP端点,序列化参数并返回结果,因此你无需再为处理表单提交而维护单独的路由。

它所取代的两文件模式

以前,任何数据变更操作都需要在Pages Router的api文件夹中编写处理程序,该程序负责读取请求体、向数据库写入数据并以JSON格式响应:

// You needed an API route
// pages/api/create-post.ts
export default async function handler(req, res) {
  const { title, content } = req.body;
  await db.post.create({ data: { title, content } });
  res.status(200).json({ success: true });
}

随后组件必须手动调用该端点,并自行序列化请求数据:

// Then in your component:
const response = await fetch("/api/create-post", {
  method: "POST",
  body: JSON.stringify({ title, content }),
});

与表单一同存在的已验证操作

借助Server Actions,页面可以导入所需的一切资源,包括用于输入验证的Zod库:

// app/posts/new/page.tsx
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { z } from "zod";

操作地址在表单旁边声明。函数体中的"use server"指令将其标记为仅服务器可执行的代码;表单会将该地址传递给其action属性。该操作会使用safeParseFormData进行验证,若验证失败则返回字段错误信息,否则会处理提交数据并重定向:

const schema = z.object({
  title: z.string().min(3, "Title must be at least 3 characters"),
  content: z.string().min(10, "Content is too short"),
});async function createPost(formData: FormData) {
  "use server";  const parsed = schema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }  await db.post.create({ data: parsed.data });
  redirect("/posts");
}export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Write something..." required />
      <button type="submit">Publish</button>
    </form>
  );
}

验证非常重要,因为服务器操作是一个公开端点,任何人都可以携带任意数据调用它。出于同样的原因,授权检查必须放在操作本身内部;为何服务器操作需要在每个函数体中加入授权检查对此有详细说明。另外请注意,此处并未读取返回的错误对象,因此验证失败时不会显示任何提示。接下来的模式可以解决这个问题。

使用 useActionState 显示错误与待处理状态

当表单需要显示验证信息,或在请求处理期间禁用按钮时,应将表单置于客户端组件中,并使用 React 的 useActionState 钩子来封装相关操作:

"use client";

该钩子会返回动作产生的最新状态、一个用于传递给表单的封装后的 formAction,以及一个 isPending 标志。组件会渲染每个字段的第一个错误,并在提交过程中切换按钮文本:

import { useActionState } from "react";
import { createPost } from "./actions";export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);  return (
    <form action={formAction}>
      <input name="title" placeholder="Post title" />
      {state?.error?.title && (
        <p className="text-red-500">{state.error.title[0]}</p>
      )}
      <textarea name="content" placeholder="Write something..." />
      {state?.error?.content && (
        <p className="text-red-500">{state.error.content[0]}</p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? "Publishing..." : "Publish"}
      </button>
    </form>
  );
}

一个常令人困惑的细节是:当通过 useActionState 调用某个动作时,React 会以先前的状态作为第一个参数,再将 FormData 作为第二个参数传入。之前展示的 createPost 函数仅接受 formData,因此从 ./actions 导出的该钩子版本需要采用 (prevState, formData) 的参数签名。此外,该动作还必须保存在单独的文件中,并且在文件顶部标注 "use server",因为客户端组件无法直接内联定义服务器端函数。

3. Turbopack 缩短了开发反馈循环

长期以来,webpack一直主导着本地开发的节奏。一旦服务器启动,快速刷新功能使用起来相当便捷,但对于大型应用而言,冷启动却可能需要30到60秒的时间。Turbopack是Vercel推出的一款基于Rust的打包工具,旨在消除这一瓶颈。

Turbopack在全部8,298项Next.js集成测试中均实现了100%的通过率,那些原本需要超过一分钟才能完成冷启动的项目,使用该工具后开发环境的冷启动时间已降至3秒以内。其成熟度等级在各个版本间变化较快,因此请查阅最新文档以了解您所使用版本的现状,尤其是针对生产环境构建的情况。

启用该功能

在开发环境中启用此功能只需在dev脚本中设置一个标志即可:

// package.json
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build"
  }
}

无需额外配置。Turbopack采用增量计算方式,仅重新生成发生变化的部分,因此对大型项目尤为有益。如果您依赖自定义的webpack加载器或插件,请先确认Turbopack中的对应组件。我们的打包工具对比文章详细分析了这些选择带来的权衡。

4. 部分预渲染:将静态外壳与流式数据结合

部分预渲染(PPR)会立即提供预先构建的静态HTML外壳,同时将同一页面的动态内容以流式方式注入其中,所有内容都在一次响应中完成。

在产品页面上,布局、导航和描述对所有人都是相同的;而个性化价格、购物车内容和库存数量则各不相同。PPR会立即发送静态外壳,然后在每次请求时根据实时数据动态填充相应内容。

该页面导入了一个静态组件和两个动态组件:

// app/product/[id]/page.tsx
import { Suspense } from "react";
import { ProductDetails } from "./ProductDetails"; // static
import { PersonalizedPrice } from "./PersonalizedPrice"; // dynamic
import { StockStatus } from "./StockStatus"; // dynamic

静态与动态组件的边界由 Suspense 标记。边界之外的内容都可以预先渲染;每个 Suspense 降级处理都会在壳层中生成一个占位符,直到其子组件在服务器端完成渲染后才会被替换:

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* This renders statically - instant */}
      <ProductDetails id={params.id} />      {/* These stream in dynamically */}
      <Suspense fallback={<div>Loading price...</div>}>
        <PersonalizedPrice productId={params.id} />
      </Suspense>      <Suspense fallback={<div>Checking stock...</div>}>
        <StockStatus productId={params.id} />
      </Suspense>
    </div>
  );
}

请注意,此处 params 被定义为普通对象类型。在较新的 Next.js 版本中,params 会以 Promise 的形式传递并需要等待其解析,因此需根据目标版本调整该参数的类型定义。

PPR 是通过 Next.js 配置中的实验性标志引入的,首先是从类型导入开始的:

// next.config.ts
import type { NextConfig } from "next";

随后才是该标志本身:

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
};export default nextConfig;

在撰写本文时,此标志已在 newer 版本中进行了重新设计(Next.js 16 中的 PPR 行为与 Cache Components 设置相关),因此请将此代码片段视为示例,并确认当前的选项名称。无论如何,由于页面外壳被缓存而其数据保持实时更新,所以该页面看起来是静态的。我们将在partial pre-rendering and concurrent rendering explained一文中更深入地探讨其原理。

5. use cache 指令使缓存机制更加明确

Next.js 13 和 14 默认会进行大量缓存,许多团队因此在生产环境中发现了无明显原因的过时页面。Next.js 16 则通过 Cache Components 和 use cache 指令引入了按需缓存机制:你需要明确指定哪些内容需要被缓存,而非猜测哪些内容已经被缓存。

将该指令放在异步组件的顶部可以缓存其渲染结果:

// A component that caches its output for 1 hour
async function PopularArticles() {
  "use cache";

该组件的其余部分则按常规方式获取数据并进行渲染:

  const articles = await fetch("https://api.example.com/popular-articles").then(
    (r) => r.json()
  );  return (
    <ul>
      {articles.map((article: { id: string; title: string }) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  );
}

注释中提到一小时,但该指令本身并未设定持续时间;其有效期来自通过cacheLife应用的缓存配置文件,否则将使用默认配置文件。自定义配置文件可在配置中声明:

// next.config.ts
const nextConfig = {
  experimental: {
    cacheLife: {
      "stale-for-a-day": {
        stale: 60 * 60, // 1 hour
        revalidate: 60 * 60 * 24, // 1 day
        expire: 60 * 60 * 24 * 7, // 1 week
      },
    },
  },
};

这三个值对应不同的问题。stale表示客户端在未检查服务器的情况下可以使用其副本的时间长度,revalidate表示服务器在后台刷新该条目的频率,而expire则是该条目被丢弃、后续请求必须等待新数据的时刻。cacheLife是否属于experimental功能取决于您的版本。关于在数据写入后需要使用的基于标签的失效机制,请参阅我们关于使用缓存及基于标签的重新验证的指南

6. 使用 AI SDK 实现流式 AI 功能

Vercel AI SDK 能与路由处理程序及 React 钩子功能集成,因此流式聊天、AI 辅助搜索以及自动生成的 UI 都能通过常规应用程序代码实现。

在服务器端,路由处理程序会导入 streamText 以及模型提供器:

// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";

POST 处理程序从请求体中读取对话内容,启动流式处理并以流式响应的形式返回结果(代码片段中函数的闭合大括号被截断了):

export async function POST(req: Request) {
  const { messages } = await req.json();  const result = streamText({
    model: openai("gpt-4o"),
    messages,
  });  return result.toDataStreamResponse();

在客户端,聊天页面属于客户端组件,因为它负责管理输入状态:

// app/chat/page.tsx
"use client";

useChat 钩子负责管理消息列表、输入值及提交操作,并在有新消息到达时重新渲染页面:

import { useChat } from "ai/react";export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit } = useChat();  return (
    <div>
      <div>
        {messages.map((m) => (
          <div key={m.id}>
            <strong>{m.role}:</strong> {m.content}
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} placeholder="Ask anything..." />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

一个流式聊天应用大约需要30行代码,后端包含路由处理程序,前端则使用useChat函数。需要注意的是,AI SDK的API更新很快:在较新的主要版本中,该钩子函数是从@ai-sdk/react导入的,输入状态需要由开发者自行管理,且响应处理函数的名称也有所变化。在复制此代码之前,请锁定所需的版本并查阅SDK文档。对于需要使用工具和多步骤的智能体界面,可参考使用Next.js和AI SDK构建多步骤AI智能体界面的相关内容。

7. 用于复杂布局的App Router模式

Next.js 13引入的App Router现已成为构建Next.js应用的标准方式。它的两项功能取代了以往需要自定义状态管理的做法。

独立仪表板面板的并行路由

并行路由允许在同一个布局中同时渲染多个页面。每个以@为前缀的文件夹都定义了一个命名槽位:

app/
  dashboard/
    @analytics/
      page.tsx
    @recent/
      page.tsx
    layout.tsx
    page.tsx

布局会将每个槽位作为属性与children一同接收,然后将其放入网格中:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  recent,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  recent: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="col-span-2">{children}</div>
      <aside>
        {analytics}
        {recent}
      </aside>
    </div>
  );
}

由于每个槽位都是独立的路由段,它能够加载自己的数据,并拥有独立的加载状态和错误状态。缓慢的分析查询不会影响近期活动面板的功能。需要注意的一点是:当导航到某个槽位未定义的子路径时,Next.js需要该槽位中存在default.tsx文件,以便在强制重新加载时知道应渲染什么内容。

使用真实URL的模态框路由拦截

一种常见的界面模式是在模态窗口中打开某个项目,同时将URL更改为该项目的地址,以便实现内容共享。路由拦截器通过文件夹约定来实现这一功能:

app/
  photos/
    [id]/
      page.tsx      // Full page view at /photos/123
    (..)[id]/
      page.tsx      // Intercepted modal view
  page.tsx

当用户从网格中点击时,拦截文件夹会捕获客户端发起的导航请求并渲染模态版本;而如果有人直接访问该URL或刷新页面,则会正常渲染完整页面,无需任何手动处理历史记录的技巧。(.)(..)(...)这些标记是相对于路由路径段而言的,而非文件系统中的文件夹,而且模态窗口通常是通过独立的@modal插槽来渲染的,因此请查阅路由文档以了解您的结构所需的具体布局。

8. SEO基础:元数据与图片

Metadata API 将 SEO 转换为普通代码,这些代码会与它所描述的页面一同存在。在导入 Metadata 类型之后:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";

页面会输出 generateMetadata 方法,该方法加载文章内容并返回标题、描述、Open Graph 数据以及 Twitter 卡片信息:

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPost(params.slug);  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage }],
      type: "article",
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

社交预览、规范 URL 以及结构化数据都来源于页面渲染的相同数据。如果页面组件也调用了 getPost 方法,应通过某种方式(例如 React 的 cache)避免重复请求,从而防止数据库被多次查询。

对于图片处理,建议默认使用 next/image

import Image from "next/image";

它默认采用懒加载方式,提供合适尺寸的图像版本,并在浏览器支持时将其转换为WebP或AVIF格式。明确指定widthheight还能预留空间,避免布局偏移:

export function ProductCard({ product }: { product: Product }) {
  return (
    <div>
      <Image
        src={product.imageUrl}
        alt={product.name}
        width={400}
        height={300}
        priority={false} // set true for above-the-fold images
      />
      <h2>{product.name}</h2>
    </div>
  );
}

仅将priority设置为true用于那些位于可视区域顶部的图像,通常是主图或核心产品图片,这样浏览器就能提前获取这些图像。

合理的学习顺序

这里的每一步都是在前一步的基础上进行的:

  1. App Router的文件规范:layout.tsxpage.tsxloading.tsxerror.tsx
  2. 服务器组件以及客户端与服务器的边界位置。
  3. 针对每次数据变更使用Zod进行验证的服务器动作。
  4. 用于声明式加载状态的Suspense与流式处理技术。
  • 静态与动态结合的模型采用部分预渲染方式。
  • 正在开发 Turbopack 以加快迭代速度。
  • 核心要点

    • 将服务器视为默认运行环境,并将 "use client" 设置应用到最底层的交互组件。
    • 服务器动作即端点:需在每个动作中验证输入并检查权限。
    • 使用 Suspense 来划分静态与动态内容边界,PPR 和缓存功能均基于此构建。
    • 建议使用 use cache 及带名称的 cacheLife 配置进行显式缓存,而非依赖默认设置。
    • 近期版本中许多 API 都发生了变化,因此请根据实际运行的版本确认相关标志和签名。

    无论你是从零开始还是将大型应用迁移到 App Router,逐步采用这些功能都是风险较低的方式。

    相关阅读