首页 / 文章 / 在 Next.js App Router 页面中如何划分服务器端与客户端边界

在 Next.js App Router 页面中如何划分服务器端与客户端边界

React Server Components 的实用思维模型:哪些代码在何处运行、博客文章页面如何拆分为服务器端和客户端部分,以及需遵循的导入规则。

1321 词

即使阅读了相关文档,React Server Components 仍可能让人感到难以理解,主要是因为它要求人们打破每位 React 开发者多年来一直持有的一个假设:组件必须在浏览器中运行。一旦这个假设被打破,其余内容就会相当自然地迎刃而解。本文以一个博客文章页面为例构建思维模型,说明哪些部分应在服务器端处理,哪些需要在客户端处理,以及维持二者之间清晰界限的几条规则。

如需更深入地了解渲染流程本身,请参阅Server Components 实现零包体渲染的架构原理

从“全部内容都需要同步”到“仅同步必要部分”

在 Server Components 出现之前,所有的 React 组件最终都必须在浏览器中运行。即便使用服务端渲染,整个组件树的 JavaScript 代码也会被发送到客户端并进行初始化,才能让应用具备交互功能。对于那些仅负责获取数据并将其作为属性传递的组件来说,这种做法十分浪费——它们的代码会被下载并运行,却从未对用户操作做出响应。

Server Component 通过仅在服务器端运行改变了这一状况。它的代码永远不会被发送到浏览器,也不会进行初始化,因此不会为客户端打包添加任何 JavaScript 代码。最终传送到浏览器的是其渲染后的输出,React 会将其与 HTML 一起序列化为紧凑的数据包。

