性能成本
我们将要讨论的最后一个问题领域是性能成本
。
上图形象的描绘了JavaScript
对客户端带来的负担
React
组件是客户端JavaScript
函数。它们是我们的React
应用程序的构建块。当我们在客户端加载应用程序时,组件会下载到客户端,React
会执行必要的操作来为我们渲染它们。
但是这会带来两个重要问题:
首先,当用户发送请求时,应用程序会下载HTML
以及链接的JavaScript
、CSS
和其他资产,如Image
。
在客户端(浏览器上),React
开始执行其魔法,并进行HTML
结构的水合
(hydrates
)。它解析HTML
,将事件侦听器附加到DOM
,并从存储中获取数据。因此,该站点变成了一个完全操作的React应用程序。
但问题是,客户端上会发生很多事情。我们最终会将所有这些代码都下载到客户端。
通常情况下,我们需要将外部库(Node模块)作为项目的依赖项。所有这些依赖项都会在客户端上下载,使其变得更加臃肿。
SSR 和 Suspense 解决的痛点
为了更好地理解对 RSC
的需求,首先需要理解对服务器端渲染(SSR
)和 Suspense
的需求。
SSR
关注初始页面加载,将预渲染的 HTML
发送到客户端,然后在它被下载的 JavaScript
注入后,才会表现为典型的 React
应用程序行为。SSR
也仅发生一次:在直接导航到页面时。
仅仅使用 SSR
,用户可以更快地获取 HTML
,但必须在all or nothing
的瀑布流之前等待,然后才能与 JavaScript
进行交互:
- 必须从服务器获取所有数据,然后才能显示其中的任何内容。
- 必须从服务器下载所有
JavaScript
,然后才能将客户端注入其中。 - 必须在客户端上完成所有的注入,然后才能与任何内容进行交互。
为了解决这个问题,React
创建了 Suspense
,它允许在服务器端进行 HTML
流式传输,并在客户端上进行选择性的注入。通过将组件包装在 <Suspense>
中,我们可以告诉服务器将该组件的渲染和注入降低优先级,让其他组件在不受较重组件阻塞的情况下加载。
当我们在 <Suspense>
中有多个组件时,React
会按照我们编写的顺序从上往下处理树状结构,使我们的应用程序能够进行最优化的流式传输。然而,如果用户尝试与某个特定组件进行交互,该组件将优先于其他组件。
这大大改善了情况,但仍然存在一些问题:
- 在显示任何组件之前,必须从服务器获取整个页面的数据。唯一的方法是在
useEffect()
钩子中在客户端进行数据获取,这比服务器端获取需要更长的往返时间,并且仅在组件渲染和注入后才发生。 - 所有页面的
JavaScript
最终都会被下载,即使它以异步方式流式传输到浏览器。随着应用程序的复杂性增加,用户下载的代码量也会增加。 - 尽管优化了注入,用户仍然无法与组件进行交互,直到客户端的
JavaScript
被下载并且为该组件实现。 - 大部分
JavaScript
计算负荷仍然位于客户端,可能在各种不同类型的设备上运行。
通过上面的各种举证和分析,我们或多或少的知道,React
在平时开发中遇到的一些令人深恶痛绝的问题. 其实React
官方也知道这些问题,所以提出了RSC
.
但在我们谈论这些之前,让我们更多地了解一下客户端
和服务器
。
4. 客户端-服务器模型
在本文中,我们已经多次使用了“客户端”
和“服务器”
这两个术语。让我们高屋建瓴的解释它们之间的关系
- 客户端:在应用程序方面,客户端是在最终用户端执行任务的系统。客户端包括我们的台式电脑、笔记本电脑、移动设备、浏览器等。
- 服务器:
字如其人
,服务器为客户端提供服务。它可以与数据存储或数据库共存,以便快速访问数据。 - 请求:请求是客户端用于向服务器请求服务的通信方式。
- 响应:响应也是服务器用于将服务(数据/信息)发送回客户端的通信方式。
如果想了解更多关于网络相关的东西,可以参考之前写的网络篇
在服务器组件出现之前,我们编写的所有 React 代码
都是在客户端(浏览器)上进行渲染的。因此,为了与在服务器上进行渲染的服务器组件区分开来,从现在开始,我们将常规的 React 组件(其中使用状态、effect
、仅限于浏览器的 API 等)称为客户端组件
(Client Components
)。
React Client Components
传统上React
组件存在于客户端。当它们与服务器交互时,它们发送请求并等待响应返回。在接收到响应后,客户端触发下一组操作。
如果请求的服务成功完成,客户端组件将根据UI采取相应操作,并显示成功消息。如果出现错误,客户端组件会向用户报告错误信息。
当它引起网络瀑布问题时,客户端组件的响应被延迟,从而导致糟糕的用户体验。
React Server Components
我们可以将React
组件迁移到服务器上.也就是说我们可以将它们与后台数据一起放置.
让我们现在来了解一下RSC
。这些新的组件可以更快地获取数据,因为它们位于服务器上。它们可以访问我们的服务器基础设施,如文件系统
和数据存储
,而无需通过网络进行任何往返。
对于React开发者来说,这是一个完整的范式转变,因为现在我们必须从服务器组件的角度来思考。
使用
RSC
,我们可以将数据获取逻辑移至服务器(使我们的组件无需网络调用即可获取数据),并在服务器上准备好它。返回到客户端的数据是一个精心构造的组件,其中包含了所有的数据。
这意味着使用RSC
,我们可以编写如下的代码:
import { dbConnect } from '@/services/mongo' import { addCourseToDB } from './actions/add-course' import CourseList from './components/CourseList' export default async function App() { // 建立 MongoDB 链接 await dbConnect(); // 从数据库(db)中获取对应的数据信息 const allCourses = await courses.find(); // 数据校验(查看是否成功和数据格式) console.log({allCourses}) return ( <main> <div> <CourseList allCourses={allCourses} /> </div> </main> ) }
从上面的代码中我们可以注意到一些写法上的变化
- 组件的类型是
async
,因为它将处理异步调用。 - 我们从组件本身连接到数据库(
MongoDB
)。
- 在常规的开发中,我们只有在
Node.js
或Express中
才会看到这种代码
- 然后我们查询数据库并获取数据,以便将其传递给我们的JSX进行渲染。
- 注意,
控制台日志
会在服务器控制台上记录,而不是在我们的浏览器控制台上。
另外,我们完全摆脱了状态管理(useState
)和副作用管理(useEffect
)。
使用RSC
,我们可能不需要使用useEffect
(老死不相往来
的那种)。
6. RSC的红与黑
以下是关于RSC
可以做和不能做的事情的列表。尽管服务器组件可能看起来很高级,但并不意味着我们可以在任何地方都使用它们。
可以做的事情:
- 使用
async/await
与仅限于服务器的数据源,如数据库
、内部服务
、文件系统
等进行数据获取。 - 渲染其他服务器组件、本地元素(如
div
、span
等)或客户端组件(普通的 React 组件)。
不能做的事情:
- 无法使用
React
提供的钩子,比如useState
、useReducer
、useEffect
等,因为服务器组件是在服务器上渲染的。 - 不能使用
浏览器 API
,比如本地存储等(不过在服务器上可以进行polyfill
)。 - 不能使用依赖于仅限于浏览器 API(例如本地存储)或依赖于状态或效果的自定义钩子的任何实用函数。
7. 如何同时使用客户端组件和服务器组件
我们的应用程序可以是服务器组件和客户端组件的组合。
服务器组件
可以导入并渲染客户端组件,但客户端组件不能在其中渲染服务器组件。如果我们想在客户端组件中使用服务器组件,我们可以将其作为props
传递并以这种方式使用。
最好将服务器组件放在组件层次结构的根部,并将客户端组件推向组件树的叶子。
数据获取可以在服务器组件的顶部进行,并可以按照React
允许的方式进行传递。用户交互(事件处理程序)和访问浏览器API可以在客户端组件中的叶子级别进行处理。
客户端组件
无法导入服务器组件,但反过来是可以的。在服务器组件
内部导入客户端组件或服务器组件都是可以的。而且,服务器组件可以将另一个服务器组件作为子组件传递给客户端组件,例如:
const ServerComponentA = () => { return ( <ClientComponent> <ServerComponentB /> </ClientComponent> ) }
在上面的示例中,我们将一个名为 ServerComponentB
的服务器组件作为子组件传递给了客户端组件。
让我们总结一下:
- 可以在服务器组件内部导入客户端组件。
- 不能在客户端组件内部导入服务器组件。
- 可以将一个服务器组件作为子组件传递给服务器组件内的客户端组件。
RSC vs SSR
RSC
和SSR
两者的名字都包含了Server
这个词,但相似之处仅限于此。
通过SSR
,我们将原始HTML
从服务器发送到客户端,然后所有客户端的JavaScript
都被下载。React
开始水合
化过程,将HTML
转换为可交互的React
组件。在SSR
中,组件不会留在服务器上。
而使用RSC
,组件会留在服务器上,并且可以访问服务器基础设施,而无需进行任何网络往返。
SSR
用于加快应用程序的初始页面加载速度。我们可以在应用程序中同时使用SSR
和RSC
,而不会出现任何问题。
8. RSC的优点
零捆绑包大小的组件
使用库对开发人员很有帮助,但它会增加捆绑包的大小,可能会影响应用程序性能。
应用程序的许多部分并不是交互式的,也不需要完全的数据一致性。例如,详细信息
页面通常显示有关产品、用户或其他实体的信息,不需要根据用户交互来更新。
RSC
允许开发人员在服务器上渲染静态内容。我们可以自由地在服务器组件中使用第三方包,而不会对捆绑包大小产生任何影响。
常规组件
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 */); }
如果我们将上面的示例渲染为RSC
,我们可以使用完全相同的代码来实现我们的功能,但避免将其发送到客户端 - 这将节省超过 240K 的代码(未压缩)。
Server Component (零捆绑包大小)
import marked from 'marked'; // 零捆绑包 import sanitizeHtml from 'sanitize-html'; // 零捆绑包 function NoteWithMarkdown({text}) { // .... }
简而言之,如果我们在服务器组件内使用任何第三方库,该库将不会包含在客户端的捆绑包中。这将减小 JavaScript 捆绑包的大小。
换句话说,通过服务器组件,初始页面加载更快,更精简。基本的客户端运行时是可缓存的,并且大小是可预测的,不会随着应用程序的增长而增加。额外的面向用户的 JavaScript 主要是在我们的应用程序通过客户端组件需要更多的客户端交互时添加的。
如果我们在任何客户端组件内部使用该库,那么就如我们所想,该库将包含在客户端捆绑包中,并将被浏览器下载以进行解析和执行。
全权访问后端数据
正如前面所讨论的,服务器组件可以利用直接的后端访问来使用数据库、内部(微)服务和其他仅限于后端的数据源。
import db from 'db'; async function Note({id}) { const note = await db.notes.get(id); return <NoteWithMarkdown note={note} />; }
在上面的代码片段中,我们将 note
传递给了 NoteWithMarkdown
组件。我们可以直接从数据库中获取这个note
.
如果我们仔细查看代码,我们会发现我们没有进行任何获取 API 调用来获取 note
。相反,我们只是在 Note
组件内直接执行了 DB
查询(通常我们在服务器端代码中执行 DB 查询)。这是可能的,因为这是一个服务器组件,它在服务器上进行渲染。
让我们再看一个例子,其中我们可以从服务器的服务器组件中访问文件系统
:
import fs from 'fs'; async function Note({id}) { const note = JSON.parse(await fs.readFile(`${id}.json`)); return <NoteWithMarkdown note={note} />; }
正如我们在上面的代码中所看到的,我们使用了 fs
模块(文件系统的缩写)来读取服务器上存在的文件。
自动代码分割
服务器组件将所有对客户端组件的导入视为潜在的代码分割点。
有如下的SRC
import OldPhotoRenderer from './OldPhotoRenderer.js'; import NewPhotoRenderer from './NewPhotoRenderer.js'; function Photo(props) { // 根据业务进行组件的渲染 if (FeatureFlags.useNewPhotoRenderer) { return <NewPhotoRenderer {...props} />; } else { return <OldPhotoRenderer {...props} />; } }
在上面的示例中,我们有两个组件 NewPhotoRenderer
和 OldPhotoRenderer
(两者都是客户端组件),它们是有条件地进行渲染的。
假设 if (FeatureFlags.useNewPhotoRenderer)
值为 True
,那么用户将会看到 NewPhotoRenderer
组件。只有该组件会被发送到客户端(或浏览器)。OldPhotoRenderer
将被懒加载(也就是说,它不会立即被发送到客户端)。因此,只有与用户可见的组件相关的 JavaScript 是需要的。
没有瀑布效应
正如前面讨论过的,连续的数据获取会引入瀑布效应。我们希望找到一种方法来避免从客户端到服务器的连续往返延迟(也就是说,我们必须等待一个请求完成,而请求可能需要一些时间来完成,因为它必须从客户端传输到服务器)。
async function Note(props) { // NOTE: 在渲染期间加载,在服务器上进行低延迟数据访问 const note = await db.notes.get(props.id); if (note == null) { // 处理note 未被获取的逻辑 } return (/* 根据note 渲染相关页面*/); }
服务器组件通过将连续的往返请求移到服务器上,使应用程序能够实现这一目标(即不再有从客户端到服务器的获取调用)。
问题实际上并不是往返请求本身,而是这些请求是从客户端到服务器的。通过将这个逻辑移到服务器上,我们减少了请求的延迟,提高了性能。