原文链接:www.joshwcomeau.com/react/serve...
本文内容较长,如果有任何理解难点和错误,请告知我~
文章干货很多!
文章干货很多!
文章干货很多!
自从十年前React初次被引进到开发社区,它经历了几次进化。到现在,他们不会因为一些想法太激进而不去做这一次升级:如果他们发现了一个更好的解决方案,他们就会去落地它。
几个月前,React团队推出了React Server Components
,是最新的一个重大变化。从这一刻开始,React components可以独立运行在服务端上。
这个功能一经推出大家都有一些困惑,比如:这到底是个什么?它是如何工作的?这个的优势是什么?它如何适配服务端渲染?
针对React Server Components
我已经做了很多实验,并且已经解开了我很多的困惑。我不得不承认它比我预想的还要令我兴奋,它真的很cool。所以,我今天的目的就是帮助你弄明白这个功能,回答很多你可能会关心的问题。
SSR快速入门
在掌握React Server Components
之前,熟悉一下SSR(server side rendering)是十分有必要的。如果你已经非常熟悉SSR了,那么你可以跳过这一节。
我在2015年开始使用React,大部分的React的设定是客户端渲染策略。用户会接收到一个像这样的HTML文件:
html
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="/static/js/bundle.js"></script>
</body>
</html>
其中bundle.js
包含了我们所需要的一切并执行应用,包括React,其他的三方依赖和我们所写的全部代码。
一旦Js文件被下载和解析,React会迅速进入执行,转化我们全部应用的所有node节点,并且会将他们注入到空节点<div id='root'>
中。
这种方式带来的一个问题就是:需要更多的时间来处理这些全部的工作。一旦这样去执行,用户就会看到一个白屏的效果。随着时间的推移,这种现象会变得越来越明显,因为每一次新特性的迭代,都会带来一些额外的字节体积到我们的bundle中,进而就会延长用户需要等待的时间。
SSR的设计就是来改善这种体验,相较于发送一个空的Html文件,服务端会渲染我们的应用进而来生成实际的Html。用户最终会接收到一个完全成形的Html文档。
这个Html文件仍然需要包含script标签,因为我们还是需要React运行在客户端,进而来处理每一次的交互。但还是存在一点与浏览器端渲染的差异:这不是从头开始变出了所有DOM节点,而是采用了现有的Html,这个过程被称之为水合作用(hydration)。
引用一下React团队的Dan Abramov的解释称:水合就像用交互性和事件处理程序的"水"浇灌"干燥"的HTML。(Hydration is like watering the "dry" HTML with the "water" of interactivity and event handlers.)
一旦JS bundle被下载下来,React会迅速启动程序,构建虚拟DOM并最终拟合到真实DOM上,绑定事件,触发效果等等。
简而言之,SSR就是通过服务端生成了一个初始的Html,这样用户就不再需要因为Js bundles的下载和解析而白白等待空白页。然后,客户端React从服务器端React中断的地方开始,采用DOM并加入交互性。
总的来说:
当我们谈论服务端渲染的时候,我们可以想象这样的一个流程:
2、Node服务接收到这个请求,立即渲染React应用,生成HTML
3、这个刚生成的HTML会被发送到客户端
这是实现服务端渲染的一种可能方式,但不是唯一方式。另一个选择是当我们构建应用的时候生成HTML。
一以贯之的是,React应用需要被编译,将JSX转化为原生的JS,打包我们所有的模块。如果在同一过程中,我们为所有不同的路由"预呈现"了所有HTML,会怎么样?
这通常被称为静态站点生成(SSG)。它是服务器端渲染的一个子变体。
在我看来,服务端渲染整体来说包含了一些不同的渲染策略,他们都有一个共同点:通过服务端运行时使用
ReactDOMServer
来初始渲染。无论是按需还是在编译时,这种情况在什么时候发生都无关紧要。无论哪种方式,它都是服务器端渲染。
前后端来回跳动的困扰
接下来我们看一下React中的数据请求,正常情况下,有两个独立的应用通过网络进行通信:
- 客户端React应用
- 服务端REST API
通过React Query、SWR或者Apollo,客户端可以对后端发起一次网络请求,服务端可以从数据库中抓取数据然后通过网络发送给客户端。
通过一张图看一下这个流程:
上述图中的横坐标单位为时间,不是分钟也不是秒,只是一种时间概念。在实际场景里面,因为影响的因素很多,这里的时间概念可能千差万别。因此这里的图只是给大家对于概念的一个更高维度的理解。
上面这张图展示了客户端渲染的流程,流程开始于客户端收到一个HTML文件,这个文件除了一些Script标签之外没有任何内容。
一旦JS文件被下载和解析,React应用就会启动,创建一批DOM节点并渲染UI。首先,因为我们还没有真实的数据,所以我们只能渲染一些加载状态的骨架(头部,底部和一些通用的layout)。
你应该看过很多这种骨架屏的模式,比如,UberEats的启动页就是一个骨架屏,它在等待实际需要渲染数据的返回。
在获取数据期间,用户只能看到这个加载状态的页面,直到数据返回并重新渲染页面。
那么,我们换个思路来解决这个问题。下面这张图保留着同样的数据请求模式,但是通过服务端渲染取代了客户端渲染:
在这个新的流程中,我们在服务端进行第一次的渲染,这意味着用户不再只是收到一个空白的HTML文件。
相较于空白页面,这种流程确实有一些改善的效果,但是根本上它并不是一种解决问题的有效方式,因为用户访问我们的应用不是看一个加载页面,而是访问我们的真实内容(餐馆、饭店列表,搜索结果,消息等等)。
为了能够真实地提升用户体验,我们在图表里面添加一些性能指标,让我们看看CSR和SSR的性能差别:
上述的每一个小红旗🚩都是一些常用的web性能指标。以下是分析结果:
- First Paint -- 用户不再需要看着空白屏,基础的布局已经被渲染,但是没有内容。这就称之为FCP(First Contentful Paint)
- Page Interactive -- React已经下载,我们的应用已经被渲染/水合,交互的元素都能正常的响应。这就称之为TTI(Time To Interactive)
- Content Paint -- 页面包含了用户关心的内容,应用已经从服务端拉取到了数据并渲染在UI中。这就称之为LCP(Largest Contentful Paint)
通过在服务端做初始渲染,我们可以更快地获得骨架屏,这会让加载的体验更好一些,就好像这是一种流程一样,并且流程已经开始。
在有些情况下,这种优化是十分有意义的,比如有些用户就是在等页面头部的加载,然后可以快速链接到其他地方。
但你不觉得这种流程有点傻么?每当我看着SSR的流程图的时候,我都忍不住地关注到请求是在服务端启动的。相较于第二次的网络请求,我们可不可以尝试在第一次请求的时候拿数据。换言之,可以不可以让流程变成这样:
相较于在客户端和服务端之间来回跳动,我们将数据请求作为初始请求的一部分,给用户发送完全填充的UI。
但是,我们究竟该怎么做呢?
为了能让这个设想成真,我们需要给React提供一串代码并让其只运行在服务端------来做数据请求。但是这不是React提供的一个能力,甚至在SSR,我们所有的组件都在客户端和服务端渲染。
前端生态针对这个问题有很多的解决思路。像是Next.js和Gatsby这样的框架已经通过他们自己的方式让代码单独运行在服务端上。
举例说明,这里是在Next.js中使用的形态(使用传统的页面路由):
jsx
import db from 'imaginary-db';
// This code only runs on the server:
export async function getServerSideProps() {
const link = db.connect('localhost', 'root', 'passw0rd');
const data = await db.query(link, 'SELECT * FROM products');
return {
props: { data },
};
}
// This code runs on the server + on the client
export default function Homepage({ data }) {
return (
<>
<h1>Trending Products</h1>
{data.map((item) => (
<article key={item.id}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</>
);
}
我们对这段代码进行一下分析:当服务端接收到一个请求,getServerSideProps
函数被执行并返回一个props
对象,这个对象被注入到组件里面,组件会先在服务端渲染,然后在客户端进行水合作用。
比较好的是getServerSideProps
这个方法不会在客户端重新执行,那是因为这个方法不会被打包进我们的Js bundle中。
这种方式是十分超前的,这确实很好,但是任何事情都是一体两面的,不免也会有一些缺陷:
- 这种策略只会在路由层面工作,也就是那些在在组件数顶层的组件,不可以在任何组件中使用
- 每个框架都有自己的实现方式,Next.js有自己的方式,Gatsby有实现方案,Remix也有,没有统一的标准
- 所有的组件在客户端都会进行水合作用,不管还需不需要
这几年,React团队一直在思考并解决这件事,期望通过一个官方统一的方式来处理这个问题,于是React Server Components
诞生了。
React Server Components介绍
在一个更高的层面来看,React Server Components是一种全新的规范,在这个新的世界,我们可以创建只在服务端运行的组件,这允许了我们在组件里进行像数据请求的操作。
下面是一个服务端组件的示例:
tsx
import db from 'imaginary-db';
async function Homepage() {
const link = db.connect('localhost', 'root', 'passw0rd');
const data = await db.query(link, 'SELECT * FROM products');
return (
<>
<h1>Trending Products</h1>
{data.map((item) => (
<article key={item.id}>
<h2>{item.title}</h2>
<p>{item.description}</p>
</article>
))}
</>
);
}
export default Homepage;
作为一位使用react很多年的我来说,这段代码给我的第一印象确实太疯狂了。
"但是...",我扪心自问:"函数式组件不允许异步啊,在渲染的流程中不允许我们产生任何副作用的操作"。
有一个关键点需要注意:服务端组件不会重新渲染,他们只会在服务端执行一次来生成UI,渲染的结果会被锁定并发送给客户端。就React而言,这个结果是不可变的,也永不会变。
这意味着React的API中有很大一部分与服务器组件不兼容。比如,我们不能使用state,因为state可变,但是服务端不会重新渲染;我们不能使用effects,因为effects只会在渲染之后执行,在客户端,服务端组件不会执行这里的。
但是当在某些条件下它还是有些弹性的。例如,传统的react中,我们在使用useEffect
的时候,我们会将副作用代码放在callback、事件处理器或者其他中,进而确保每次渲染的时候不会重复执行。但是如果组件只会执行一次,这种副作用代码就可以不写啦。
服务端组件本身是很清晰简单的,但是React Server Components
规范还是很复杂的。因为React还有一些常规的组件,他们结合在一起的方式可能会令人十分困惑的。
在这个新的规范中,传统的React组件------也就是我们说的客户端组件,这类组件只会在客户端渲染,但是这个结论又并不严谨,客户端组件会同步是在两端渲染。
我知道这些术语会让人感到困惑,所以这里我做个总结:
- React Server Components是在这个规范才出现的名称
- 在这个规范里,我们熟知的标准的React Components被重新称为Client Components,旧物新名
- 在这个规范里,服务端组件是一种新的组件类型,他们会单独运行在服务端,他们的代码不会被打包进js bundle,所以他们不会进行水合作用和重新渲染
React Server Components VS Server side Rendering
让我们来厘清一下这两个容易混淆的概念:React Server Components不是要取代SSR,你也不必认为这是SSR2.0,我反而认为这是两块独立的部分,结合在一起会非常完美,这两个部分互相完整彼此。
我们仍然需要SSR来生成初始的HTML,React Server Components在顶部构建,并且会在客户端bundle中过滤掉,进而确保这个组件只会在客户端执行
实际上,React Server Components可以脱离SSR使用,但是在实操中,一起使用会会得到更好的结果。React 团队构建了一个最小的没有SSR的RSC demo,你可以看看
上手说明
通常,React有新特性发布以后,通过对已存项目升级React到最新版本就可以使用新特性,一次快速安装npm install react@latest
即可。
但是, React Server Components的使用不是酱婶儿的。
在我的理解里,React Server Components需要与React之外一些工作紧紧结合才能使用,像是bundler,服务端和路由等。
在我写这篇文章的当下,只有一种使用React Server Components的方式,那就是Next.js 13.4+,使用他们全新的重新设计的App Router
。
希望在不久的将来能有更多地基于React的框架集成React Server Components。其实像这么重要的React功能目前只有一种方式在支持是很尴尬的。React的文档中有一个地方列举着支持RSC的介绍,我会不定期地去查看这个页面,看看会不会有一些新的消息同步进来。
客户端组件的说明
在这个新的React Server Components规范里,所有的组件都是默认为服务端组件。针对客户端组件,我们需要选择加入客户端组件。
如何选择?通过申明一个全新的命令来做:
tsx
'use client';
import React from 'react';
function Counter() {
const [count, setCount] = React.useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Current value: {count}
</button>
);
}
export default Counter;
这段代码的顶部单独申明的一行use client
,就是用来表明当前的组件为客户端组件,这些组件需要打包进js bundle中,进而可以在客户端实现重新渲染的能力。
通过这种方式来申明我们开发的组件看起来似乎有点古怪,但是这类申明其实是有先例的:use strict
命令就是严格是模式(Strict Modes
)的一种声明。
我们不会在服务端组件中声明use server
来标记服务端组件,那是因为在RSF中默认都是服务端组件。实际上,use server
是用在服务端的事件中的,一个完全不同的功能,但是超出了本文的介绍范围。
哪种组件应该被设计为客户端组件呢?
你可能有这个疑问:我该如何定义一个组件为客户端组件还是服务端组件呢?
一个简单规则:如果一个组件可以成为服务端组件,那么它就应该是服务端组件,服务端组件倾向于更简单更容易推理。同时还有一个性能优势:因为服务端组件不会打包到客户端的JS bundler中。RSC的一个潜在的优点就是:可以提升TTI(time to interactive)的指标。
也就是说,我们不必要为了尽可能减少客户端组件作为我们的开发任务,我们不需要优化到最小数量的客户端组件。有一点需要提醒的是:每个React应用程序中的每个React组件都是客户端组件。
当你开始进行RSC的开发,你会发现这种划分更多的时候是凭直觉。有些组件我们因为使用了state或者effects进而必须要在客户端运行,这是时候你需要在组件文件的顶部添加一个命令
use client
。其他的都可以作为服务端组件。
边界的概念
我在学习RSC的时候遇到的第一个问题是:当props变化的时候组件会怎么样呢?
举个例子,假设有个这样的服务端组件:
tsx
function HitCounter({ hits }) {
return (
<div>
Number of hits: {hits}
</div>
);
}
假设服务端渲染的初始值hits为0,这个组件将会产出下面的标记:
html
<div>
Number of hits: 0
</div>
那如果hits的值发生了变化呢?假设这是一个state变量,从0变成1,HitCounter
会发生重新渲染,但是它不会发生重新渲染,因为这是个服务端组件。
问题是:服务端组件隔离的情况下是没有任何意义的,我们需要缩小视野,以获得更全面的视角来看应用的结构。
我们以下面的这个组件树为例:
如果上述都是服务端组件,那么这就是都有意义的。没有props的变化,因为所有组件都不会触发重新渲染。
但是我们假设Article
组件持有state变量hits
,为了能使用这个变量,我们需要将这个组件变成客户端组件。
你发现问题了吗?当Article组件重新渲染以后,该组件的所有子组件都需要重新渲染,包括了HitCounter
和Discussion
组件,那如果这些组件都是服务端组件的情况下,是没有办法触发重新渲染的。
为了避免这种情况,React团队添加了一条规则:客户端组件只能引用客户端组件
,use client
指令意味着HitCounter和Discussion的实例需要转化为客户端组件。
学习RSC规范的过程中,最哇塞的一个时刻是对于创建客户端边界的认知。实际上,下面才是最终实际的效果。
当Article
组件添加了use client
指令,客户端边界就已经创建了。这个范围内的所有组件都会隐式地转化为客户端组件,尽管这些组件都没有添加use client
的指令,他们仍然会在客户端进行水合和重新渲染。(当然如果你在其他地方通过服务端组件调用该组件,这个组件还是服务端组件)。
所以,我们不需要为每个运行在客户端组件添加use client
指令,实际情况下,我们只需要在创建新的客户端边界的组件上添加即可。
概念的变通之法
当我第一次了解到客户端组件不能引用服务端组件的时候,我感觉这是个十分受限的功能。那万一我要在应用树顶部去使用state,那是不是就说明所有的组件都会变成客户端组件了?
实际情况下有很多这种场景,我们可以通过重新组织应用结构来改变组件的拥有者,通过变通的方式解决这个限制。
这个比较难解释清楚,可以直接看一个例子:
jsx
'use client';
import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';
import Header from './Header';
import MainContent from './MainContent';
function Homepage() {
const [colorTheme, setColorTheme] = React.useState('light');
const colorVariables = colorTheme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
return (
<body style={colorVariables}>
<Header />
<MainContent />
</body>
);
}
在这个设定里面,我们需要使用React state来提供用户切换黑白模式的功能。这就需要将state放在应用树的顶层,进而才能将css变量应用到body
标签上。
为了能使用state,我们需要将Homepage
定义为一个客户端组件,但因为这是个顶部组件,所以它的所有子组件Header
和MainContent
都会隐式地变成客户端组件。
那么,为了解决这个问题,我们提取这个颜色管理任务成为一个组件,并在该组件内实现逻辑:
jsx
// /components/ColorProvider.js
'use client';
import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';
function ColorProvider({ children }) {
const [colorTheme, setColorTheme] = React.useState('light');
const colorVariables = colorTheme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
return (
<body style={colorVariables}>
{children}
</body>
);
}
回到Homepage
,我们使用这个新组件:
jsx
// /components/Homepage.js
import Header from './Header';
import MainContent from './MainContent';
import ColorProvider from './ColorProvider';
function Homepage() {
return (
<ColorProvider>
<Header />
<MainContent />
</ColorProvider>
);
}
我们可以从Homepage
中删除use client
这个指令,因为这个组件不再使用state,任何其他客户端React特性。这也就意味着Header
和MainContent
不再需要隐式地转化为客户端组件。
桥豆麻袋,当我没看见呢?ColorProvider
作为一个客户端组件,且作为Header
和MainContent
的父组件,无论怎样,还是在应用树的顶层,对吧?
当触发客户端边界的时候,组件的父子关系将不再生效。Homepage
是唯一引用和渲染Header
和MainConent
,这意味着Homepage
决定了这些组件的props是什么。
请记住,我们尝试解决的问题是:服务端组件不能重新渲染,所以他们的Props不能被赋予任何新的值。有了这个设定,Homepage
决定了Header
和MainContent
的props是什么,但是Homepage
又是一个服务端组件,所以没有问题。
这确实有点烧脑,纵使我有了好几年的React的开发经验,我仍然觉得这个十分让人困惑😴。这需要相当多的练习才能形成直觉。
更严谨一点说,use client
指令运用在文件或者模块级别,任何在客户端组件文件中引用的模块都是客户端组件。毕竟,当bundler打包我们的代码时,它会遵循这些导入!
如何修改主题?
在我上面的例子中,你可能注意到了没有办法可以更改颜色主题,
setColorTheme
没有被调用。我想要通过最小单元来展示我想表达的内容,所以我就遗弃了一些完整的内容,完整的例子则是通过React context来让setter方法在所有子孙节点生效。只要消费这个上下文的组件是个客户端组件,那么一切都会正常运行。
深入了解服务端组件
让我们看得再深入一些,当我们使用服务端组件,那么它的产出是什么?实际上生成了什么呢?
让我们从一个最简单的React应用来看:
jsx
function Homepage() {
return (
<p>
Hello world!
</p>
);
}
在RSC的规范中,所有的组件默认都是服务端组件,因为我们没有显式地标记组件为客户端组件(或者被统计为客户端边界内的组件),它只会在服务端渲染。
当我们通过浏览器访问这个app的时候,我们会接收到一个HTML文档,其内容如下:
HTML
<!DOCTYPE html>
<html>
<body>
<p>Hello world!</p>
<script src="/static/js/bundle.js"></script>
<script>
self.__next['$Homepage-1'] = {
type: 'p',
props: null,
children: "Hello world!",
};
</script>
</body>
</html>
HTML文档中已经包括了通过React应用已生成的UI,Hello world!
这个段落,这要归功于SSR,而不是由RSC来贡献的。
接下来是一个script标签,用来加载我们的js bundle。这个bundle包括了一些依赖,像是React,一些在应用中使用到的客户端组件。Homepage
组件是服务端组件,这个组件的代码不会包含在这个bundle里面。
最终,我们还有第二个script标签,里面包含了一些内敛JS:
js
self.__next['$Homepage-1'] = {
type: 'p',
props: null,
children: "Hello world!",
};
这一段确实有点意思,本质上,我们这里代码是想告诉React:hey,我知道你没有Homepage
组件的代码了,但是不用担心,这里是它最终渲染的内容。
正常情况下,React在客户端进行水合的过程中,它会快速渲染所有组件,构建整个应用的虚拟内容。这个过程不能运用到服务端组件,因为bundle里面没有这部分代码。
因此,我们发送渲染值,即服务器生成的虚拟内容。当React在客户端运行时,它会重新使用这个虚拟描述而不是重新生成它。
这就是为什么上面的ColorProvider
案例可以运行的原因。Header
和MainContent
的输出通过children
prop传递给ColorProvider
组件,ColorProvider
可以重新渲染很多次,但是这个children是静态的,在服务端就被锁定了。
如果您想了解服务器组件是如何序列化和通过网络发送的真实内容,你可以通过RSC Devtools工具查看。
RSC的优势
RSC在React中是第一个官方方式运行纯服务端代码,我前面也提到了,在丰富的React的生态中,运行服务端代码不是一个新鲜事儿,在2016年的Next.js中就已经可以运行服务端代码了。
最大的区别是:我们之前没有一种方式可以让单独服务端代码运行在组件内部。
最明显的优点就是性能,服务端代码不必打包到bundle中,这会减少脚本的下载体积,也会减少水合的组件数量。
说实话,这并不是我最兴奋的事情,大部分的Next.js的应用在TTI方面的时间进行很好了,都很快。
如果你了解HTML的语法规则,大部分的应用在React完成水合之前是可以使用的。链接可以点击,表单可以提交,手风琴可以展开和关闭(使用<details>
和<summary>
)。对于大部分的项目,为了React的水合作用等待几秒是可以接受的。
但是我发现了一个更Cool的事情:就功能和包体积大小而言,我们不再需要做这种妥协。
举例说明,大部分技术博客都需要引入一些语法高亮的依赖包,在这篇文档中,我在使用Prism。代码片段是这样的:
一个合理的语法高亮包会包含所有的流行编程语言,那将会有好几MB,占用了太多的bundle体积。结果就是我们需要做出妥协,清除掉不需要的语言和特性功能。
但是,如果我们将语法高亮放在服务端组件中,这样就没有语法高亮代码被打包进bundle中,结论就是:我们不需要任何妥协,进而可以使用所有的功能。
这就是Bright的想法,一个与RSC结合的现代化的语法高亮包。
这才是我对于RSC兴奋的点,那些因为打包进bundle中而带来过高成本的内容,现在没有任何负担地可以跑在服务端,bundle不会增加任何体积,也会带来一个更好的用户体验。
不仅仅在性能和用户体验方面,使用了RSC一段时间以后,感觉使用服务端组件是一件多么舒畅的事情。在这里你不需要关心依赖数组,闭包问题,内存问题,还有其他因为状态变化带来的其他复杂的事情。
最后,一切都还早,RSC目前还在beta版本几个月中。接下来几年的技术进化让我兴奋,社区持续研发新的解决方案,就像Bright,充分利用了这个新规范的优势。成为一名React开发者是一件兴奋的事情!
完整蓝图
RSC是一项令人兴奋的开发方式,但这也只是整个现代化React蓝图中一小块。
当我们将RSC、Suspense和新的流式SSR结构结合,那将是真的令人兴奋的事情。整体的流程就变成了如下:
这个图确实超过了这篇文章介绍的内容,但是如果你有兴趣,可以看这里。