LWDW!

Learn the work from doing the work🍺

【译】so, why server components / server components有啥好?
Posted on 2023-07-04

译者:Server components是个有趣的概念,目前React将其作为未来发展的重点方向,且Next13也将其作为App Router的默认的方案,所以了解一下还是很有必要的。但它又是个很容易混淆的概念,比如我一开始以为这只是SSR换了个皮?后来则发现完全不是!

本篇译文会从传统的React Client Components讲起,从它的局限引申出Server components所要解决的问题,让你对Server components的原理与优势有一个清晰的认知。

下面开始正文,原文地址:So, why Server Components。(请务必看到最后,我还会做一些补充。)


为什么我们需要Server Components

好了,接下来我会聊聊把组件放到服务端会发生什么——

(举手🙋)呃呃请等下,是原来的组件不够好吗?为啥要提出这种大胆的想法?

嘛嘛,冷静,事实上客户端组件非常适合用于实现动态内容,或者强交互内容。但正所谓人无完人,传统的客户端组件也有其局限。

客户端组件的局限

1. 过长的TTL(Time to Interactive/页面从展示到其可交互所花的时间)

让我们来复习一下客户端渲染配合SSR是如何工作的。

  1. 客户端向服务端请求页面。
  2. 服务端打包JS,并生成一个HTML。
  3. 服务端将JS包与HTML返回给客户端。
  4. 客户端渲染HTML。
  5. 客户端加载、解析、执行JS。
  6. 客户端进行hydrate,页面变得可以交互。

image.png

红色的横线即TTL所需要的时间。显然,JS包越大,客户端就要花更多的时间去加载、解析、执行它。

2. 依赖越多,JS越臃肿

显然,依赖越多,那JS包就会越大,客户端就要花更多的时间去处理它。让我们看看代码;

这段代码来自React Server Component demo Notes app

import {format, isToday} from 'date-fns';
import excerpts from 'excerpts';
import marked from 'marked';

import ClientSidebarNote from './SidebarNote.client';

export default function SidebarNote({note}) {
  const updatedAt = new Date(note.updated_at);
  const lastUpdatedAt = isToday(updatedAt)
    ? format(updatedAt, 'h:mm bb')
    : format(updatedAt, 'M/d/yy');
  const summary = excerpts(marked(note.body), {words: 20});
  return (
    <ClientSidebarNote
      id={note.id}
      title={note.title}
      expandedChildren={
        <p className="sidebar-note-excerpt">{summary || <i>(No content)</i>}</p>
      }>
      <header className="sidebar-note-header">
        <strong>{note.title}</strong>
        <small>{lastUpdatedAt}</small>
      </header>
    </ClientSidebarNote>
  );
}

这个组件主要就是处理了日期以及markdown的显示。

我们可以看到,组件用了三个依赖来实现这些功能:date-fnsexcerptsmarked。它们的大小是gzip压缩过后80KB。由于客户端组件引入了各种类似的依赖,你的JS包肯定会越来越大。

image.png

3. 陷入请求瀑布

以上两点可能给你这样一种错觉:如果JS执行了,那我的页面应该是正常的,有意义的。

但事实上,经常有这种情况,JS执行了,结果用户看到的还是这样的页面:

image.png

这是咋回事呢?有可能页面是这样的:

import React from 'react';
import Spinner from 'components/Spinner';
import Something from 'components/Something';

const ChildComponentA = () => <Something />

const ChildComponentB = () => {
  // 4. 结果这里还需要获取dataC
  const dataC = useDataC();

  return dataC
    ? <Something />
    : <Spinner />
}

const Root = () => {
  const dataA = useDataA(); // 1. 首先获取dataA
  const dataB = useDataB({ skip: !dataA }); // 2. dataA存在后,才会获取dataB

  if (!dataA || !dataB) {
    return <Spinner />
  }

  // 3. 终于有了dataA与dataB,开始渲染东西了
  return (
      <>
        <ChildComponentA data={dataB} />
        <Something />
        <ChildComponentB data={dataB} />
    </>
  )
}

看看上面的代码,我们可以看出这个组件需要串行请求dataA、dataB、dataC。所以这个用户等待的时间真的就是长的没边了,大概是这样的:

