谈一谈React18的服务端组件

最近笔者腾出了大把的时间,学习了一下React18。

什么是服务器组件

React18带来了一个实验性的特性:React Server Components,简称RSC,即服务器组件。

服务器组件可能会在React19版本作为稳定特性推出。如果目前就用于生产的话,可能需要注意API在未来的变化情况。

什么是服务器组件?简单来说就是渲染过程运行在服务端的组件,它通过将组件在服务端执行,并输出序列化的数据到客户端以构建UI。在推出RSC之前,React组件并没有特定的名称,但是现在可以称之为客户端组件(RCC)。它们最大的区别就是运行环境,RCC需要在客户端执行渲染。

那么问题就来了,为什么要出现RSC?RCC存在什么问题吗?

阅读本文,必须要知道React18的SSR新特性的知识,可以看看我之前写的文章谈一谈React18的服务端渲染。此外官方RFC也可以读一读。

客户端组件的缺点

客户端组件在用户浏览器上运行渲染。这就表示,如果React要在页面上渲染某RCC,那么必不可少的需要经过 加载依赖的js bundle -> 按顺序解析和执行js 这个过程。如果这些js很大怎么办?那就会阻塞渲染以及交互。

比如我们最常见的SPA方案,想要完整的渲染出页面,就要等待包括React在内的库加载和执行完成,导致FCP比较高、白屏时间长。所以才出现了代码分包、懒加载、SSR等技术,来降低页面的FCP,可以说解决了一大痛点。但对于单个组件来说,仍然存在问题。

1. TTI(Time to Interactive)比较长

一个很可能会遇见的情况就是,项目中有一个组件依赖了另一个js库,而这个js库即使打包压缩了仍然很大。比如以下代码:

import React from 'react';
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function Markdown({ content }) {
  return <div>{sanitizeHtml(marked(content))}</div>;
}

这个<Markdown />组件接收一个字符串的markdown文本作为参数,并在组件中使用依赖的第三方库markedsanitize-html来解析文本,并转换为相应的html内容。这两个三方库都比较大。React想要成功渲染该组件,那么必不可少的需要先下载这些js。无论是提前加载还是懒加载,下载的耗时不会消失。所以在成功执行前,组件无法交互。

2. js依赖包过重

由于是在客户端渲染,那么依赖的js库都需要被打包,上面的例子只是一个组件。如果项目中用到了成百个组件,不敢想象打包后的产物得有多大。

3. 访问服务端资源不便

在浏览器上,无法直接获取服务器上的资源。所以我们需要通过RESTful或其它API来获取。这样很合理,但是确实不太便利。有的时候我们甚至需要在一个组件中发起很多次请求,并且请求数据之间有依赖(请求B的参数依赖请求A的结果)。这些请求直接影响页面的展示。会产生客户端-服务端请求瀑布

服务器组件如何解决问题

上述问题都是源于在客户端环境中渲染组件这个条件下。那如果把组件的渲染移到服务端,这些问题是否能解决?答案是完全可以。

1. 降低TTI

RSC在服务端执行渲染,发送到客户端的只是一段序列化的数据。叫RSC Payload。它的数据大概长这样:

3:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
5:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
1:D{"name":"","env":"Server"}
2:D{"name":"PostItem","env":"Server"}
4:["id","1","d"]
6:D{"name":"rQ","env":"Server"}
6:null
7:D{"name":"","env":"Server"}
0:["development",[["children","posts","children",["id","1","d"],[["id","1","d"],{"children":["__PAGE__",{}]}],[["id","1","d"],{"children":["__PAGE__",{},[["$L1","$L2"],null],null]},["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","posts","children","$4","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}],null],[null,["$6","$L7"]]]]]

什么是RSC Payload?

它是表示RSC树渲染结果的一段压缩文本内容。用于客户端React更新dom。它包含:

  1. 服务端组件的渲染结果及描述如何更新dom。
  2. 包含客户端组件的占位,以及客户端组件对应的js文件地址。
  3. 包含传递给客户端组件的props。

采用这种自定义格式的好处是它比较适合流式传输,每读一行便可以操作一次。

在之前的例子中,如果使用RSC的话,那客户端并不需要加载这些js。服务端渲染完后,发送RSC Payload到客户端,客户端只需解析并更新dom即可。最后得到如下html:

<div>
  <!-- 在服务端渲染出来的markdown html -->
</div>
2. js依赖大幅度减少

RSC组件的依赖并不需要在客户端执行,所以也不需要打包并发送到客户端。

3. 可以直接访问服务端

由于是在node server中运行,那么可以在RSC组件中直接访问服务器资源如文件、数据库…比如下面代码:

import db from 'db';

async function Note({id}) {
  const note = await db.notes.get(id);
  return <NoteWithMarkdown note={note} />;
}

同时,对于一个组件中可能会访问许多资源的情况。由于都是运行在服务端,所以不会产生客户端-服务端请求瀑布。只需要发起一次请求。

4. 自动的代码分割

这是RSC特性的另一个优势。客户端组件想要实现代码分割,需要借助于React.lazy。比如下面代码:

// *客户端组件*
import { lazy } from 'react';

const OldPhotoRenderer = lazy(() => import('./OldPhotoRenderer.js'));
const NewPhotoRenderer = lazy(() => import('./NewPhotoRenderer.js'));

function Photo(props) {
  // 根据flag判断使用哪个Renderer组件
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />; 
  } else {
    return <OldPhotoRenderer {...props} />;
  }
}

例子展示了,在运行时,根据当前flag来使用新的或者老的Renderer组件,懒加载组件代码并执行渲染。

在RSC中,对于导入的客户端组件,React已经实现了自动的代码分割。只有浏览器需要执行这些组件时,对应的组件js才会被加载。

// *服务端组件*
import OldPhotoRenderer from './OldPhotoRenderer.js'; // RCC
import NewPhotoRenderer from './NewPhotoRenderer.js'; // RCC

function Photo(props) {
  // 可以稍后切换flag,比如用户登录、状态变更。
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />;
  } else {
    return <OldPhotoRenderer {...props} />;
  }
}

可以看到写出的代码更加自然。每个renderer组件都是客户端组件,在RSC中被导入,它们被自动代码分割了。只有flag对应的renderer组件被使用到时,浏览器才会去加载该组件的代码块。

使用RSC

使用Async Components

RSC使用async/await来编写组件代码。如果在组件内部使用await,React会中断渲染(Suspend。如果配合<Suspense />组件可实现流式传输),并等待Promise被解决后继续向下执行。

import db from 'db';

async function Note({id}) {
  // 这一句会中断渲染并等待,resolve后继续执行
  const note = await db.notes.get(id);
  // 这一句不会中断,不等待
  const commentsPromise = db.comments.get(note.id);
  return <NoteWithMarkdown note={note} />;
}
进阶例子

下面这个例子是一个展示笔记详情的页面。看看它们分别用RCC组件和RSC组件是怎么写的。

// *客户端组件*
function Note({id}) {
  const [note, setNote] = useState(null);
  // 获取note数据
  useEffect(() => {
    fetch(`/api/notes/${id}`).then(data => {
      setNote(data.note);
    });
  }, [id]);
  
  return (
    <div>
      {/* note数据获取后才执行Author组件 */}
      {note && <Author id={note.authorId} />}
      <p>{note.content || '空'}</p>
    </div>
  );
}

function Author({id}) {
  const [author, setAuthor] = useState('');
  // 再次请求author数据
  useEffect(() => {
    fetch(`/api/authors/${id}`).then(data => {
      setAuthor(data.author);
    });
  }, [id]);

  return <span>作者: {author.name}</span>;
}

上面的是使用客户端组件的前端代码写法。当然还需要后端提供两个接口:“/api/notes/:id"和”/api/authors/:id"。

// *后端api*
import db from './database';

app.get(`/api/notes/:id`, async (req, res) => {
  const note = await db.notes.get(id);
  res.send({note});
});

app.get(`/api/authors/:id`, async (req, res) => {
  const author = await db.authors.get(id);
  res.send({author});
});

如果我们使用服务端组件,那代码可以大幅简化:

// *服务端组件*
import db from './database';

async function Note({id}) {
  // 立刻获取note数据
  const note = await db.notes.get(id);
  return (
    <div>
      <Author id={note.authorId} />
      <p>{note.content}</p>
    </div>
  );
}

async function Author({id}) {
  // 立刻获取author数据
  const author = await db.authors.get(id);
  return <span>作者: {author.name}</span>;
}
添加交互

由于服务端组件不会被发送到浏览器上,所以它不能访问浏览器API,也不能使用交互相关的React APIs。比如useStateuseEffect等。那如果要在RSC中为组件添加交互怎么办呢?那就是组合使用RSC和RCC。此时需要使用"use client"指令来显式的声明某个组件为RCC。并在RSC中导入RCC组件。

// *服务端组件*
import Expandable from './Expandable';

export default async function Notes() {
  const notes = await db.notes.getAll();
  return (
    <div key="Note">
      {notes.map(note => (
        <Expandable key={note.id}>
          <p>{note.title}</p>
        </Expandable>
      ))}
    </div>
  )
}
// *客户端组件*
"use client"
import { useState } from "react";

export default function Expandable({children}) {
  const [expanded, setExpanded] = useState(false);
  return (
    <div>
      <button onClick={() => setExpanded(!expanded)}>
        Toggle
      </button>
      {expanded && children}
    </div>
  )
}

该示例展示了一个RSC组件<Notes />,在服务端获取一组笔记数据notes,然后在组件内部使用了RCC组件<Expandable /><Expandable />组件是可交互的,它的功能是点击后展开/收缩笔记数据。最后在客户端渲染出来的组件+dom结构如下:

<div key="Note">
  <Expandable key="1">
    <button />
    <p>...</p>
  </Expandable>
  <Expandable key="2">
    <button />
    <p>...</p>
  </Expandable>
  <!-- ... -->
</div>

由于RSC并不会被发送到客户端,所以客户端的组件树(可以用React Developer Tools查看)里看不到<Notes />组件,但是<Expandable />组件是可以看到的。<Expandable />可以交互。

RCC下嵌入子RSC

到目前为止,客户端组件还不能导入服务端组件。一旦定义了某组件为RCC。那么从这个入口开始,其下的组件树都将被视为客户端组件。比如如下的代码:

// share.client.jsx
// *客户端组件*
"use client";
import { Suspense, use, useState } from "react";
import Loading from "./loading";
import ShareSocial from "./sharesocial.server"; // 导入RSC
// 这个组件是交互的,所以定义为RCC
export default function ShareView() {
  const [showShare, setShowShare] = useState(false);
  const clickShare = () => {
    setShowShare(true);
  };
  return (
    <>
      <button onClick={clickShare}>Share</button>
      {showShare && (
        {/* 分享弹窗 */}
        <div className="fixed inset-0 bg-black bg-opacity-50">
          <Suspense fallback={<Loading />}>
            {/* 分享的社交平台 */}
            <ShareSocial shareText={shareText} />
          </Suspense>
          <button onClick={() => setShowShare(false)}>
            Close
          </button>
        </div>
      )}
    </>
  );
}

这个组件展示了一个分享功能。点击分享按钮打开分享弹窗。因为是可交互的,所以定义为客户端组件。此外,分享平台需要从server获取,所以定义为RSC:

// sharesocial.server.jsx
// *服务端组件*
import db from 'db';

export default async function ShareSocial() {
  const list = await db.socials.getAll();
  return (
    <>
      <h2>Share to Friends</h2>
      <p>You can share to friends at:</p>
      <ul>
        {list.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </>
  );
}

分享平台定义为RSC,并从db获取平台列表。看起来设计很合理,RCC和RSC分工很明确!实际运行看看!

demo1

控制台直接报错:*async/await is not yet supported in Client Components, only Server Components.*这是因为<ShareSocial />组件被<ShareView />组件导入,而后者是RCC,前者故而也被当作RCC,代码最终被发送到浏览器上执行。而RCC是不支持async/await的。

我懵了,那应该怎么处理这种情况呢?

两种方法:一是还原以前的方式,不使用RSC,改为RCC并在useEffect中调用接口获取数据。二是将RSC作为props.children传递给RCC:

// postitem.server.jsx
// *服务端组件*
import db from 'db';
import ShareSocial from "./sharesocial.server"; // 
// PostItem组件是ShareView组件的父级
export default async function PostItem({ params }: { params: { id: string } }) {
  const post = await db.posts.get(id)

  return (
    <div>
      {/* ... */}

      <ShareView>
        {/* 作为children */}
        <ShareSocial />
      </ShareView>

      {/* ... */}
    </div>
  );
}

改为在<ShareView />的父级组件<PostItem />(RSC)中,导入<ShareSocial />,并作为children传递到<ShareView />中。

// share.client.jsx
// *客户端组件*
"use client";
// ...
// 增加children属性
export default function ShareView({ children }) {
  // ...
  return (
    <>
      {/* ... */}
      {showShare && (
        <div className="fixed inset-0 bg-black bg-opacity-50">
          {/* 用children代替组件 */}
          {children}

          {/* ... */}
        </div>
      )}
    </>
  );
}

<ShareView />稍加改造即可。RSC支持将组件作为props传递给RCC,也支持其它可以序列化的属性。至于不可序列化的属性比如function则不行

RSC的缺点

介绍了RSC的优势,也介绍了它如何使用。现在来说说它的缺点。我们可以看出来,在RSC+RCC这种组件架构下。我们书写组件的方式跟原来有所不同。原来我们更多的是考虑复用和业务逻辑分离,来设计组件结构。而现在,我们在考虑这两点的基础上,还需要额外考虑组件是否需要获取数据,是否需要交互。这会带来几个很麻烦的缺点:

  • 组件抽取的粒度变得很细。笔者自己体验后,感受就是,使用RSC后需要抽取的组件数量可能比原来多50%。

  • 组件设计的心智负担提高。那就是如果组件设计不合理,很容易出bug。比如在RSC里使用了useState、比如RCC导入RSC。

  • 需要显式声明"use client"客户端组件,很容易疏漏。旧项目就不用提了,那么多组件根本改不完…

  • 三方组件库需要适配。三方组件如果没适配添加"use client"指令。RCC中导入不会有什么问题。但RSC导入就会出问题:因为React也不知道组件是客户端专用的。

到目前为止的React版本,RSC和RCC的使用限制在编译阶段是没有任何错误提示的。导致很多问题只能在运行时发现。可以使用server-onlyclient-only来标识组件和js是客户端专用还是服务端专用,并且会在编译时给出提示。npm i server-only

总结一:RSC和RCC

前面介绍了RSC出现的背景和使用方法,现在来做一个总结:

  • 服务端组件的出现使任务分工更明确。客户端组件(RCC)专注于状态和交互,服务端组件(RSC)专注于数据获取。

  • RCC运行在客户端,拥有完整的交互性。所以可以使用状态管理useState、副作用useEffect、事件监听器和生命周期钩子等等。

  • RSC仅在服务端运行,它的代码和依赖都不会下载到客户端。它无法使用和客户端交互相关的React API。

  • 由于RSC运行在服务器上,它可以直接访问服务器资源。比如读取数据库和文件系统。

  • RSC需要和RCC结合使用。RSC负责在服务器获取数据,并传递给RCC。RCC负责为UI添加交互性。

  • RSC中导入的RCC默认是自动Code-Spliting的,所以浏览器只会加载真正需要的RCC组件的代码。

  • RSC组件中的await是会中断渲染的。如果不结合Suspense,那服务器响应会延迟,等待Promise解决。如果结合Suspense,那么响应不会等待,会先返回fallback内容。等到Promise解决后再流式传输到客户端。

RSC对比SSR

我们再来讨论另一个问题,这可能也是很多同学会疑惑的:那就是React已经提供了SSR能力,可以在服务器上渲染组件了,为什么又推出了RSC?这两者之间又有什么异同呢?

回顾一下,SSR是一种服务端渲染技术,服务器收到某个请求后,使用React在服务端渲染页面并输出html内容发送到客户端,客户端需要执行水合后接管。SSR的目标是SEO优化和加速首屏渲染。

比较一下异同:

  • 相同点就是,它们都运行在服务端。

  • RSC在服务端渲染组件;SSR在服务端渲染整个路由页面。

  • RSC被渲染成特殊的序列化数据,发送到客户端来构建UI;SSR渲染生成静态HTML标记,发送到客户端展示。

  • RSC不需要水合,因为它不运行在客户端;SSR必须要水合,否则组件无法交互。

这个比较主要是为了让我们清晰这两者的功能边界。实际上,RSC和SSR不是互斥的技术,并不是用了SSR就不能用RSC。它们并不对立,反而是互补的

当两者结合使用时,SSR仍然是负责渲染整个路由的组件树为html,当然组件树包含了RSC和RCC,它们都可以在SSR过程被渲染成html。而RSC除了在SSR阶段起作用,在客户端接管后仍然起作用。

Next.js下RSC的工作流程

下面所讲的内容基于Next.js之类的全栈框架。这是因为想要不借助框架,直接使用React实现RSC+SSR还是很困难的。现在分析下Next.js下的工作流程见识一下难度系数。

RSC在SSR流程下,以及在客户端接管后,工作流程是不同的,分情况来分析:

SSR流程下

客户端发起一个页面请求。比如在浏览器里,输入一个页面地址。

在服务端上:

  1. 当请求到达服务端,匹配到了路由,将从路由页面的根组件开始。(我们只考虑根组件为RSC的情况)。路由参数通过props传递给根组件。

  2. React从根RSC开始向下深度遍历,并渲染树上所有RSC,生成RSC Payload数据。这个渲染过程仅限于RSC。遇到RCC组件,不会向下遍历:Host组件(如<div>)会被包含在RSC Payload中;RCC组件则会生成chunk,并存储props和chunk引用也放到RSC Payload中。

  3. Next.js开始SSR渲染。根据RSC Payload和RCC代码渲染出来初始HTML。和原来一样,该初始HTML不包含交互,并支持Suspense和流式渲染。不同的是,生成的HTML里被注入了RSC Payload,在<script>标签内。如图:

demo2

在客户端上:

  1. 浏览器上接收到初始HTML并立即显示。此时还不可交互。

  2. 客户端React开始运行,从window上读取RSC Payload。有了这些数据,React就可以进行重建、协调和水合。

  3. 浏览器加载RCC对应的js chunks。并开始重建客户端组件树并开始水合,使整个页面变得可交互。

当然,这整个过程中流式HTML + 选择性水合还是生效的。

客户端接管后

当客户端React接管了交互后。路由跳转也是在客户端上处理。不会发起全新的请求。

在客户端上:

  1. 点击一个Link,跳转到一个新路由。Next.js发起一个rsc数据请求*(这个请求以?__rsc=xxx结尾)*。

在服务端上:

  1. Next.js接收rsc请求,并匹配对应的RSC组件。

  2. React从该RSC开始,渲染RSC树,并生成RSC Payload

  3. Next.js流式输出RSC Payload到rsc请求的reponse里。

demo3

在客户端上:

  1. Next.js接收到流式response后,触发一次路由的重新渲染。

  2. React根据最新的RSC Payload。加载RCC对应的js chunks。并与已存在的组件树进行协调。重新渲染完成后页面更新即完成。

从这里也可以看出来,React选用RSC Payload,而不是直接使用HTML标记,来描述UI的另一个好处就是,它可以跟旧组件树很方便的协调。

总结二:RSC和SSR

前面介绍了SSR和RSC的功能重心,以及两者结合下的工作流程。也来做一个总结:

  • SSR是对整个路由页面的渲染,它生成初始html以在客户端快速展示。它的目标是降低首屏渲染。一旦页面呈现,它的职责就结束了。

  • RSC使组件可以在服务端渲染,它的粒度更细。它可以运用在SSR流程中,也可以在SSR流程结束后,客户端接管时起作用。生命周期长。

  • 这两种情况下RSC的工作流程不太一样。

  • RSC被React渲染成一种叫RSC Payload的数据格式。这个数据格式旨在帮助客户端React重建、协调和水合组件树。

其它

到目前为止,想要在React项目中自己实现RSC+SSR还是很难的。从Next13开始的App Router已经默认支持RSC。感兴趣的小伙伴可以使用App Router开始新项目,来体验RSC带来的全栈体验吧。

后续可能补充的知识点:RSC Payload数据详解结合Server ActionsNext.js中的组件缓存、等等。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值