JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第10章—渲染篇服务端组件和客户端组件

首次发表于 2024-03-22, 更新于 2024-03-22

前言

服务端组件和客户端组件是 Next.js 中非常重要的概念。如果没有细致的了解过,你可能会简单的以为所谓服务端组件就是 SSR,客户端组件就是 CSR,服务端组件在服务端进行渲染,客户端组件在客户端进行渲染等等,实际上并非如此。本篇就让我们深入学习和探究 Next.js 的双组件模型吧!

服务端组件

1. 介绍

在 Next.js 中,组件默认就是服务端组件。

举个例子,新建 app/todo/page.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 发送给客户端:

截屏2024-03-05 15.59.27.png

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

2. 优势

使用服务端渲染有很多好处:

  1. 数据获取:通常服务端环境(网络、性能等)更好,离数据源更近,在服务端获取数据会更快。通过减少数据加载时间以及客户端发出的请求数量来提高性能
  2. 安全:在服务端保留敏感数据和逻辑,不用担心暴露给客户端
  3. 缓存:服务端渲染的结果可以在后续的请求中复用,提高性能
  4. bundle 大小:服务端组件的代码不会打包到 bundle 中,减少了 bundle 包的大小
  5. 初始页面加载和 FCP:服务端渲染生成 HTML,快速展示 UI
  6. Streaming:服务端组件可以将渲染工作拆分为 chunks,并在准备就绪时将它们流式传输到客户端。用户可以更早看到页面的部分内容,而不必等待整个页面渲染完毕

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

3. 限制

虽然使用服务端组件有很多好处,但使用服务端组件也有一些限制,比如不能使用 useState 管理状态,不能使用浏览器的 API 等等。如果我们使用了 Next.js 会报错,比如我们将代码修改为:

import { useState } from 'react';

export default async function Page() {

  const [title, setTitle] = useState('');

  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>
}

此时浏览器会报错:

image.png

报错提示我们此时需要使用客户端组件。那么又该如何使用客户端组件呢?

客户端组件

1. 介绍

使用客户端组件,你需要在文件顶部添加一个 "use client" 声明,修改 app/todo/page.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 的一部分。

2. 优势

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

服务端组件 VS 客户端组件

1. 如何选择使用?

如果你需要…… 服务端组件 客户端组件
获取数据 ✅ ❌
访问后端资源(直接) ✅ ❌
在服务端上保留敏感信息(访问令牌、API 密钥等) ✅ ❌
在服务端使用依赖包,从而减少客户端 JavaScript 大小 ✅ ❌
添加交互和事件侦听器(onClick(), onChange() 等) ❌ ✅
使用状态和生命周期(useState(), useReducer(), useEffect()等) ❌ ✅
使用仅限浏览器的 API ❌ ✅
使用依赖于状态、效果或仅限浏览器的 API 的自定义 hook ❌ ✅
使用 React 类组件 ❌ ✅

2. 渲染环境

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

这是什么意思呢?让我们写个例子,新建 app/client/page.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,代码如下:

console.log('server')