image.png

很显然这种情形并不理想,因为客户端向同一个服务端发了三个请求,然后进行长长的等待。最耗时的部分其实不是服务端的内部事务处理(数据库通信、微服务调用、访问文件系统之类的),而是往返客户端与服务端之间的路程。三次串行请求就等于要在两端往返三次。

4. 浏览器只有一个线程

众所周知,浏览器的JS是单线程运行的。这也就意味着,我们提到的所有操作都发生并堆积在单个调用栈内。

5. 浏览器无法访问服务端API

显然,浏览器可以访问浏览器API,比如操作DOM节点,调用fetch API,Canvas/WebGL之类的。而服务端可以访问环境变量、文件系统、数据库之类的,而客户端就只能通过接口访问这些数据了。

当然,我们之所以通过REST/GraphQL接口去访问后端资源,还是为了系统的安全性。但有时候,如果开发者可以对系统多一些控制,开发体验就上来了。比如开发者只能在Next.js的服务端代码中访问环境变量,如果你想要在客户端用到环境变量,Next可以使用NEXT_PUBLIC前缀来解决,但我们仍然需要注意不要将敏感的信息暴露到客户端。

如果能够在客户端直接访问环境变量,又不用担心各种限制,是不是也挺好?

服务端组件来了!

在上面的内容中,我们探讨了React客户端组件的局限。这些局限可以说是客户端环境本身决定的。

所以让我们重新审视这份列表,看看服务端组件是如何解除这些限制并带来各种可能性。

1. 长时间的TTL

将组件放在服务端渲染的一大好处就是,组件JS包不再需要传输到客户端了。比如我们有1000个组件,有500个组件放在了服务端,那么最后需要传送到客户端的JS包肯定会小很多。JS包小了,那客户端页面的初始化速度也就快了。

服务端组件不会被包括在JS包中,它们会在服务端被渲染好,并序列化为React官方定制的字符串格式。于是React@18/Next.js@13就可以通过网络将这些字符串发送到客户端,客户端的React就可以根据这些字符串进行组件树的更新。

让我们看一个组件更新的实际例子,来自React Server Components demo Notes application

