精读《React Server Component》

1,988 阅读10分钟

截止目前,React Server Component 还在开发与研究中,因此不适合投入生产环境使用。但其概念非常有趣,值得技术人学习。

目前除了国内各种博客、知乎解读外,最一手的学习资料有下面两处:

  1. Dan 的 Server Component 介绍视频
  2. Server Component RFC 草案

我会结合这些一手资料,与一些业界大牛的解读,系统的讲清楚 React Server Component 的概念,以及我对它的一些理解。

首先我们来看,为什么需要提出 Server Component 这个概念:

Server Component 概念的提出,是为了解决 "用户体验、可维护性、性能" 这个不可能三角,所谓不可能三角就是,最多同时满足两条,而无法三条都同时满足。

简单解释一下,用户体验体现在页面更快的响应、可维护性体现在代码应该高内聚低耦合、性能体现在请求速度。

  • 保障 用户体验、可维护性,用一个请求拉取全部数据,所有组件一次性渲染。但当模块不断增多,无用模块信息不敢随意删除,请求会越来越大,越来越冗余,导致瓶颈卡在取数这块,也就是 性能不好
  • 保障 用户体验、性能,考虑并行取数,之后流程不变,那么以后业务逻辑新增或减少一个模块,我们就要同时修改并行取数公共逻辑与对应业务模块,可维护性不好
  • 保障 可维护性、性能,可以每个模块独立取数,但在父级渲染完才渲染子元素的情况下,父子取数就变成了串行,页面加载被阻塞,用户体验不好

一言蔽之,在前后端解耦的模式下,唯一连接的桥梁就是取数请求。要把用户体验做好,取数就要提前并行发起,而前端模块是独立维护的,所以在前端做取数聚合这件事,必然会破坏前端可维护性,而这并行这件事放在后端的话,会因为后端不能解析前端模块,导致给出的聚合信息滞后,久而久之变得冗余。

要解决这个问题,就必须加深前端与后端的联系,所以像 GraphQL 这种前后端约定方案是可行的,但因为其部署成本高,收益又仅在前端,所以难以在后端推广。

Server Component 是另一种方案,通过启动一个 Node 服务辅助前端,但做的不是 API 对接,而是运行前端同构 js 代码,直接解析前端渲染模块,从中自动提取请求并在 Node 端直接与服务器通信,因为服务端间通信成本极低、前端代码又不需要做调整,请求数据也是动态按需聚合的,因此同时解决了 "用户体验、可维护性、性能" 这三个问题。

其核心改进点如下图所示:

如上图所示,这是前后端正常交互模式,可以看到,RootChild 串行发了两个请求,因为网络耗时与串行都是严重阻塞部分,因此用红线标记。

Server Component 可以理解为下图,不仅减少了一次网络损耗,请求也变成了并行,请求返回结果也从纯数据变成了一个同时描述 UI DSL 与数据的特殊结构:

到此,恭喜你已经理解了 Server Component 核心概念,如果你只想泛泛了解一下,读到这里就可以结束了。如果你还想深入了解其实现细节,请继续阅读。

概述

概括的说,Server Component 就是让组件拥有在服务端渲染的能力,从而解决不可能三角问题。也正因为这个特性,使得 Server Component 拥有几种让人眼前一亮的特性,都是纯客户端组件所不具备的:

  • 运行在服务端的组件只会返回 DSL 信息,而不包含其他任何依赖,因此 Server Component 的所有依赖 npm 包都不会被打包到客户端。
  • 可以访问服务端任何 API,也就是让组件拥有了 Nodejs 能拥有的能力,你理论上可以在前端组件里干任何服务端才能干的事情。
  • Server Component 与 Client Component 无缝集成,可以通过 Server Component 无缝调用 Client Component。
  • Server Component 会按需返回信息,在当前逻辑下,走不到的分支逻辑的所有引用都不会被客户端引入。比如 Server Component 虽然引用了一个巨大的 npm 包,但某个分支下没有用到这个包提供的函数,那客户端也不会下载这个巨大的 npm 包到本地。
  • 由于返回的不是 HTML,而是一个 DSL,所以服务端组件即便重新拉取,已经产生的 State 也会被维持住。比如说 A 是 ServerComponent,其子元素 B 是 Client Component,此时对 B 组件做了状态修改比如输入一些文字,此时触发 A 重新拉取 DSL 后,B 已经输入的文字还会保留。
  • 可以无缝与 Suspense 结合,并不会因为网络原因导致连 Suspense 的 loading 都不能及时展示。
  • 共享组件可以同时在服务端与客户端运行

