NextJS v13服务端组件和客户端组件及最佳实践

🧑‍💻
推荐全栈学习资源:
  • Next.js 中文文档:样式和官网一样的中文文档,创造沉浸式Next.js中文学习体验。
  • 《Chrome插件全栈开发》:真实出海项目的实战教学课,讲解Chrome插件和Next.js端的全栈开发,帮助你半个月内成为全栈出海工程师。
  • 上一篇文章中,我们介绍了 NextJS v13 的混合渲染机制,本文将展开介绍 NextJS v13 服务端渲染和客户端渲染的工作原理、使用时机,以及官方推荐的最佳实践。

    服务端组件

    服务端组件就是在服务端渲染的组件,使用 NextJS v13 的项目,只要是在 app 目录内的页面,全部默认为服务端组件。

    服务端组件的优势

    1. 数据获取:服务端组件具备完整的服务端能力,所以可以直接与数据库或其他数据源进行交互。这消除了需要从客户端到服务器的额外请求,从而加速了数据获取。
    2. 安全性:因为渲染是在服务器上进行的,所以你可以在服务器上使用敏感的 API 密钥或令牌,而不必担心它们被暴露给客户端用户。
    3. 缓存:服务端渲染的结果可以被缓存。这意味着对于经常被访问的页面,你可以存储已经渲染的HTML,从而快速地为后续的请求提供响应,而不必每次都重新渲染。
    4. 包大小:一些库或框架可能非常大,如果全部发送到客户端,会增加首次加载时间。通过在服务器上使用这些库,你可以避免增加客户端的包大小。
    5. 初始页面加载和首次内容绘制 (FCP):服务端渲染的页面可以立即为用户提供可见的内容,而不必等待客户端JavaScript加载和执行。
    6. 搜索引擎优化和社交网络分享:服务端渲染的页面为搜索引擎提供了完整的HTML内容,这有助于提高SEO排名。此外,社交媒体平台也可以预览这些页面,从而提高分享的吸引力。
    7. 流式传输:这是一个更高级的优化。你可以将页面渲染分成多个部分,并在它们准备好时发送到客户端。这允许用户更早地看到部分内容,而不必等待整个页面在服务器上渲染完成。

    除了这些优势,还要清楚一个注意点:服务端组件是在服务端运行的,所以就没有调用浏览器 API 的能力了,比如要使用类似于window.xxxuseStateuseEffect等方法,需要在文件开头用“use client”声明,这是后文的内容。

    服务端组件的渲染

    NextJS v13 基于 React v18,将 Server Component 变为实际可用了,而且通过 Suspense 实现了流式渲染,也就是把页面一块一块返回给客户端,然后与客户端组件进行混合渲染。

    1.png

    服务端组件的渲染策略

    服务器渲染三种策略:静态渲染、动态渲染和流式渲染。

    静态渲染(默认)

    NextJS v13 的服务端组件默认是静态渲染,它有以下特点:

    • 路由会在构建时进行渲染,或在数据重新验证后在后台运行
    • 结果会被缓存,并可推送到内容分发网络(CDN)

    这种渲染方式很适合静态博客或产品介绍页面。

    动态渲染

    既然默认是静态渲染,那么什么情况下会触发动态渲染呢?

    在渲染过程中,如果 NextJS 发现有动态函数或未缓存的数据请求,会自动切换为动态渲染整个路由。本表总结了动态函数和数据缓存对静态或动态渲染路由的影响:

    是否有动态方法数据是否缓存路由渲染方式
    静态渲染
    动态渲染
    动态渲染
    动态渲染

    动态函数依赖于只能在请求时知道的信息,如用户的 cookie、当前请求头或 URL 的搜索参数。

    流式渲染

    通过流式处理,路由会在请求时在服务器上渲染。当工作准备就绪时,会将其分成几块并以流式传输到客户端。这样,用户就能在页面完全呈现之前看到页面预览。

    2.png

    3.png

    流式渲染适合应用于优先级较低的用户界面,或依赖于较慢数据获取速度的用户界面。例如,产品页面上的评论。

    在 NextJS 中,我们可以使用 loading.js 对路由段进行流式处理,并使用 React Suspense 对 UI 组件进行流式处理。关于 React Suspense,以后有机会再展开说。

    客户端组件

    在app文件夹中,默认是服务端组件,如果要使用客户端组件,只需要在文章开头添加 "use client"指令。

    客户端组件的优势

    • 交互性:客户端组件可以使用状态、效果和事件监听器,这意味着它们可以向用户提供即时反馈并更新用户界面。
    • 浏览器 API:客户端组件可以访问浏览器 API,如 window,从而为特定用例构建用户界面。

    如何使用客户端组件

    如果要使用客户端组件,只需要在文章开头添加 "use client"指令。

    'use client'
     
    import { useState } from 'react'
     
    export default function Counter() {
      const [count, setCount] = useState(0)
     
      return (
        <div>
          <p>You clicked {count} times</p>
          <button onClick={() => setCount(count + 1)}>Click me</button>
        </div>
      )
    }

    使用了"use client"等于是声明了一个服务器和客户端组件模块之间的边界,也就是说,如果一个文件顶部有这个指令,那么导入该文件的所有其他模块(包括子组件)都将被视为客户端捆绑的一部分,并将由 React 在客户端上呈现。

    4.png

    客户端组件如何渲染

    在 NextJS,客户端组件会根据请求是完整页面加载(首次访问应用程序或浏览器刷新触发的页面重载)的一部分还是后续导航而以不同方式呈现。

    完整页面加载

    为了优化初始页面加载,NextJS 将使用 React 的 API 在服务器上为客户端组件和服务端组件呈现静态 HTML 预览。这意味着,当用户第一次访问我们的应用程序时,他们将立即看到页面内容,而无需等待客户端下载、解析和执行客户端组件 JavaScript 捆绑程序。

    在服务端侧:

    • React 将 Server Components 渲染成一个特殊的数据格式,称为 React Server Component Payload(RSC Payload),其中包括对 Client Components 的引用。

    • NextJS 使用 RSC Payload 和 Client Component 的 JavaScript 指令在服务器上为路由渲染 HTML。

      React Server Component Payload(RSC Payload),是一个由 React 团队设计的数据格式,用于表示在服务器上渲染的 React Server Components 的结果,它会包含从 服务端组件产生的所有内容,和指示客户端组件渲染位置的占位符。RSC Payload 是一个内部格式,开发者通常不需要直接与它交互,只要了解一下即可。

    在客户端侧:

    • HTML 用于立即显示路由的快速非交互式初始预览。
    • RSC Payload 用于调和 Client 和 Server Component 树,并更新 DOM。
    • JavaScript 指令用于 hydrate Client Components,使其 UI 成为可交互的。

    这种方法的主要优点是,它允许页面在不需要客户端执行任何 JavaScript 的情况下快速显示,从而提供更好的用户体验。然后,一旦客户端的 JavaScript 加载并执行,页面就会变得完全可交互。

    后续导航

    对于后续的导航(即用户在应用内从一个页面导航到另一个页面),情况会有所不同。这是因为在这种情况下,已经假设客户端已经加载了必要的代码和数据,所以不需要再次从服务器获取渲染的HTML。

    后续导航的处理流程:

    1. JavaScript Bundle:当用户进行后续导航时,NextJS 会确保客户端下载并解析了必要的 Client Component JavaScript 包。如果包已经被缓存(例如,用户之前访问过该页面),则不需要重新下载。
    2. 使用 RSC Payload:一旦 JavaScript 包准备好,React 会使用之前从服务器获取的 RSC Payload 来调和 Client 和 Server Component 树。这基本上是比较当前 DOM 与新的 Server Component 结果之间的差异,并进行必要的更新。
    3. DOM 更新:在上述调和过程中,React 会更新 DOM,以确保它反映了最新的页面内容和状态。

    这种处理后续导航的方式有几个优点:

    • 速度:由于不需要从服务器获取完整的 HTML,导航会更快。
    • 流畅的用户体验:React 通过智能地只更新发生变化的 DOM 部分,确保了平滑的页面转换。
    • 带宽效率:只传输真正需要的数据和代码,而不是完整的页面 HTML。
    💡

    为什么完整页面加载里说服务端渲染更快,而后续导航里又说不从服务端渲染更快?

    因为这两种情况有所不同:

    1. 首次页面加载:
      • 当用户首次访问你的应用时,他们的浏览器尚未加载任何相关的 JavaScript 或数据。在这种情况下,从服务器发送预渲染的 HTML 是有益的,因为用户可以立即看到页面的内容,而不必等待客户端 JavaScript 下载、解析和执行。
    2. 后续导航:
      • 在用户已经在你的应用内部并开始导航到其他页面时,大部分所需的 JavaScript 代码和数据可能已经被缓存或预加载。因此,再次进行服务端渲染并从服务器获取完整的 HTML 可能不是最有效的方法。
      • 在这种情况下,只获取必要的数据(例如 JSON 格式)并在客户端进行渲染可能更为高效,因为这消除了服务器渲染的延迟,并减少了网络传输的数据量。

    服务端和客户端组件最佳实践

    在构建 NextJS/React 应用程序时,我们应当考虑应用程序的哪些部分应在服务器或客户端上呈现。

    分别在什么时候使用服务端和客户端组件

    动作服务端组件客户端组件
    获取数据
    访问后台资源(直接)
    将敏感信息保存在服务器上(访问令牌、应用程序接口密钥等)
    在服务器上保留大量依赖关系/减少客户端 JavaScript
    添加交互性和事件监听器(onClick()、onChange()等)
    使用状态和生命周期效果(useState()、useReducer()、useEffect()等)
    使用浏览器专用 API
    使用依赖于状态、效果或浏览器专用 API 的自定义钩子
    使用 React 类组件

    服务端组件最佳实践

    1. 共享数据

      当在服务器上获取数据时,可能需要在不同的组件之间共享数据。与其使用 React Context(在服务器上不可用)或传递数据作为 props,不如使用 fetch 或 React 的 cache 函数在需要的组件中获取相同的数据,React 将对重复请求进行合并。

    2. 让服务端代码隔离于客户端组件之外

      要确保一些只应在服务器上运行的代码不会意外地进入客户端,可以使用 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()
      }

      使用了 server-only 包的组件,将只允许服务端代码调用,如果客户端代码调用则会报错。

      相应的,还有一个 client-only 包,可以用来标记仅包含客户端代码的模块。

    3. 使用第三方包

      由于服务端组件是一个新的 React 功能,许多 npm 包可能还没有添加use client指令。如果一个第三方组件在服务端组件中不起作用,可以将其封装在你自己的客户端组件中来解决这个问题。

      例如:

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

      可以通过封装一层,客户端组件引入封装后的代码,就能解决掉报错:

      'use client'
       
      import { Carousel } from 'acme-carousel'
       
      export default Carousel
      import Carousel from './carousel'
       
      export default function Page() {
        return (
          <div>
            <p>View pictures</p>
       
            {/*  Works, since Carousel is a Client Component */}
            <Carousel />
          </div>
        )
      }
    4. 使用Context Providers

      通常,Context Providers 会在应用的根部渲染,以共享全局状态,如当前主题。

      但是 Context Providers 在服务端组件里是不受支持的,例如:

      // layout.tsx
       
      import { createContext } from 'react'
       
      //  createContext is not supported in Server Components
      export const ThemeContext = createContext({})
       
      export default function RootLayout({ children }) {
        return (
          <html>
            <body>
              <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
            </body>
          </html>
        )
      }

      要解决这个问题,就要在客户端组件中创建上下文并呈现其提供程序

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

      现在引入服务端组件就不会报错了

      // layout.tsx
       
      import ThemeProvider from './theme-provider'
       
      export default function RootLayout({
        children,
      }: {
        children: React.ReactNode
      }) {
        return (
          <html>
            <body>
              <ThemeProvider>{children}</ThemeProvider>
            </body>
          </html>
        )
      }
      💡

      看到这个例子,有的同学就有疑问了,前面说了被"use client"声明的组件,会画一条分界线,把这个组件及其子组件都变成客户端组件,而这里ThemeProvider包裹了所有children,那不就变成所有页面都是客户端组件了吗?

      其实不是,ThemeProvider在这里其实只是提供了一个插槽,只能决定子元素的位置,而不能决定子元素的渲染方式。

    客户端组件最佳实践

    1. 向下移动客户端组件

      为了减少客户端打包后的大小,客户端组件应当放在组件树的枝干(组件树从上到下,最上面是跟节点)。

      例如,我们有一个<SearchBar />的组件,这个组件需要交互,所以只能是客户端组件。那么,如果要加快渲染速度,就可以把页面放在服务端渲染,而<SearchBar />作为客户端组件引入。

      // SearchBar is a Client Component
      import SearchBar from './searchbar'
      // Logo is a Server Component
      import Logo from './logo'
       
      // Layout is a Server Component by default
      export default function Layout({ children }: { children: React.ReactNode }) {
        return (
          <>
            <nav>
              <Logo />
              <SearchBar />
            </nav>
            <main>{children}</main>
          </>
        )
      }

      联系上文,我们可以知道,这样做的方式,会优先进行服务端组件渲染,而此时<SearchBar />的位置会是一个占位符,一旦开始混合渲染,占位符的位置会被真实的<SearchBar />填充。

    2. 从服务器组件传递 props 到客户端组件(可序列化数据)

      考虑以下场景:你有一个服务器组件负责获取用户的信息,然后你希望将这些信息传递给一个客户端组件以展示一个交互式的用户资料卡。

      在这种情境中,你会:

      • 在服务器组件中预获取用户数据。
      • 将数据作为 props 传递给客户端组件。
      • 为了使这个过程有效且无错误,从服务器组件传递给客户端组件的 props 必须是可序列化的。

      由此又引申出两个问题:

      💡

      什么是“可序列化的”?

      基本的数据类型(如数字、字符串、数组和普通对象)通常都是可序列化的。但是,如函数、特定的对象实例(例如 Date 对象或自定义类的实例)或包含循环引用的对象可能不是。

      💡

      怎么处理不可序列化的数据?

      如果客户端组件依赖于不可序列化的数据,你有几个选项:

      • 客户端获取:在客户端组件中直接获取数据,比如使用 AJAX 请求。
      • 路由处理程序:在 NextJS 中,你可以使用 API 路由来在服务器上获取数据,并从客户端进行调用。

    混合使用客户端和服务器组件

    当你混合使用客户端和服务器组件时,应当将 UI 视为组件树。从根布局开始(它是一个服务器组件),你可以通过添加 "use client" 指令在客户端渲染某些子树。

    虽然在这些客户端子树中仍然可以嵌套服务器组件或调用服务器操作,但仍有一些事情需要注意:

    • 如果需要在客户端访问服务器上的数据或资源,客户端需要向服务器发出新的请求。
    • 当向服务器发出新请求时,首先渲染所有服务器组件,在客户端上,React 会使用 RSC Payload 将服务器组件和客户端组件调和成一棵树。
    • 由于客户端组件是在服务器组件之后呈现的,因此不能将服务器组件导入到客户端组件中。正确的方式应该是:将服务器组件作为属性传递给客户端组件(如:children),这种方式允许你在客户端组件中使用来自服务器组件的数据或内容,而不必再次请求服务器。

    错误示例:

    'use client'
     
    // You cannot import a Server Component into a Client Component.
    import ServerComponent from './Server-Component'
     
    export default function ClientComponent({
      children,
    }: {
      children: React.ReactNode
    }) {
      const [count, setCount] = useState(0)
     
      return (
        <>
          <button onClick={() => setCount(count + 1)}>{count}</button>
     
          <ServerComponent />
        </>
      )
    }

    正确示例:

    'use client'
     
    import { useState } from 'react'
     
    export default function ClientComponent({
      children,
    }: {
      children: React.ReactNode
    }) {
      const [count, setCount] = useState(0)
     
      return (
        <>
          <button onClick={() => setCount(count + 1)}>{count}</button>
          {children}
        </>
      )
    }
    // This pattern works:
    // You can pass a Server Component as a child or prop of a
    // Client Component.
    import ClientComponent from './client-component'
    import ServerComponent from './server-component'
     
    // Pages in Next.js are Server Components by default
    export default function Page() {
      return (
        <ClientComponent>
          <ServerComponent />
        </ClientComponent>
      )
    }

    结语

    本文是衔接上一篇文章:NextJS v13 的渲染机制有什么不同?,这两篇文章把 NextJS v13 的渲染机制讨论得透透的了,学完就再也不是知其然却不知其所以然的 NextJS 开发者了🫡。

    专栏资源

    专栏介绍:以实战的角度进行Next.js生态圈的技术栈分享,内容包括但不限于:Next.js理论知识、功能模块设计思路、实战中使用到的技术栈。这是一个长期更新的专栏,我会持续把自己的思考和经验提炼分享出来,欢迎关注我的专栏👇

    专栏地址:👉Next.js生态圈实战

    专栏演示站:👉Next.js Demos

    专栏源码仓库:👉Github - Source Code

    交个朋友:👉加入「独立全栈交流群」