M1:{"id":"./src/SearchField.client.js","chunks":["client5"],"name":""}
M2:{"id":"./src/EditButton.client.js","chunks":["client1"],"name":""}
S3:"react.suspense"
// J0
J0:["$","div",null,{"className":"main","children":[["$","section",null,{"className":"col sidebar","children":[["$","section",null,{"className":"sidebar-header","children":[["$","img",null,{"className":"logo","src":"logo.svg","width":"22px","height":"20px","alt":"","role":"presentation"}],["$","strong",null,{"children":"React Notes"}]]}],["$","section",null,{"className":"sidebar-menu","role":"menubar","children":[["$","@1",null,{}],["$","@2",null,{"noteId":null,"children":"New"}]]}],["$","nav",null,{"children":["$","$3",null,{"fallback":["$","div",null,{"children":["$","ul",null,{"className":"notes-list skeleton-container","children":[["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}]]}]}],"children":"@4"}]}]]}],["$","section","null",{"className":"col note-viewer","children":["$","$3",null,{"fallback":["$","div",null,{"className":"note skeleton-container","role":"progressbar","aria-busy":"true","children":[["$","div",null,{"className":"note-header","children":[["$","div",null,{"className":"note-title skeleton","style":{"height":"3rem","width":"65%","marginInline":"12px 1em"}}],["$","div",null,{"className":"skeleton skeleton--button","style":{"width":"8em","height":"2.5em"}}]]}],["$","div",null,{"className":"note-preview","children":[["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}]]}]]}],"children":["$","div",null,{"className":"note--empty-state","children":["$","span",null,{"className":"note-text--empty-state","children":"Click a note on the left to view something! 🥺"}]}]}]}]]}]
// J0结束
M5:{"id":"./src/SidebarNote.client.js","chunks":["client6"],"name":""}
// J4
J4:["$","ul",null,{"className":"notes-list","children":[["$","li","1",{"children":["$","@5",null,{"id":1,"title":"Meeting Notes","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"This is an example note. It contains Markdown!"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Meeting Notes"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","2",{"children":["$","@5",null,{"id":2,"title":"A note with a very long title because sometimes you need more words","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"You can write all kinds of amazing notes in this app! These note live on the server in the notes..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"A note with a very long title because sometimes you need more words"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","3",{"children":["$","@5",null,{"id":3,"title":"I wrote this note today","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It was an excellent note."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"I wrote this note today"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","4",{"children":["$","@5",null,{"id":4,"title":"Make a thing","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It's very easy to make some words bold and other words italic with Markdown. You can even link to React's..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Make a thing"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","6",{"children":["$","@5",null,{"id":6,"title":"Test Noteeeeeeeasd","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"Test note's text"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Test Noteeeeeeeasd"}],["$","small",null,{"children":"11/29/22"}]]}]}]}],["$","li","7",{"children":["$","@5",null,{"id":7,"title":"asdasdasd","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"asdasdasd"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"asdasdasd"}],["$","small",null,{"children":"11/29/22"}]]}]}]}]]}]
// J4结束

这一大坨简直没法看是不是!事实上这份类似JSON的文本就可以指导客户端的React去更新应用状态,让我们来仔细看看。

首先看看比较短的两行:

M1:{"id":"./src/SearchField.client.js","chunks":["client5"],"name":""}
M2:{"id":"./src/EditButton.client.js","chunks":["client1"],"name":""}
M5:{"id":"./src/SidebarNote.client.js","chunks":["client6"],"name":""}

第一行(M1)其实就是指导React运行时渲染SearchField组件,而这个组件位于叫做client5的文件中。换句话来说,它指向了客户端组件位于JS包中的位置。既然这是个客户端组件,此时它还尚未渲染。

React Suspense边界是这样定义的,很直接:

S3:"react.suspense"

下面重点来了,J0J4这两行,包含了非常多的内容:

// J0
J0:["$","div",null,{"className":"main","children":[["$","section",null,{"className":"col sidebar","children":[["$","section",null,{"className":"sidebar-header","children":[["$","img",null,{"className":"logo","src":"logo.svg","width":"22px","height":"20px","alt":"","role":"presentation"}],["$","strong",null,{"children":"React Notes"}]]}],["$","section",null,{"className":"sidebar-menu","role":"menubar","children":[["$","@1",null,{}],["$","@2",null,{"noteId":null,"children":"New"}]]}],["$","nav",null,{"children":["$","$3",null,{"fallback":["$","div",null,{"children":["$","ul",null,{"className":"notes-list skeleton-container","children":[["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}]]}]}],"children":"@4"}]}]]}],["$","section","null",{"className":"col note-viewer","children":["$","$3",null,{"fallback":["$","div",null,{"className":"note skeleton-container","role":"progressbar","aria-busy":"true","children":[["$","div",null,{"className":"note-header","children":[["$","div",null,{"className":"note-title skeleton","style":{"height":"3rem","width":"65%","marginInline":"12px 1em"}}],["$","div",null,{"className":"skeleton skeleton--button","style":{"width":"8em","height":"2.5em"}}]]}],["$","div",null,{"className":"note-preview","children":[["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}]]}]]}],"children":["$","div",null,{"className":"note--empty-state","children":["$","span",null,{"className":"note-text--empty-state","children":"Click a note on the left to view something! 🥺"}]}]}]}]]}]
// J0结束
// J4
J4:["$","ul",null,{"className":"notes-list","children":[["$","li","1",{"children":["$","@5",null,{"id":1,"title":"Meeting Notes","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"This is an example note. It contains Markdown!"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Meeting Notes"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","2",{"children":["$","@5",null,{"id":2,"title":"A note with a very long title because sometimes you need more words","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"You can write all kinds of amazing notes in this app! These note live on the server in the notes..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"A note with a very long title because sometimes you need more words"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","3",{"children":["$","@5",null,{"id":3,"title":"I wrote this note today","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It was an excellent note."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"I wrote this note today"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","4",{"children":["$","@5",null,{"id":4,"title":"Make a thing","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It's very easy to make some words bold and other words italic with Markdown. You can even link to React's..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Make a thing"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","6",{"children":["$","@5",null,{"id":6,"title":"Test Noteeeeeeeasd","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"Test note's text"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Test Noteeeeeeeasd"}],["$","small",null,{"children":"11/29/22"}]]}]}]}],["$","li","7",{"children":["$","@5",null,{"id":7,"title":"asdasdasd","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"asdasdasd"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"asdasdasd"}],["$","small",null,{"children":"11/29/22"}]]}]}]}]]}]
// J4结束

J0J4是两个我们熟知的基本组件divul。它们已经在服务端被渲染好了,然后序列化成了我们看到的这种字符串,最后客户端就可以将其转换为视图。牛的地方就在于,客户端不需要做什么实际的工作,它只要解析字符串并做更新就好了。

于是,服务端和客户端的通信就变成了这样:

image.png

绿色框框正是RSC带来的改变,相比于之前:

  • 只有客户端组件被打包到JS包。
  • 服务端将服务端组件渲染并序列为定制字符串,而不是HTML。
  • 在后续的服务端组件更新中,序列化字符串会被发送到客户端。
  • 客户端可以根据这些字符串进行更新并维护状态。

总之,JS包变小了,TTL也就变短了。

2. 依赖不再增加JS包的体积

服务端组件的另一大好处就是它可以做到“0体积”。既然组件是在服务端渲染,那我们就不需要在客户端引入相关的依赖。

从实现的角度来讲,当服务端的React@18处理一个服务端组件的时候,它会将其处理为最终在客户端呈现的基本组件。比如你的React组件用到了date-fns/format去处理日期,这些处理都发生在服务端,最终发送到客户端的已经是最后的结果,因此date-fns/format依赖在客户端是不需要的。

所以,在RSC的世界中,客户端可以和庞大的依赖说再见了。

还记得这张图么?这些依赖都不占JS体积了!

image.png

3. 请求瀑布流不再往往复复

服务端组件相比于客户端组件更贴近服务端硬件。这也就意味着,原先需要从客户端发送到服务端的请求,现在可以完全在服务端内完成。让我们更新一下之前的图:

image.png

显然,客户端现在只需要往返服务端一次,而服务端组件间的请求都发生在服务端内部,相比于之前,这可快太多了!

4. 服务端不止一个线程

这也很显然,浏览器的JS运行时只有一个线程,而服务端的Node.js往往有多个线程,那么就有更多的带宽可以处理服务端组件了!

5. 服务端可以访问服务端API(废话)

当服务端处理服务端组件时,我们可以直接访问很多东西,比如环境变量,因此我们可以在代码里直接使用process.env,这还挺爽的不是么?

不仅如此,我们还可以直接访问本地数据库、文件系统、微服务。当然,直接在组件中访问数据库不是什么最佳实践,我只是举个例子ww。换说一个实用的例子吧:在实现一个静态博客的时候,我们可以在组件中直接循环文件目录,过去可是没法这么干的。

总结

在本文中,我们提出了客户端组件的5个痛点。服务端组件给我们展现了一种全新的视角,并提出了一些解决方案。

希望本文可以让你大致理解

  1. 为啥React@18要转向服务端组件。
  2. 为啥Next@13要将服务端组件(搭配App Router)作为默认配置。
  3. 以及总的来说,未来一段时间内,服务端组件生态的可能发展方向。

译者:那么,译文本体就结束了,这篇文章可以说是讲了一大通RSC的优势,但如果它真的那么厉害,为啥现在我们仍然罕见RSC的应用呢?百闻不如一试,现在体验RSC有几个很好的demo:

在体验demo的时候,可以考虑这几个问题:

  1. RSC在实现的时候,相比客户端组件有哪些限制?
  2. 怎样触发RSC的组件状态更新?
  3. RSC在哪些场景下比较适用?
  4. 相比于SSR+客户端组件,引入了RSC后,到底有多少的改变?

译者对于这些问题其实也没有很好的答案,但如果你试一试,一定会发现RSC的实际应用比这篇文章更加有趣!

最后推荐阅读几篇相关文章: