声明:文章约3500字,阅读需要10分钟左右。
长期以来,web开发中有一个没有定论的问题,是 "瘦"客户端,还是“胖”客户端。这个问题放到 React 的生态中依然存在。React 主要以客户端为中心,对服务端的利用很少。在 2020 年底,React 团队共享了他们最近在这方面的研究: React Server Components。
怎么开始
资源
Introducing Zero-Bundle-Size React Server Components
官方Blog里提供了三个关于 React Server Components 的资源:
- 一个可以运行的 full stack demo 项目。项目可以直接在本地运行,但是需要进行一些数据库配置。如果有docker环境的话,建议在docker里运行,可以避免手动配置环境。
- RFC。React 团队 一直采用 RFC 的方式来帮助和指导 React 的设计。重大变更在经过社区彻底讨论后才会合并到 React 的稳定版本。
- 一个近一个小时的视频。 解释了为什么要开发 React Server Components 和 对上述 demo项目的演示。有精心翻译的中文字幕。
说明
与 React team 的做法一样,这里也推荐大家先去看这个视频,先对 React Server Components 有个整体的认识。当然直接看本文也是可以的。
现在 React Server Components 仍处于开发状态。暂时不适合深入使用,也不适合基于这个新特性去开发升级自己的框架。
基础认识
组件类型
- server component。不能包含客户端代码,如使用 DOM api、 useState、useEffect等。
- client component。现在大家所熟悉的普通组件。
- share component。既能作为 server component, 又能作为 client component,取决于引用该组件的组件。 通常是根据props 直接渲染的组件。
组件命名
- server component:扩展名
.server.js
- client component:扩展名
.client.js
- sharing component:扩展名
.js
这个命名约定不是最终的方案,只是目前快速开发原型时的简易策略。
组件引用
三种组件之间相互引用只有一个限制:客户端组件不能 import 服务端组件。其余情况下,都可以相互引用。
运行机制
官方例子
上图展示的是官方提供的demo的侧边栏。 包含 Header、SearchInput、NoteList 等组件。其中 红色为服务端组件,绿色为客户端组件。
看到这里,大家可能会困惑,这种交错嵌套的组件是怎么在不同环境下渲染并且拼接到一起的。 下面我们通过更简单的例子来解释。
简单的例子
为了便于解释,我们来看一个简单的例子。Test 是一个普通的 客户端组件,App 是服务端组件,App 组件中使用了 Test 组件。
// Test.client.js
export default function Test({text}) {
return <div className="client">{text}</div>
}
// App.server.js
import Test from "./Test.client"
export default function App() {
return <div className="main">
<Test text={"props to Test.client.js"} />
</div>
}
当向服务器请求整个组件时,服务器的响应如下:
M1:{"id":"./src/Test.client.js","chunks":["client7"],"name":""}
J0:["$","div",null,{"className":"main","children":["$","@1",null,{"text":"props to Test.client.js"}]}]
对比服务器的响应以及组件的编写形式,我们可以清楚地看到以下三点:
- M-id(M1): 表示的是对一个客户端组件的描述。其中 id 为该组件在项目中的路径,可以用来唯一标识这个组件; chunks 是 webpack 打包后的 chunk。
- J-id(J0): 表示服务端组件渲染后的结果。大家很容易注意到,这个形式与 React.createElement 返回的结果是高度吻合的。这当然不是巧合,因为这个 JSON 所描述的正是组件 Render 后的结果。
- 在
J0
对应的这段 JSON 中, 有一个标识@1
。这个标识是对客户端组件M1
(Text组件)的引用。{"text":"props to Test.client.js"}
是传递给 Text 组件的 props。
因此,我们可以得出一个不那么正式的总结:从根服务端组件开始,尽量渲染它能渲染的内容,当遇到原生组件(divs, spans等) 或者 客户端组件时,停止渲染。原生组件在客户端会被直接渲染成 DOM, 而客户端组件在客户端会以大家熟知的方式被解析渲染。 至此,一个完整的React 组件在客户端被完整拼接,从而渲染出一个完整的页面。
客户端组件中 "使用" 服务端组件
上述简单的例子解释了服务端组件中怎么使用客户端组件。但是通过官方例子的图(红色为服务端组件,绿色为客户端组件)我们可以看到,是有服务端组件被“**包围”**在客户端组件里的。
通过前文的 组件引用 小节我们知道服务端组件是不能被客户端组件直接 import 使用的,因为这会导致服务端代码泄漏以及发往客户端的 js bundle 变大。但是上述例子中却出现了这种情况,我们不妨从官方 demo 的代码中找一下答案。
代码如下,其中 ClientSidebarNote(绿框部分)因为要包含展开详情的交互,所以是客户端组件。 SiderBarNoteHeader(红框部分)只是简单的展示,因此被设计成了服务端组件。
// SiderbarNote.js
import ClientSidebarNote from './SidebarNote.client';
import SiderBarNoteHeader from './SiderbarNoteHeader'
export default function SidebarNote({ note }) {
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>
}>
<SiderBarNoteHeader note={note} />
</ClientSidebarNote>
);
}
通过代码我们可以看到,实际上客户端组件并没有直接 import 服务端组件,而是把服务端组件作为 客户端组件的 Children。
这里涉及到了一个很关键的点,Lauren Tan 在视频中也着重强调了这一点: 在服务端组件中使用 JSX作为传递给客户端组件的 props 时,这个 JSX会被在服务端渲染,然后再返回给客户端。如 expandedChildren
和 <SiderBarNoteHeader note={note} />
都会在服务端被渲染。
因此我们才可以看到上图中 客户端组件“包含”服务端组件的情况。
流协议
场景
考虑一个场景,当服务端某个接口被 block 时,我们会面临一个问题:在服务端,部分组件已经渲染完成,而某个组件 被 block,这会导致整体被block。
如下图展示,在网络良好的情况下,左侧的noteList 和 右侧的 note 详情会很快展示出来。
但是当网络不好时,右侧获取 note详情的接口很慢。我们希望整个页面能够按照下图的方式运行,即没准备好的组件稍微再返回,先展示一个骨架屏。
原理和验证
React team 对这个问题提供了解决方案: streaming protocol. 我们类比前面服务端组件引用客户端组件的例子。
- 服务端组件引用客户端组件时,服务端组件发现无法处理客户端组件,于是把客户端组件的处理延迟到客户端执行,并打上tag。客户端渲染好客户端组件后,把渲染后的结果填充到 tag 的位置。
- 服务端组件引用被block的服务端组件时,服务端组件发现暂时无法处理被block的组件,于是暂时放弃被block的组件,并打上tag, 把渲染好的结果返回到客户端。在被block的组件准备好后,再次返回数据到客户端,填充到 tag的位置。从而实现渐进式的渲染.
下面代码中,配合使用Suspence实现了上述的渐进式渲染。 App 是根组件,使用了 Delay 组件。Delay 组件被 block 了 5秒。
// Delay.server.js
export default function Delay() {
fetch('http://localhost:4000/sleep/5000');
return <div>I am delay</div>
}
// App.server.js
import Delay from "./Delay.server"
export default function App() {
return <div className="main">
<Suspense fallback={"loading"}>
<Delay />
</Suspense>
</div>
}
如果使用流的方式来读取服务器的响应,我们会得到以下结果,其中 @2
是 J2
的占位符。
======== chunk 1 ==========
S1:"react.suspense"
J0:["$","div",null,{"className":"main","children":[["$","div",null,{"children":"123"}],["$","$1",null,{"fallback":"loading","children":"@2"}]]}]
======== chunk 2 ==========
J2:["$","div",null,{"children":["I am delay ",5000]}]
动机
原始动机
Dan 在视频中提到:React Server Components 最初是用来解决组件渲染时 client 与 server 需要多次通信的问题的。
这可能会让人联想到 React SSR 或者 GraphQL, 又或者是最初的 JSP/ASP 时代。这个会在后面进行详细的对比。
设计目标
在明确了把 React 移动到服务端这个方案后,React团队也进一步明确 React Server Components 的设计目标。以下是 RFC 中提到的设计目标:
-
零打包体积的组件。 如果一个组件是服务端组件,那么这个组件将不会出现在最终发往客户端的 js bundle 中。
-
对后端的完全访问能力。 因为服务端组件只会运行在 server 上,因此可以在服务端组件中调用任何的服务端API, 而不用做环境判断。
-
自动代码分割。在服务端组件中引入客户端组件,那么客户端组件会被自动分割成小的chunk。
-
No Client-Server Waterfalls。使用服务端组件在服务端多次获取数据时,都是服务器间的通信(例如:node server 和 java server),内网通信速度 比 client-server 通信速度快很多,因此可以大大提升整体的效率。
-
Avoiding the Abstraction Tax。这个描述可能有些抽象,于是官方给出了解释和例子。像Angular/vue 这种基于模板的 UI框架,会使用类似于 AOT 的技术对开发者写出的组件进行一定程度的优化,但是 React 是使用 JS 来描述组件的,因此很难去优化。但是使用了服务端组件后,可以在一定程度上去优化 React 中的抽象。例如:不管一个组件被写了多少层Wrapper, 最终发往客户端的都是最终的 HTMlElement。
// Note.server.js import NoteWithMarkdown from "./NoteWithMarkdown.server" function Note({id}) { const note = db.notes.get(id); return <NoteWithMarkdown note={note} />; } // NoteWithMarkdown.server.js function NoteWithMarkdown({note}) { const html = sanitizeHtml(marked(note.text)); return <div ... />; } // client sees: <div> <!-- markdown output here --> </div>
-
不同的挑战,统一的解决方案。Web开发领域长期存在一个问题:是使用 “瘦”客户端还是 “胖” 客户端。React 团队认为要同时利用服务端的能力与客户端的能力。 同时使用服务端组件和客户端组件允许开发者用同一种语言、同一个框架 来同时利用这两种能力。这可能会让人再次想起 SSR, 下面一节我们会说明 React Server Components 与 现有相关技术的区别和联系。
与现有技术的关系
SSR
SSR 用于加速首屏的渲染,在请求页面时执行一次,多次请求时,会导致上一次请求渲染的组件和状态全部丢失。暂时不支持数据分批次返回进行渐进式渲染。
React Server Components 可以反复被请求。一次请求就像一次 rerender, 只不过部分工作被分配给 server 做了。甚至有人提出,server components 的 server 不一定是 web server, 也可能是 web worker。支持通过 Streaming data 做渐进式渲染。
但是二者并不冲突,我们可以同时使用 SSR 和 React Server Components。
GraphQL
GraphQL 也是 Facebook 的产品,同样是用来一定程度上解决 client-server 多次通信的问题的。目前配合 relay 和 GraphQL 可以做到 数据获取代码分散在组件间,最终合并成一个大的 GraphQL Query,通过一次 http 请求获取全部数据,从而达到减少通信次数的目的。但是并不是所有团队都会去使用和接受 GraphQL, 因此 React 团队希望使用 React 自己的生态去解决这个问题。 对于没有 GraphQL 的环境,React Server Components 是一种替代方案。另外,Facebook在使用 React Server Components 时,会在服务端组件中去调用 GraphQL。
JSP/ASP
React Server Components确实会让人想起来曾经的 JSP/ASP,因为二者都会在服务端进行 模板 与 数据的绑定。但是 React Server Components 实际上是 Partial 页面更新的技术 ,多次触发路由不会重新渲染整个页面,客户端组件的状态会被保留。
未来展望
- 可降级的 react server components。当服务器压力太大时,有办法降级为普通的客户端渲染。目前优秀的 SSR 方案都支持通过配置来决定当前请求要不要进行 SSR。
- React Server Components 适合的场景还需要最佳实践来确定。可以在一定程度上减少 js bundle size 这个是必然的结果,但是反复请求 Server Components 时多次返回某个相同的 UI 片段的问题的解决方案还需要进一步探讨。
总结
React Server Components 是 React 团队让组件从 以客户端为中心 到 不同的挑战、统一的解决方案 的一次尝试。React 在服务端可以快速生成静态的内容,在客户端可以构建丰富的、交互式的页面。实际上在这些方面,很多人也在探索,像 Hotwire 的 partail html 技术; 微软的 Blazor server 更是早就走向另一个极端:所有状态维护在服务器上,客户端只是渲染器 和 事件触发器。
就像C语言之父说的一样,“只要设计得当,添加新特性是很自然的事。这种做法是艰苦的,但是仍在取得成功”。就目前来看,React Server Components 是一个不太被看好的特性,离设计得当还有有些距离,但这种探索还是值得肯定和支持的。