在脑海中区分这两种组件的最简单方法如下:

  • 客户端组件:在服务器端预渲染后于浏览器中运行,能够存储状态并响应事件。
  • 服务器组件:在服务器上运行,可直接从数据库或文件中读取数据,并仅返回渲染后的输出。
  • 为何这种区分如此重要

    以典型的博客文章页面为例,标题和正文来自数据库,对所有访问者显示一致且不考虑点击行为。传统上,用于渲染这些内容的代码以及任何 Markdown 或格式化库仍需传输到浏览器。而使用服务器组件后,所有这些内容都留在服务器上。这样一来,代码包体积更小,页面加载速度也更快,尤其是在性能较弱的设备上。

    那些转向基于服务器组件构建的 App Router 的团队报告称,在某些场景下核心网页指标有所提升,尤其是 LCP 指标。需注意这些效果因场景而异:收益程度取决于页面减少了多少客户端 JavaScript 代码,因此建议自行测试各路由的表现。

    被拆分为两部分的博客文章页面

    在 Next.js App Router 项目中,除非另有说明,否则页面文件均为服务器组件。下面的页面是一个 async 函数,它直接从数据层获取数据,处理未找到的情况,渲染静态内容,然后再添加一个用于评论的交互式子组件。该代码是基于 Next.js 14 的 API 编写的:

    // app/posts/[slug]/page.tsx
    // This is a Server Component by default — no "use client" needed
    
    import { getPostBySlug } from "@/lib/db"
    import { PostContent } from "@/components/PostContent"
    import { CommentSection } from "@/components/CommentSection"
    
    type Props = {
      params: { slug: string }
    }
    
    export default async function PostPage({ params }: Props) {
      // Direct DB call — no useEffect, no API route, no loading state
      const post = await getPostBySlug(params.slug)
    
      if (!post) {
        return <div>Post not found.</div>
      }
    
      return (
        <article className="max-w-2xl mx-auto py-12 px-4">
          <h1 className="text-3xl font-bold mb-4">{post.title}</h1>
          <PostContent content={post.body} />
    
          {/* This one needs interactivity — so it's a Client Component */}
          <CommentSection postId={post.id} />
        </article>
      )
    }
    

    注意其中缺失了什么:没有 useState,没有 useEffect,没有 API 路由封装,也没有加载状态管理。数据的获取方式与其他异步函数完全相同,这正是该模式的核心所在。

    有两个实用提示。从 Next.js 15 开始,params 以 Promise 的形式传递,因此页面需要在读取 slug 之前先执行 await params;请确认您使用的版本。而对于真正的未找到页面情况,调用 next/navigation 中的 notFound() 函数会返回标准的 404 状态码,而非带有错误信息的普通页面。

    评论框则有所不同。它需要将草稿文本保存在状态中,并对输入和点击操作做出响应,因此必须在浏览器中运行。文件顶部的 "use client" 指令将其标记为客户端组件:

    // components/CommentSection.tsx
    "use client" // opts into browser rendering
    
    import { useState } from "react"
    
    type Props = {
      postId: string
    }
    
    export function CommentSection({ postId }: Props) {
      const [comment, setComment] = useState("")
    
      const handleSubmit = async () => {
        await fetch("/api/comments", {
          method: "POST",
          body: JSON.stringify({ postId, comment }),
        })
        setComment("")
      }
    
      return (
        <div className="mt-8">
          <textarea
            value={comment}
            onChange={(e) => setComment(e.target.value)}
            placeholder="Leave a comment..."
            className="w-full border rounded p-2 text-sm"
          />
          <button
            onClick={handleSubmit}
            className="mt-2 bg-blue-600 text-white px-4 py-2 rounded text-sm"
          >
            Post Comment
          </button>
        </div>
      )
    }
    

    所有交互功能都集中在这里:文本区域的本地状态、变化处理函数,以及用于向 API 路由发送数据并清空该字段的提交处理函数。在实际实现时,请求中应添加 Content-Type: application/json 头部信息,同时可以考虑使用 Server Action 作为独立 API 路由的替代方案。

    这样的分工便于理解:服务器负责数据,客户端负责交互操作。

    保持边界清晰的规则

    • 在 App Router 中,组件默认为 Server Components。
    • 仅在与状态、效应、事件处理函数或 windowlocalStorage 等浏览器 API 相关的场景下才添加 "use client"
    • Server Components 可以导入并渲染 Client Components,就像页面中使用 CommentSection 那样。
  • 客户端组件无法导入服务器端组件,但可以将其作为children或其他属性接收,这样就可以将服务器渲染的内容嵌套在交互式界面中。
  • 从服务器传递到客户端的属性必须是可序列化的:普通数据可以,函数和类实例则不行。
  • 样式不会受到影响。Tailwind类和CSS在这两种类型的组件中表现一致。
  • "use client"标记的是一个边界而非单个文件:该文件导入的所有内容都会成为客户端代码包的一部分。将此指令放在层级尽可能低的位置,即那些小的交互式组件上,就能让其余内容保留在服务器端。

    总结

    一旦形成“这段代码在何处运行?”这个思维模式,它就会成为你思考某个组件时的第一个问题,而App Router的默认答案就是服务器。

    • Server Components将渲染和数据访问功能移至服务器端,不会生成任何组件级JavaScript代码。
    • 数据获取操作在组件内部可通过普通的async/await方式实现。
    • 应将"use client"视为交互式组件的可选功能,而非默认设置。
    • 实际的第一步是:在现有项目中挑选一个用于数据获取的组件,判断它是否真的需要浏览器环境,若不需要则进行转换。

    一旦确立了这种架构模式,布局、Suspense流式加载、Server Actions以及并行路由等功能就会容易得多,因为它们都是建立在相同的以服务器优先为基础之上的。

    相关阅读

  • 从 next/router 迁移:App Router 中的参数、浅层 URL 与 404 错误处理 — 了解在弃用 next/router 后,Next.js App Router 中的参数、useParams、浅层 URL 更新、程序化导航以及真正的 404 页面是如何工作的。
  • Next.js App Router 中的技术 SEO:元数据、站点地图与 JSON-LD — 了解共享的元数据辅助工具、根布局默认设置、robots.ts 文件、动态站点地图、规范的 JSON-LD 格式以及页面审计如何为 Next.js 应用奠定良好的 SEO 基础。
  • 为什么 WebSockets 会在 Next.js API 路由中卡住以及如何通过自定义服务器解决该问题 — 了解为何 pages/api 路由中的 WebSocket 服务器无法完成握手过程,以及如何使用自定义的 Next.js 服务器自行处理升级事件。