nextjs学习6:服务端组件和客户端组件

服务端组件和客户端组件是 Next.js 中非常重要的概念。

如果没有细致的了解过,你可能会简单的以为所谓服务端组件就是 SSR,客户端组件就是 CSR,服务端组件在服务端进行渲染,客户端组件在客户端进行渲染等等,实际上并非如此。

本篇就深入学习和探究 Next.js 的双组件模型

服务端组件

介绍

在 Next.js 中,组件默认就是服务端组件服务端组件一般会在function 前面加上async(不加也行)。往往意味着你需要利用服务端能力(比如异步数据获取),而 Next.js 的默认规则会让这类组件天然运行在服务端。

举个例子,新建 app/todo/page.js,代码如下:

js 复制代码
export default async function Page() {
  const res = await fetch('https://jsonplaceholder.typicode.com/todos')
  const data = (await res.json()).slice(0, 10)
  console.log(data)
  return <ul>
    {data.map(({ title, id }) => {
      return <li key={id}>{title}</li>
    })}
  </ul>
}

请求会在服务端执行,并将渲染后的 HTML 发送给客户端:

因为在服务端执行,console 打印的结果也只可能会出现在命令行中,而非客户端浏览器中

优势

  1. 数据获取:通常服务端环境(网络、性能等)更好,离数据源更近,在服务端获取数据会更快。通过减少数据加载时间以及客户端发出的请求数量来提高性能。

  2. 安全 :在服务端保留敏感数据和逻辑,不用担心暴露给客户端。服务端组件不会生成客户端 Chunk.js仅在服务端渲染为 HTML,代码不暴露给浏览器)。

  3. bundle 大小:服务端组件的代码不会打包到 bundle 中,减少了 bundle 包的大小。

  4. 初始页面加载和 FCP:服务端渲染生成 HTML,快速展示 UI。

  5. Streaming:服务端组件可以将渲染工作拆分为 chunks,并在准备就绪时将它们流式传输到客户端。用户可以更早看到页面的部分内容,而不必等待整个页面渲染完毕。

因为服务端组件的诸多好处,在实际项目开发的时候,能使用服务端组件就尽可能使用服务端组件

限制

虽然使用服务端组件有很多好处,但使用服务端组件也有一些限制,比如不能使用 useState 管理状态,不能使用浏览器的 API 等等。

RSC 与 SSR

了解了这两个基本概念,现在让我们来回顾下 React Server Components 和 Server-side Rendering,表面上看,RSC 和 SSR 非常相似,都发生在服务端,都涉及到渲染,目的都是更快的呈现内容。但实际上,这两个技术概念是相互独立的。

正如它们的名字所表明的那样,Server-side Rendering 的重点在于 Rendering ,React Server Components 的重点在于 Components

简单来说:

  • RSC 提供了更细粒度的组件渲染方式,可以在组件中直接获取数据,而不用像传统的 SSR 顶层获取数据
  • RSC 在服务端进行渲染,组件依赖的代码不会打包到 bundle 中,而 SSR 需要将组件的所有依赖都打包到 bundle 中

当然两者最大的区别是:

SSR 是在服务端将组件渲染成 HTML 发送给客户端,而 RSC 是将组件渲染成一种特殊的格式,我们称之为 RSC Payload

这个 RSC Payload 的渲染是在服务端,但不会一开始就返回给客户端,而是在客户端请求相关组件的时候才返回给客户端,RSC Payload 会包含组件渲染后的数据和样式,客户端收到 RSC Payload 后会重建 React 树,修改页面 DOM。

让我们本地开启一下当时 React 提供的 Server Components Demo:

你会发现 localhost 这个 HTML 页面的内容就跟 CSR 一样,都只有一个用于挂载的 DOM 节点。当点击左侧 Notes 列表的时候,会发送请求,这个请求的地址是http://localhost:4000/react?location={"selectedId":3,"isEditing":false,"searchText":""}

返回的结果是:

除此之外没有其他的请求了。其实这条请求返回的数据就是 RSC Payload。

让我们看下这条请求,我们请求的这条笔记的标题是 Make a thing,具体内容是 It's very easy to make some......,我们把返回的数据具体查看一下,你会发现,返回的请求里包含了这些数据:

不仅包含数据,完整渲染后的 DOM 结构也都包含了。

客户端收到 RSC Payload 后就会根据这其中的内容修改 DOM。而且在这个过程,页面不会刷新,页面实现了 partial rendering(部分更新)

这也就带来了我们常说的 SSR 和 RSC 的最大区别,那就是状态的保持。SSR 每次都是一个新的 HTML 页面,所以状态不会保持(传统的做法是 SSR 初次渲染,然后 CSR 更新,这种情况,状态可以保持,不过现在讨论的是 SSR,对于两次 SSR,状态是无法维持的)。

