干货 | React Server Components 初探
作者简介
Sprite 82,喜欢研究各种语言对 web 框架的实现,函数式编程的爱好者,最近在编译原理前端中验证学习到的函数式编程知识。
随着前端职能在互联网公司的重要性与日俱增,我们的前端代码库的体积也开始跟着膨胀,特别是基于 React 的应用,Size 常常以兆 (Mb) 计,给渲染的性能优化提出了巨大的挑战。除了体积,还有另一个问题是,在当下的前端同构 SSR 实践中,Server 端的主要用途是执行一些在服务端和客户端都能执行的通用渲染计算,很少能充分发掘 Server 端特有能力的场景。如何降低客户端 JS Size 以及更极致地挖掘服务端的优化能力,成为一个待解决的开放性问题。
Facebook 的 React Team 尝试给出了他们的一个探索方案——React Server Component。一种只运行在服务端的 React 组件化能力,我们来一探究竟。
一、怎么开始
资源
Introducing Zero-Bundle-Size React Server Components
官方Blog里提供了三个关于 React Server Components 的资源:
1)一个可以运行的 full stack demo 项目。项目可以直接在本地运行,但是需要进行一些数据库配置。如果有docker环境的话,建议在docker里运行,可以避免手动配置环境。
2)RFC。React 团队 一直采用 RFC 的方式来帮助和指导 React 的设计。重大变更在经过社区彻底讨论后才会合并到 React 的稳定版本。
3)一个近一个小时的视频。解释了为什么要开发 React Server Components 和 对上述 demo项目的演示。有精心翻译的中文字幕。
说明
与 React team 的做法一样,这里也推荐大家先去看这个视频,先对 React Server Components 有个整体的认识。当然直接看本文也是可以的。
现在 React Server Components 仍处于开发状态。暂时不适合深入使用,也不适合基于这个新特性去开发升级自己的框架。
二、基础认识
组件类型
1)server component。不能包含客户端代码,如使用 DOM api、 useState、useEffect等。
2)client component。现在大家所熟悉的普通组件。
3)share component。既能作为 server component, 又能作为 client component,取决于引用该组件的组件。通常是根据 props 直接渲染的组件。
组件命名
1)server component:扩展名 .server.js
2)client component:扩展名 .client.js
3)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"}]}]
对比服务器的响应以及组件的编写形式,我们可以清楚地看到以下三点:
1)M-id(M1): 表示的是对一个客户端组件的描述。其中 id 为该组件在项目中的路径,可以用来唯一标识这个组件;chunks 是 webpack 打包后的 chunk。
2)J-id(J0): 表示服务端组件渲染后的结果。大家很容易注意到,这个形式与 React.createElement 返回的结果是高度吻合的。这当然不是巧合,因为这个 JSON 所描述的正是组件 Render 后的结果。
3)在 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 是一个不太被看好的特性,离设计得当还有些距离,但这种探索还是值得肯定和支持的。
【推荐阅读】
“携程技术”公众号
分享,交流,成长