三种组件

Server Component 将组件分为三种:Server Component、Client Component、Shared Component,分别以 .server.js.client.js.js 后缀结尾。

其中 .client.js 与普通组件一样,但 .server.js.js 都可能在服务端运行,其中:

  • .server.js 必然在服务端执行。
  • .js 在哪执行要看谁调用它,如果是 .server.js 调用则在服务端执行,如果是 .client.js 调用则在客户端执行,因此其本质还要接收服务端组件的约束。

下面是 RFC 中展示的 Server Component 例子:

// Note.server.js - Server Component
import db from 'db.server'; 
// (A1) We import from NoteEditor.client.js - a Client Component.
import NoteEditor from 'NoteEditor.client';

function Note(props) {
  const {id, isEditing} = props;
  // (B) Can directly access server data sources during render, e.g. databases
  const note = db.posts.get(id);
  
  return (
    <div>
      <h1>{note.title}</h1>
      <section>{note.body}</section>
      {/* (A2) Dynamically render the editor only if necessary */}
      {isEditing 
        ? <NoteEditor note={note} />
        : null
      }
    </div>
  );
}

可以看到,这就是 Node 与 React 混合语法。服务端组件有着苛刻的限制条件:不能有状态,且 props 必须能被序列化

很容易理解,因为服务端组件要被传输到客户端,就必须经过序列化、反序列化的过程,JSX 是可以被序列化的,props 也必须遵循这个规则。另外服务端不能帮客户端存储状态,因此服务端组件不能用任何 useState 等状态相关 API。

但这两个问题都可以绕过去,即将状态转化为组件的 props 入参,由 .client.js 存储,见下图:

或者利用 Server Component 与 Client Component 无缝集成的能力,将状态与无法序列化的 props 参数都放在 Client Component,由 Server Component 调用。

优点

零客户端体积

这句话听起来有点夸张,但其实在 Server Component 限定条件下还真的是。看下面代码:

// NoteWithMarkdown.js
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
  const html = sanitizeHtml(marked(text));
  return (/* render */);
}

markedsanitize-html 都不会被下载到本地,所以如果只有这一个文件传输,客户端的理论增加体积就是 render 函数序列化后字符串大小,可能不到 1KB。

当然这背后也是限制换来的,首先这个组件没有状态,无法在客户端实时执行,而且在服务端运行也可能消耗额外计算资源,如果某些 npm 包计算复杂度较高的话。

这个好处可以理解为,marked 这个包仅在服务端读取到内存一次,以后只要后客户端想用,只需要在服务端执行 marked API 并把输出结果返回给客户端,而不需要客户端下载 marked 这个包了。

拥有完整服务端能力

由于 Server Component 在服务端执行,因此可以执行 Nodejs 的任何代码。

// Note.server.js - Server Component
import fs from 'react-fs';

function Note({id}) {
  const note = JSON.parse(fs.readFile(`${id}.json`));
  return <NoteWithMarkdown note={note} />;
}

我们可以把对请求的理解拔高一个层次,即 request 只是客户端发起的一个 Http 请求,其本质是访问一个资源,在服务端就是个 IO 行为。对于 IO,我们还可以通过 file 文件系统写入删除资源、db 通过 sql 语法直接访问数据库,或者 request 直接在服务器本地发出请求。

运行时 Code Split

我们都知道 webpack 可以通过静态分析,将没有使用到的 import 移出打包,而 Server Component 可以在运行时动态分析,将当前分支逻辑下没有用到的 import 移出打包:

// PhotoRenderer.js
import React from 'react';

// one of these will start loading *once rendered and streamed to the client*:
import OldPhotoRenderer from './OldPhotoRenderer.client.js';
import NewPhotoRenderer from './NewPhotoRenderer.client.js';

function Photo(props) {
  // Switch on feature flags, logged in/out, type of content, etc:
  if (props.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />;
  } else {
    return <OldPhotoRenderer {...props} />;
  }
}

这是因为 Server Component 构建时会进行预打包,运行时就是一个动态的包分发器,完全可以通过当前运行状态比如 props.xxx 来区分当前运行到哪些分支逻辑,而没有运行到哪些分支逻辑,并且仅告诉客户端拉取当前运行到的分支逻辑的缺失包。

纯前端模式与之类似的写法是:

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

只是这种写法不够原生,且实际场景往往只有前端框架把路由自动包一层 Lazy Load,而普通代码里很少出现这种写法。

无客户端往返的数据端取数

一般考虑到取数网络消耗,我们往往会将其处理成异步,然后在数据返回前展示 Loading:

// Note.js

function Note(props) {
  const [note, setNote] = useState(null);
  useEffect(() => {
    // NOTE: loads *after* rendering, triggering waterfalls in children
    fetchNote(props.id).then(noteData => {
      setNote(noteData);
    });
  }, [props.id]);
  if (note == null) {
    return "Loading";
  } else {
    return (/* render note here... */);
  }
}

这是因为单页模式下,我们可以快速从 CDN 拿到这个 DOM 结构,但如果再等待取数,整体渲染就变慢了。而 Server Component 因为本身就在服务端执行,因此可以将拿 DOM 结构与取数同时进行:

// Note.server.js - Server Component

function Note(props) {
  // NOTE: loads *during* render, w low-latency data access on the server
  const note = db.notes.get(props.id);
  if (note == null) {
    // handle missing note
  }
  return (/* render note here... */);
}

当然这个前提是网络消耗敏感的情况,如果本身就是一个慢 SQL 查询,耗时几秒的情况下,这样做反而适得其反。

减少 Component 层次

看下面的例子:

// Note.server.js
// ...imports...

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

// NoteWithMarkdown.server.js
// ...imports...

function NoteWithMarkdown({note}) {
  const html = sanitizeHtml(marked(note.text));
  return <div ... />;
}

// client sees:
<div>
  <!-- markdown output here -->
</div>

虽然在组件层面抽象了 NoteNoteWithMarkdown 两个组件,但由于真正 DOM 内容实体只有一个简单的 div,所以在 Server Component 模式下,返回内容就会简化为这个 div,而无需包含那两个抽象的组件。

限制

Server Component 模式下有三种组件,分别是 Server Component、Client Component、Shared Component,其各自都有一些使用限制,如下:

Server Component

  • ❌ 不能用 useStateuseReducer 等状态存储 API。
  • ❌ 不能用 useEffect 等生命周期 API。
  • ❌ 不能用 window 等仅浏览器支持的 API。
  • ❌ 不能用包含了上面情况的自定义 Hooks。
  • ✅ 可无缝访问服务端数据、API。
  • ✅ 可渲染其他 Server/Client Component

Client Component

  • ❌ 不能引用 Server Component。
    • ✅ 但可以在 Server Component 中出现 Client Component 调用 Server Component 的情况,比如 <ClientTabBar><ServerTabContent /></ClientTabBar>
  • ❌ 不能调用服务端 API 获取数据。
  • ✅ 可以用一切 React 与浏览器完整能力。