export default function Page() {

  console.log('server Page')

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

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

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

截屏2024-03-05 21.46.46.png

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

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

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

image.png

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

image.png

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

截屏2024-03-05 22.00.58.png

你会发现 init text其实是来自于 useState 中的值,但是却依然输出在 HTML 中。这就是编译客户端组件的作用,为了第一次加载的时候能更快的展示出内容。

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

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

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

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

'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 等,该组件可千万不要导入到客户端组件中。

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

'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> 代码解耦且独立渲染。

注:你可能会想为什么要这么麻烦的非要使用 ServerComponent 呢?这是因为 ServerComponent 有很多好处比如代码不会打包到 bundle 中。而为什么以 props 的形式就可以传递呢?在 《实战篇 | React Notes | 笔记搜索》中,我们会结合实战项目更具体的讲解。

4. 组件渲染原理

在服务端:

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

image.png

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

我们在上节《渲染篇 | Suspense 与 Streaming》讲到 Suspense 和 Streaming 也有一些问题没有解决,比如该加载的 JavaScript 代码没有少、所有组件都必须水合,即使组件不需要水合。

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

而在后续导航的时候:

  1. 客户端组件完全在客户端进行渲染
  2. React 使用 RSC Payload 来协调客户端和服务端组件树,并更新 DOM

image.png

线上查看代码和效果:CodeSandbox Server Components And Client Components

最佳实践:使用服务端组件

1. 共享数据

当在服务端获取数据的时候,有可能出现多个组件共用一个数据的情况。

面对这种情况,你不需要使用 React Context(当然服务端也用不了),也不需要通过 props 传递数据,直接在需要的组件中请求数据即可。这是因为 React 拓展了 fetch 的功能,添加了记忆缓存功能,相同的请求和参数,返回的数据会做缓存。

async function getItem() {
  const res = await fetch('https://.../item/1')
  return res.json()
}
 
// 函数被调用了两次,但只有第一次才执行
const item = await getItem() // cache MISS
 
// 第二次使用了缓存
const item = await getItem() // cache HIT

当然这个缓存也是有一定条件限制的,比如只能在 GET 请求中,具体的限制和原理我们会在缓存篇中具体讲解。

2. 组件只在服务端使用

由于 JavaScript 模块可以在服务器和客户端组件模块之间共享,所以如果你希望一个模块只用于服务端,就比如这段代码:

export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

这个函数使用了 API_KEY,所以它应该是只用在服务端的。如果用在客户端,为了防止泄露,Next.js 会将私有环境变量替换为空字符串,所以这段代码可以在客户端导入并执行,但并不会如期运行。

为了防止客户端意外使用服务器代码,我们可以借助 server-only包,这样在客户端意外使用的时候,会抛出构建错误。

使用 server-only,首先安装该包:

npm install server-only

其次将该包导入只用在服务端的组件代码中:

import 'server-only'
 
export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

现在,任何导入 getData的客户端组件都会在构建的时候抛出错误,以保证该模块只能在服务端使用。

3. 使用三方包

毕竟 React Server Component 是一个新特性, React 生态里的很多包可能还没有跟上,这样就可能会导致一些问题。

比如你使用了一个导出 <Carousel />组件的 acme-carousel包。这个组件使用了 useState,但是它并没有 "use client" 声明。

当你在客户端组件中使用的时候,它能正常工作:

'use client'
 
import { useState } from 'react'
import { Carousel } from 'acme-carousel'
 
export default function Gallery() {
  let [isOpen, setIsOpen] = useState(false)
 
  return (
    <div>
      <button onClick={() => setIsOpen(true)}>View pictures</button>
 
      {/* Works, since Carousel is used within a Client Component */}
      {isOpen && <Carousel />}
    </div>
  )
}

然而如果你在服务端组件中使用,它会报错:

import { Carousel } from 'acme-carousel'
 
export default function Page() {
  return (
    <div>
      <p>View pictures</p>
 
      {/* Error: `useState` can not be used within Server Components */}
      <Carousel />
    </div>
  )
}

这是因为 Next.js 并不知道 <Carousel />是一个只能用在客户端的组件,毕竟它是三方的,你也无法修改它的代码,为它添加 "use client" 声明,Next.js 于是就按照服务端组件进行处理,结果它使用了客户端组件的特性 useState,于是便有了报错。

为了解决这个问题,你可以自己包一层,将该三方组件包在自己的客户端组件中,比如:

'use client'
 
import { Carousel } from 'acme-carousel'
 
export default Carousel

现在,你就可以在服务端组件中使用 <Carousel />了:

import Carousel from './carousel'
 
export default function Page() {
  return (
    <div>
      <p>View pictures</p>
      <Carousel />
    </div>
  )
}

4. 使用 Context Provider

上下文是一个典型的用于节点的特性,主要是为了共享一些全局状态,就比如当前的主题(实现换肤功能)。但服务端组件不支持 React context,如果你直接创建会报错:

import { createContext } from 'react'
 
//  服务端组件并不支持 createContext
export const ThemeContext = createContext({})
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  )
}

为了解决这个问题,你需要在客户端组件中进行创建和渲染:

'use client'
 
import { createContext } from 'react'
 
export const ThemeContext = createContext({})
 
export default function ThemeProvider({ children }) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}

然后再在根节点使用:

import ThemeProvider from './theme-provider'
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

这样应用里的其他客户端组件就可以使用这个上下文。

最佳实践:使用客户端组件

1. 客户端组件尽可能下移

为了尽可能减少客户端 JavaScript 包的大小,尽可能将客户端组件在组件树中下移。

举个例子,当你有一个包含一些静态元素和一个交互式的使用状态的搜索栏的布局,没有必要让整个布局都成为客户端组件,将交互的逻辑部分抽离成一个客户端组件(比如<SearchBar />),让布局成为一个服务端组件:

// SearchBar 客户端组件
import SearchBar from './searchbar'
// Logo 服务端组件
import Logo from './logo'
 
// Layout 依然作为服务端组件
export default function Layout({ children }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  )
}

注:这点我们还会在实战篇的第一个项目《实战篇 | React Notes | 侧边栏笔记列表》讲解演示。

2. 从服务端组件到客户端组件传递的数据需要序列化

当你在服务端组件中获取的数据,需要以 props 的形式向下传给客户端组件,这个数据需要做序列化。

这是因为 React 需要先在服务端将组件树先序列化传给客户端,再在客户端反序列化构建出组件树。如果你传递了不能序列化的数据,这就会导致错误。

如果你不能序列化,那就改为在客户端使用三方包获取数据吧。

注:这点我们还会在实战篇的第一个项目《实战篇 | React Notes | 侧边栏笔记列表》讲解演示。

参考链接

  1. Introducing Zero-Bundle-Size React Server Components – React Blog
  2. How React server components work: an in-depth guide
  3. Rendering: Server Components
  4. Rendering: Client Components
  5. Rendering: Composition Patterns
  6. https://github.com/reactwg/server-components/discussions/4
  7. https://news.ycombinator.com/item?id=25499171
  8. https://betterprogramming.pub/the-future-of-react-server-components-90f6e3e97c8a
  9. https://twitter.com/dan_abramov/status/1342264337478660096
  10. https://www.builder.io/blog/why-react-server-components#suspense-for-server-side-rendering
© Copyright 2025 JackyLove 的技术人生. Powered with ♥ by CreativeDesignsGuru