但是 RSC 不同,RSC 会被渲染成一种特殊的格式(RSC Payload),可以多次重新获取,然后客户端根据这个特殊格式更新 UI,而不会丢失客户端状态。

客户端组件

使用客户端组件,你需要在文件顶部添加一个 "use client" 声明,修改 app/todo/page.js,代码如下:

js 复制代码
'use client'

import { useEffect, useState } from 'react';

function getRandomInt(min, max) {
  const minCeiled = Math.ceil(min);
  const maxFloored = Math.floor(max);
  return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}

export default function Page() {

  const [list, setList] = useState([]);

  const fetchData = async () => {
    const res = await fetch('https://jsonplaceholder.typicode.com/todos')
    const data = (await res.json()).slice(0, getRandomInt(1, 10))
    setList(data)
  }

  useEffect(() => {
    fetchData()
  }, [])

  return (
    <>
      <ul>
        {list.map(({ title, id }) => {
          return <li key={id}>{title}</li>
        })}
      </ul>
      <button onClick={() => {
        location.reload()
      }}>换一批</button>
    </>
  )
}

在这个例子中,我们使用了 useEffect、useState 等 React API,也给按钮添加了点击事件、使用了浏览器的 API。无论使用哪个都需要先声明为客户端组件。

注意:"use client"用于声明服务端和客户端组件模块之间的边界。当你在文件中定义了一个 "use client",导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分。

它的优势是:

  1. 交互性:客户端组件可以使用 state、effects 和事件监听器,意味着用户可以与之交互;
  2. 浏览器 API:客户端组件可以使用浏览器 API 如地理位置、localStorage 等;

服务端组件 VS 客户端组件

1、如何选择使用?

组件类型 执行 / 渲染位置 核心特征
服务端组件(SC) 仅在服务端(Node.js 环境)执行,渲染为 HTML 片段 / React 服务端数据结构 可直接访问数据库、后端接口,无浏览器 API 限制,代码不会发送到客户端
客户端组件(CC) 先在服务端做 "首屏渲染"(生成 HTML),再在客户端(浏览器)hydrate(水合)并运行 可使用 useState/useEffect 等 Hooks、访问 window/document,代码会打包发送到客户端

2、渲染环境

服务端组件只会在服务端渲染,但客户端组件会在服务端渲染一次,然后在客户端渲染。

这是什么意思呢?让我们写个例子,新建 app/client/page.js,代码如下:

js 复制代码
'use client'

import { useState } from 'react';

console.log('client')

export default function Page() {

  console.log('client Page')

  const [text, setText] = useState('init text');

  return (
    <button onClick={() => {
      setText('change text')
    }}>{text}</button>
  )
}

新建 app/server/page.js,代码如下:

js 复制代码
console.log('server')

export default function Page() {

  console.log('server Page')

  return (
    <button>button</button>
  )
}

现在运行 npm run build,会打印哪些数据呢?

答案是无论客户端组件还是服务端组件,都会打印:

而且根据输出的结果,无论是 /client还是 /server走的都是静态渲染。

当运行 npm run start的时候,又会打印哪些数据呢?

答案是命令行中并不会有输出,访问 /client的时候,浏览器会有打印:

访问 /server的时候,浏览器不会有任何打印:

客户端组件在浏览器中打印,这可以理解,毕竟它是客户端组件,当然要在客户端运行。可是客户端组件为什么在编译的时候会运行一次呢?

让我们看下 /client 的返回:

你会发现 init text其实是来自于 useState 中的值,但是却依然输出在 HTML 中。

这就是编译客户端组件的作用,为了第一次加载的时候能更快的展示出内容。

所以,其实所谓服务端组件、客户端组件并不直接对应于物理上的服务器和客户端。服务端组件运行在构建时和服务端,客户端组件运行在构建时、服务端(生成初始 HTML)和客户端(管理 DOM)

3、交替使用服务端组件和客户端组件

实际开发的时候,不可能纯用服务端组件或者客户端组件,当交替使用的时候,一定要注意一点,那就是:

服务端组件可以直接导入客户端组件,但客户端组件并不能导入服务端组件

1. 服务端组件能导入客户端组件:符合 "渲染流向"

服务端组件的核心作用是在服务端组装页面骨架、获取数据 ,而客户端组件是为了处理交互(点击、输入、状态) 。Next.js 设计时,把 SC 作为 "页面的根 / 容器",CC 作为 "交互子节点",这种 "父(SC)包含子(CC)" 的结构完全契合渲染逻辑

执行过程

  1. 服务端执行 SC 时,遇到导入的 CC,不会直接执行CC 的代码(CC 的代码是给浏览器用的),而是将 CC 标记为 需要客户端水合的组件;
  2. 服务端把 SC 渲染为 HTML 片段,同时把 CC 的占位标记和 CC 的打包代码路径一起发给客户端;
  3. 客户端接收到页面后,先渲染 SC 生成的静态内容,再加载 CC 的代码并完成水合,让 CC 具备交互能力。

整个React 树会变成这样:

其中黄色节点表示 React Server Component。在服务端,React 会将其渲染会一个包含基础 HTML 标签和客户端组件占位的树。

因为客户端组件的数据和结构在客户端渲染的时候才知道,所以客户端组件此时在树中使用特殊的占位进行替代。

当然这个树不可能直接就发给客户端,React 会做序列化处理,客户端收到后会在客户端根据这个数据重构 React 树,然后用真正的客户端组件填充占位,渲染最终的结果。

2. 客户端组件不能能导入客户端组件

js 复制代码
'use client'
 
// 这是不可以的
import ServerComponent from './Server-Component'
 
export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ServerComponent />
    </>
  )
}

正如介绍客户端组件时所说:

"use client"用于声明服务端和客户端组件模块之间的边界。当你在文件中定义了一个 "use client",导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分。

组件默认是服务端组件 ,但当组件导入到客户端组件中会被认为是客户端组件。客户端组件不能导入服务端组件,其实是在告诉你,如果你在服务端组件中使用了诸如 Node API 等,该组件可千万不要导入到客户端组件中

另外,渲染逻辑闭环被打破,Next.js 的渲染逻辑是 "服务端先处理静态 / 数据层(SC)→ 客户端再处理交互层(CC)",是单向的 "服务端 → 客户端" 流向。如果允许 CC 导入 SC,相当于让 "客户端" 反向控制 "服务端"。

但你可以将服务端组件以 props 的形式传给客户端组件:

js 复制代码
'use client'
 
import { useState } from 'react'
 
export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0)
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children}
    </>
  )
}


import ClientComponent from './client-component'
import ServerComponent from './server-component'
 
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

使用这种方式,<ClientComponent><ServerComponent> 代码解耦且独立渲染。

4、组件渲染原理

1. 在服务端

Next.js 使用 React API 编排渲染,渲染工作会根据路由和 Suspense 拆分成多个块(chunks),每个块分两步进行渲染:

  1. React 将服务端组件渲染成一个特殊的数据格式称为 React Server Component Payload (RSC Payload)
  2. Next.js 使用 RSC Payload 和客户端组件代码在服务端渲染 HTML;

RSC payload 中包含如下这些信息:

  1. 服务端组件的渲染结果
  2. 客户端组件占位符和引用文件
  3. 从服务端组件传给客户端组件的数据

为什么会包含"客户端组件占位符和引用文件"呢?

1. 占位符:告诉客户端 "这里有个需要交互的组件,先留位置"

其实在上面我们已经说了,服务端组件(SC)执行在服务端,客户端组件(CC)执行在浏览器,两者的职责边界是:SC 负责搭骨架,CC 负责加交互 。但 SC 在服务端渲染时,根本无法执行 CC 的代码(CC 依赖浏览器 API、React 状态等),只能做 "标记",这就是 "占位符 + 引用文件" 的核心作用。

SC 渲染时,遇到导入的 CC,不会生成 CC 的真实 DOM(因为 CC 还没在客户端激活),而是生成一个特殊的占位标记(RSC 协议里的 JSON 标记) ,比如:

js 复制代码
// 简化的 RSC payload 片段
{ 
    "type": "client.component",
    "id": "cc-123", // 唯一标识 
    "fallback": "<div>加载中...</div>" // 可选的占位内容 
}

这个占位符的作用:

  • 保证页面结构完整:客户端拿到 payload 后,先渲染 SC 生成的静态内容 + CC 的占位符,不会出现 "交互组件位置空白" 的情况,避免布局错乱;
  • 标记待激活区域:告诉 React 运行时这个位置的组件需要后续加载客户端代码并水合,是客户端激活 CC 的 锚点。

2. 引用文件:告诉客户端去哪找这个 CC 的交互代码

CC 的代码会被 Next.js 打包成独立的客户端 JS 包(比如 static/chunks/cc-123.js),RSC payload 中会附带这个包的引用路径和哈希值,比如:

js 复制代码
{ 
   "type": "client.reference", 
   "id": "cc-123", 
   "filePath": "/_next/static/chunks/cc-123.js", 
   "name": "ClientButton" 
}

这个引用的核心价值:

  • 按需加载:客户端只会加载页面中实际用到的 CC 代码,而不是把所有 CC 代码都打包进首屏(比如页面有 10 个 CC,但首屏只显示 2 个,就只加载这 2 个的代码),减少客户端 JS 体积;
  • 精准激活:React 运行时根据引用路径下载对应的 CC 代码后,能精准替换掉之前的占位符,完成 CC 的水合(让 CC 具备 useState/useEffect 等交互能力);
  • 版本控制 :通过哈希值(比如 cc-123.abc123.js)实现缓存复用,后续页面如果用到同一个 CC,客户端不用重复下载。

为什么包含从服务端组件传给客户端组件的数据?

如果 RSC payload 不附带这份数据,CC 激活后只能自己通过 fetch 去请求相同的数据,会导致:

  • 重复的网络请求: 服务端查一次数据库,客户端又查一次,浪费服务器资源;
  • 额外的网络延迟 :CC 要等 fetch 返回才能渲染,出现 "占位符→加载中→真实内容" 的二次等待。而 SC 把数据直接塞进 payload,CC 激活后能直接用。

2. 在客户端

  1. 加载渲染的 HTML 快速展示一个非交互界面(Non-interactive UI)
  2. RSC Payload 会被用于协调(reconcile)客户端和服务端组件树,并更新 DOM
  3. JavaScript 代码被用于水合客户端组件,使应用程序具有交互性(Interactive UI)

注意:上图描述的是页面初始加载的过程。其中 SC 表示 Server Components 服务端组件,CC 表示 Client Components 客户端组件。

在前一篇文章中讲到 Suspense 和 Streaming 也有一些问题没有解决,比如该加载的 JavaScript 代码没有少、所有组件都必须水合,即使组件不需要水合。

使用服务端组件和客户端组件就可以解决这个问题,服务端组件的代码不会打包到客户端 bundle 中。渲染的时候,只有客户端组件需要进行水合,服务端组件无须水合

而在后续导航的时候:

后续导航(客户端路由导航) 则是 Next.js 基于 next/navigation(App Router)实现的客户端侧无刷新导航 ,核心是 "按需加载资源 + 局部更新页面 + 保留客户端状态",全程不触发浏览器的整页刷新。

核心前提:后续导航的触发条件

用户点击 Next.js 提供的 <Link> 组件(而非原生 <a> 标签)、调用 useRouter().push()/replace() 等客户端路由方法时,会触发后续导航;

如果直接刷新页面 / 输入 URL,仍会走首次导航流程。

完整流程(App Router)

  • Next.js 的客户端路由运行时(next/navigation 底层)会拦截 <Link> 点击事件,阻止浏览器的默认页面跳转(event.preventDefault());

  • 客户端向服务端发起一个轻量的 RSC 请求 (不是整页 HTML 请求),请求目标路由的 Server Components 渲染结果(即 RSC Payload,格式是特殊的 JSON 流);这个请求只会获取目标路由的 Server Components 渲染出的静态内容、客户端组件的占位符 + 代码引用、服务端传给客户端组件的数据

  • React 运行时接收 RSC Payload 后,RSC Payload 内容如下:

不仅包含数据,完整渲染后的 DOM 结构也都包含了。

客户端收到 RSC Payload 后就会根据这其中的内容修改 DOM。而且在这个过程,页面不会刷新,页面实现了 partial rendering(部分更新)

也就是,先渲染 SC 生成的静态内容,替换当前页面的主内容区域,同时保留页面的公共布局(比如导航栏、页脚),这就是局部更新,公共部分不重新渲染。

如果目标路由包含新的客户端组件(未在当前页面加载过),Next.js 会根据 RSC Payload 中的 "客户端组件引用路径",异步加载对应的客户端 JS 包(体积很小,按需加载);已加载过的客户端组件会复用缓存,不会重复下载。

  • 对客户端组件来说:先渲染占位符(比如加载中),等对应的 JS 包下载完成后,完成 "水合"(激活交互,比如 useState/useEffect 生效),替换占位符为真实交互组件;

  • 整个过程中,页面的 <head> 标签(标题、meta 等)会被 Next.js 自动更新(基于目标路由的 generateMetadatametadata 配置),但不会刷新页面

  • Next.js 调用浏览器的 history.pushState()/replaceState() API,更新地址栏 URL,但不会触发浏览器的 popstate 整页刷新;

  • 客户端状态(比如全局 Redux 状态、组件内的 useState、表单输入值)会被保留(除非主动重置),比如从 /home 跳转到 /post/123,导航栏的登录状态、全局主题设置不会丢失。

相关推荐
小满zs2 天前
Next.js第二十章(MDX)
前端·next.js
多啦C梦a3 天前
《双Token机制?》Next.js 双 Token 登录与无感刷新实战教程
前端·全栈·next.js
小p3 天前
nextjs学习4:创建服务端 API(路由处理程序)
next.js
小p3 天前
nextjs学习5:Suspense 与 Streaming
next.js
无敌暴龙战士通关前端4 天前
3天速成 使用AI《从零开发一款 AI 面试作弊助手》一
react.js·next.js
小满zs6 天前
Next.js第十九章(服务器函数)
前端·next.js
小p7 天前
nextjs学习2:app router
next.js
小p7 天前
nextjs学习3:动态路由、路由组、平行路由
next.js