Shared Component

  • ❌ 不能用 useStateuseReducer 等状态存储 API。
  • ❌ 不能用 useEffect 等生命周期 API。
  • ❌ 不能用 window 等仅浏览器支持的 API。
  • ❌ 不能用包含了上面情况的自定义 Hooks。
  • ❌ 不能引用 Server Component。
  • ❌ 不能调用服务端 API 获取数据。
  • ✅ 可以同时在服务器与客户端使用。

其实不难理解,因为 Shared Component 同时在服务器与客户端使用,因此兼具它们的劣势,带来的好处就是更强的复用性。

精读

要快速理解 Server Component,我觉得最好也是最快的方式,就是找到其与十年前 PHP + HTML 的区别。看下面代码:

$link = mysqli_connect('localhost', 'root', 'root');
mysql_select_db('test', $link);
$result = mysql_query('select * from table');

while($row=mysql_fetch_assoc($result)){
  echo "<span>".$row["id"]."</span>";
}

其实 PHP 早就是一套 "Server Component" 方案了,在服务端直接访问 DB、并返回给客户端 DOM 片段。

React Server Component 在折腾了这么久后,可以发现,最大的区别是将返回的 HTML 片段改为了 DSL 结构,这其实是浏览器端有一个强大的 React 框架在背后撑腰的结果。而这个带来的好处除了可以让我们在服务端能继续写 React 语法,而不用退化到 "PHP 语法" 以外,更重要的是组件状态得以维持。

另一个重要不同是,PHP 无法解析现在前端生态下任何 npm 包,所以无从解析模块化的前端代码,所以虽然直觉上感觉 PHP 效率与 Server Component 并无区别,但背后的成本是得写另一套不依赖任何 npm 包、JSX 的语法来返回 HTML 片段,Server Component 大部分特性都无法享受到,而且代码也无法复用。

所以,本质上还是 HTML 太简单了,无法适应如今前端的复杂度,而普通后端框架虽然后端能力强大,但在前端能力上还停留在 20 年前(直接返回 DOM),唯有 Node 中间层方案作为桥梁,才能较好的衔接现代后端代码与现代前端代码。

PHP VS Server Component

其实在 PHP 时代,前后端都可以做模块化。后端模块化显而易见,因为可以将后端代码模块化的开发,最后打包至服务器运行。前端也可以在服务端模块化开发,只要我们将前后端代码剥离出来即可,下图青色是后端部分,红色是前端部分:

但这有个问题,因为后端服务对浏览器来说是无状态的,所以后端模块化本身就符合其功能特征,但前端页面显示在用户浏览器,每次都通过路由跳转到新页面,显然不能最大程度发挥客户端持续运行的优势,我们希望在保持前端模块化的基础上,在浏览器端有一个持续运行的框架优化用户体验,因此 Server Component 其实做了下面的事情:

这样做有两大好处:

  1. 兼顾了 PHP 模式下优势,即前后端代码无缝混合,带来一系列体验和能力增强。
  2. 前后端还是各自模块化编写,图中红色部分是随前端项目整体打包的,因此开发还是保留了模块化特点,且在浏览器上还保持了 React 现代框架运行,无论是单页还是数据驱动等特性都能继续使用。

总结

Server Component 还没有成熟,但其理念还是很靠谱的。

想要同时实现 "用户体验、可维护性、性能",重后端,或者重前端的方案都不可行,只有在前后端取得一种平衡才能达到。Server Component 表达了一种职业发展理念,即未来前后端还是会走向全栈,这种全栈是前后端同时做深,从而让程序开发达到纯前端或纯后端无法达到的高度。

2021 年国内开发环境依然比较落后,所谓全栈,往往指的是 “前后端都懂一点”,各端都做不深,难以孵化出 Server Component 这种概念。当然,这也是我们继续向世界学习的动力。

也许 PHP 与 Server Component 的区别,就是检验一个人是真全栈还是伪全栈的试金石,快去问问你的同事吧!

讨论地址是:精读《React Server Component》· Issue #311 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证