最近笔者腾出了大把的时间,学习了一下React18。
React18带来了许多新特性,其中包括SSR
的增强和Suspense
的增强。本文就来具体剖析一下这两部分的变化。
那么,首先来详细讲一下SSR。
什么是SSR
SSR并不是最近才发明的,早在互联网初期,我们看到的web页面几乎都是服务端渲染的,诸如Jsp、ASP、Java等。当时前后端开发是一体的,UI通过模版来表现,并在服务端处理成html发送到浏览器。如今我们所说的SSR往往指的是在使用前端组件化的框架如React、Vue时,服务端渲染组件并最终生成html发送到客户端。
对比客户端渲染(CSR)主要优势就是:
- SEO友好:html在服务端渲染,搜索引擎能抓取页面内容,SEO效果好。而CSR页面往往html是空的。
- 首屏加载速度快:用户请求页面后直接返回完整的HTML并展示。不需要等待客户端下载完整js并执行,减少白屏时间。
缺点就是,需要node服务器,不能像CSR应用那样可以直接放到CDN上。
React技术栈的同学在想到SSR时,往往第一反应就是Next.js。Next.js是一套完整的SSR应用开发框架,包含路由系统、数据获取、SSR、SSG、图片脚本优化等。它底层是基于React,其中SSR的实现也是React本身提供的能力。
这是因为,React具有同构特性,它允许相同的代码在服务器和客户端上运行。
React18之前的旧SSR
React提供了一些Server Api帮助在服务端渲染html,并在客户端水合(Hydration)。
简述下这个过程:
-
接收到一个请求后,React在server端执行Server Api,将React组件(比如
<App />
)渲染成string或者stream形式的html。这个过程是服务端的React执行的。此时,html中只包含静态的内容,不包含事件监听器等,页面无法交互。 -
当这个html渲染到web端时,浏览器去执行js脚本,此时开始由客户端的React接管了。执行
hydrateRoot
方法,会将该html和React组件树(比如上一步的<App />
)进行Hydration处理。 -
Hydration流程,和我们熟悉的
createRoot
触发的首次渲染流程一样。会执行到render阶段
,只不过Hydration会复用dom,将创建的fiber与dom关联,并且在completeWork
时不创建dom。执行到commit阶段
时,会绑定上事件监听器。这样流程结束后,页面就有了交互性。
一句话概述就是:server端React渲染出首屏页面,浏览器可以直接展示,但是交互不了。Hydration就是web端React接管页面的过程,并让页面变得可交互。
其中服务端渲染部分
renderToString
原理:就是递归遍历组件树,拼接成html字符串。
客户端水合hydrateRoot
原理如上。代码不赘述。
参考文章:React SSR流程分析。
手动实现旧SSR
下面写一个简单的demo,来实现SSR。
为了方便实现同一套组件,既在服务端渲染,又在客户端hydrate。所以我将服务端代码和客户端代码放在同一个项目里,创建了两个webpack配置文件。这样它们都方便导入App.jsx
。
参考官方文档,首先先实现App.jsx
:
import React, { useState } from "react";
export default function App() {
return <Count />;
}
function Count() {
const [count, setCount] = useState(0);
return (
<div>
<h1>测试SSR</h1>
<div>计数器:{count}</div>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
App组件嵌套了一个简单的计数器组件。
我们打算在server上通过renderToString
方法直接渲染出一个包含客户端执行脚本的html。这样的话浏览器渲染后可以直接执行。
所以我们要实现一下客户端待执行的脚本index.js
:
import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App.jsx";
// ...
// 开始hydrate
function hydrate() {
hydrateRoot(document.getElementById("app"), <App />);
}
// ...
客户端脚本写完了,使用webpack打包客户端代码,生成output/bundle.js
。
然后在服务端server.js
里,设置请求路由,并通过renderToString
方法返回html。html中包含脚本bundle.js
:
const React = require("react");
const Koa = require("koa");
// ...
app.use(serve("./output"));
router.get("/", async (ctx, next) => {
ctx.set("content-type", "text/html");
ctx.status = 200;
const appString = renderToString(<App />);
ctx.body = `<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/styles.css"></link>
<title>My App</title>
</head>
<body>
<div id="app">
${appString}
</div>
</body>
<script src="/bundle.js" async></script>
</html>`;
});
// ...
app.listen(888);
开发完了,测试下。
可以正常展示计数和点击+1。如果我们把index.js
里面的hydrateRoot
方法注释掉,即不水合,会发现,上面的按钮点击已经没有反应了。
思考:React的SSR和传统后端语言的SSR的区别是什么呢?
最明显的区别就是传统SSR是多页应用,页面通过刷新跳转。请求一个地址返回一个html页面。体验较差。
而React的SSR是同构应用,即请求地址的首屏由服务端渲染,此后客户端React接管,形成单页应用。体验很优秀。
为什么用Next.js
既然React已经支持了SSR,那为什么还需要Next.js这样的技术呢?(这里主要说的是Next.js的Pages Router)。
开发体验
从上面的例子可以看出来,手动通过React实现SSR,在一个项目里面,要自己控制好前后端代码的边界。哪些是前端的代码,哪些是后端的代码;要自己考虑怎么实现前端和后端都可以运行的代码;并且还需要分开编译,整体开发体验很差。而使用Next.js
就不需要考虑这些,Next.js已经帮我们处理好了。
路由
也是上面的例子,我们仅仅实现了一个基础的ssr应用,没有包含多页面路由,而路由是最重要的功能之一。那ssr如果要支持多页面怎么做?
好在react-router
支持ssr。在服务端使用StaticRouter
,在客户端使用BrowserRouter
。但是路由功能很多呀,两边适配起来也不容易。而Next.js
则通过基于文件的路由系统将这一系列复杂工作全都处理完了,我们开箱即用即可。
异步数据获取
上面的例子也没有展现如何在ssr时获取异步数据,并依据数据渲染页面。这里要考虑的,一是数据要在服务端获取,二是数据也要注入到客户端,否则水合会出问题*(比如ssr获取数据为100个元素的list,并渲染出100个div。客户端如果未获取这个props数据,那客户端会渲染空列表)*。
为了实现两端异步数据获取的同构,我们需要借助于react-router
的loader功能。在服务端,在loader中调用组件暴露的静态方法来获取异步数据,并作为props传递到组件。
// router
[
// ...
{
path: "/posts",
component: Posts,
loader: isServer ? Posts.getServerData : Posts.getClientData,
}
// ...
]
// Posts组件
function Posts({ posts }) {
return <div>{posts.map((p) => {/* */})}</div>
}
Posts.getServerData = async () => {
const posts = await getPosts()
return { posts };
}
生成ssr的html时,暴露数据到<script>标签下。
ctx.body = `<html>
// ...
<script id="_POST_DATA_" type="application/json">
[{ id: 1, title: "Sumish" }, ...]
</script>
// ...
</html>`;
客户端,需要从html内<script>标签下获取数据并注入。
Posts.getClientData = () => {
const posts = JSON.parse(document.getElementById("__NEXT_DATA__").textContent);
return { posts };
}
Next.js
的方案可能与上面类似,它已经完全封装好该功能,只需要提供一个getServerSideProps
函数并实现即可。
CSS处理
上面的例子中,我们将css放在了html上。这样是可行的。但是如果我们想要使用less这样的css预处理器,甚至是emotion这样的css-in-js
技术,ssr就不好处理了。因为node上无法执行dom操作。ssr处理css,必须要将css内容生成到html字符串中。好在Next.js
也为我们封装好了。
单页体验
上面的例子中,我们在水合后交由客户端React处理后续操作,就为了实现SPA的良好体验。但实现上还存在问题。比如异步数据获取:SSR时,异步数据是在服务端获取并在客户端注入。但SPA情况下,数据则必须在客户端获取。要应对这种情况,代码还需要去fix。而Next.js默认已经处理好SSR场景和SPA场景下的异步数据获取逻辑。其它功能也一样处理完善。
其他优化
除此之外,Next.js还提供了很多优化和便利。包括SEO处理、SSG静态生成、TypeScript支持、打包优化等等。并且Next.js始终跟进最新的React能力,从而提供性能更好的应用。
参考文章:React ssr原理
React18的新SSR
React18下,SSR得到了很多增强特性。在此之前的React版本中,正如我们上面手动实现的例子,实现SSR需要:
-
在服务端,整个应用从数据源fetch数据;整个应用渲染成字符串;返回html的response。
-
在客户端,加载javascript;执行javascript进行Hydration。
这两步骤缺一不可,按顺序执行,并且内部也是同步执行的。比如服务端必须获取完数据,并完整渲染、客户端接收到完整html再整个执行Hydration。可想而知,这个过程并不高效:
-
必须获取所有数据,才能显示内容
比如某一个组件获取数据比较耗时,那它就会阻塞后续所有步骤,导致页面白屏时间很长。除非说把这个数据获取从SSR中排除,丢给CSR去处理。(CSR大家都懂,必须等待js执行完才开始获取数据)。
-
必须加载所有js,才能开始水合
比如某个组件交互逻辑比较复杂,js代码比较多。由于React只能一次性完成Hydration,那么你必须等待所有组件js都加载完成,才能开始Hydration。你可能会选择代码拆分,但React没法做到Hydration过程中按需加载组件代码。为了妥协,你只能把这个组件从SSR中移除,并使用代码拆分。
-
必须完成整个水合过程,页面才能交互
比如某个组件渲染逻辑很复杂,hydration可能耗时比较久。那么你必须等到所有组件hydration结束,才能与其中一个组件有交互。
那新的SSR架构怎么解决这些问题呢?React18带来了流式Html和选择性水合:
-
Streaming Html:在服务端,使用
renderToPipeableStream
代替renderToString
渲染,可以让html分段传输到浏览器,让浏览器可以更快速、平滑的显示内容。 -
Selective Hydration:在客户端,可以针对已经完成渲染的区域进行水合,而不需要和原来一样等待整个页面加载完成以及所有js加载完成,才能开始水合。
在讲这两个特性之前,先了解一下新的Suspense
组件。因为它们的实现离不开Suspense
。
Suspense组件
在React18之前,Suspense
组件的功能很单一,我们使用它的场景就是和React.lazy
组合,来实现代码拆分和组件懒加载。
import React, { Suspense, lazy } from 'react';
const LazyComponent = lazy(() => import('./LazyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
}
例子中,React.lazy
+ 动态导入语法(() => import(/*...*/)
)让LazyComponent
组件的代码在打包时被单独拎出去。并在需要到该组件时,才去加载对应的组件代码。Suspense
组件会在该组件代码没有被加载完成前,展示fallback中的备用组件。
在React18的并发特性下,Suspense组件得到了诸多增强特性。它的定义改为:**在组件树中,如果某一部分还没准备好被显示出来,Suspense
可以让你指定它的loading状态。**可以看出来,它包含了之前的能力,但是明显范围更大(没加载完成=>没准备好显示)。
Suspense的全部新特性,以及原理、升级原因,详细可以看一下Suspense功能的RFC。这里就不再赘述。
这里重点关注一下与SSR有关的新特性,之前Suspense
无法应用于SSR。而在React18使用renderToPipeableStream
这个新的SSR渲染器的话,它可以与Suspense集成:**新渲染器创建一个stream来发送html到客户端,SSR过程中,当使用Suspense
包裹的这部分组件树还没准备好渲染时,可以先发送fallback的内容。当这部分组件准备好后,React会在当前流中,发送html和内联script脚本到客户端,来将客户端的dom修正。**这一部分都是SSR实现的,即使客户端js完全没有执行,也不影响。
流式传输Html
上面提到renderToPipeableStream
创建一个stream来发送html到客户端。这个是怎么实现的呢?
扩展一下,服务端发送流式数据到客户端有三种方式:
-
Server-Send Events:SSE是一种服务器推送技术,允许服务器向客户端发送事件。通过设置请求头
Content-Type: text/event-stream
,请求体发送二进制流实现。 -
分块传输(Chunked Transfer Encoding):可以将不确定内容长度的数据块,分多次的传输给客户端,通过设置
Transfer-Encoding: chunked
,请求体发送二进制流实现。与SSE的区别是SSE主要用于发送事件。 -
WebSocket:一种服务器和客户端的双向通信协议。用来实现实时通信。与SSE的区别是SSE不支持客户端发送事件到服务端。
新渲染器看起来很适合使用分块传输的方式发送html,因为html内容是不确定的,随着渲染过程的进行才慢慢确定。实际上React也是使用的分块传输技术。很简单,写个接口测试一下:
router.get("/streaming", async (ctx) => {
ctx.set("content-type", "text/html");
ctx.set("transfer-encoding", "chunked");
ctx.status = 200;
// 发送第一段html
ctx.res.write("<!DOCTYPE html><html><head><title>Streaming</title></head><body><div id='app'><p>First Segment</p>");
// 延迟3秒方便看效果
await new Promise((resolve) => setTimeout(resolve, 3000));
// 发送第二段html
ctx.res.write("<p>Second Segment</p></div></body></html>");
ctx.res.end();
});
浏览器上直接调用该接口,查看渲染出来的页面如下:
react-ssr-streaming
可以看到,现代浏览器对分块传输的html支持还是很好的,即使html内容不全,也自动补全了。并在后一段发送过来时追加上去。整个过程页面显示很平滑。
流式HTML + Suspense
基于此,React中在服务器端又是怎么区分哪些需要分块去传输呢?答案是Suspense
。
数据获取导致的Suspense
直接拿官方demo来说(可以自己fork后运行,如果不行就下载到本地)。主要的组件结构如下:
<Layout>
<NavBar />
<Sidebar />
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
页面包含顶部导航、左侧侧边栏、右侧内容区,右侧内容区包含上面的Post内容,和用Suspense包裹的Comments评论列表。如图:
Comments需要一个耗时的获取数据的操作,所以使用Suspense包裹。并且在SSR首次工作到这里时,由于Comments没有获取完成数据,所以它没有准备OK。Suspense将fallback的组件提交出去。最终SSR首次提交到客户端的stream Html如下:
<main>
<nav>
<!--NavBar -->
<a href="/">Home</a>
</nav>
<aside>
<!-- Sidebar -->
<a href="/profile">Profile</a>
</aside>
<article>
<!-- Post -->
<p>Hello world</p>
</article>
<section id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>
接着,当Comments终于完成了自己的渲染。React将发送另一段html并包含一段内联js脚本,来将新的内容替换到Spinner的位置。
<!-- 实际的Comment组件内容 -->
<div hidden id="comments">
<p>First comment</p>
<p>Second comment</p>
</div>
<script>
// 非常简化的实现
const commentsDiv = document.getElementById('comments');
document.getElementById('sections-spinner').replaceChildren(commentsDiv);
commentsDiv._reactRetry();
</script>
同时注意到最后一句commentsDiv._reactRetry()
,是为了重启Comment组件所属的Suspense
边界的渲染任务。后面讲到选择性水合时会讲解。
由此可见,我们在组件树中也可以插入任意多个Suspense的组件,这些组件会在自己内容准备OK的时候,独立的发送一段html及js到客户端以完成“修正”。它们不是自上而下的,下面的无需等待上面的组件渲染完成。
由于Suspense必须要知道组件数据准备OK的时机,所以数据获取逻辑必须要与Suspense打通。但是到目前为止,React还没有提供更合适的方式来打通它们。官方推荐使用Server Components(目前仍是实验性的)或者是三方的框架如Next.js、Relay等,它们可能会提供对应的封装。
查看官方的示例,它能实现上述效果,原理是组件在请求数据时抛出一个Promise
,这个抛出的Promise
会被上方的Suspense
组件捕获,此时Suspense组件就会认为子树还未准备好显示,就会输出fallback
的内容,从而让SSR继续。当这个Promise
被解决(resolve)时,Suspense
会重新渲染子树。注意:这个方法只是为了演示使用,不能用于生产防止意外情况!
代码分包导致的Suspense
React18之前,React.lazy
无法应用于SSR实现代码分包,如果之前希望SSR的同时,客户端还能代码分包。那就只能将需要代码分包的组件从SSR移除,并且在客户端Hydration之前加载这些js。那代码分包的意义何在呢?
但现在不存在这个问题了。用法是一样的,比如我们将SideBar
组件懒加载:
import { lazy } from 'react';
const Sidebar = lazy(() => import('./Sidebar'));
// ...
<Suspense fallback={<Spinner />}>
<Sidebar />
</Suspense>
// ...
此时,由于SideBar
组件懒加载。首次SSR渲染时,如果组件未准备OK,Suspense
会输出fallback内容,直到SideBar
组件加载完成再发送新的内容。
流式SSR存在降级逻辑,假如Suspense边界内的组件SSR抛出异常,SSR失败。那么客户端会接管这部分组件渲染逻辑。
选择性水合 + Suspense
React18之后,客户端支持了选择性水合,不再需要等待html及所有组件加载完成后一次性的执行Hydration过程。
原理是:客户端在执行Hydration阶段时,会将那些已经完成渲染的组件hydate,使之可以交互。遇到Suspense
组件时,判断它的子树是否已经完成了渲染,如果未完成则跳过hydrate。等待组件渲染完成后,再恢复这部分子树的hydrate。
分三种情况去讨论:
1. 客户端加载完成,客户端水合时,发现服务端还在分段传输
常见的情况就是服务端组件还在获取数据等待渲染,而客户端js都已经开始执行了。此时选择性水合与流式HTML协作,不管是由于数据获取还是代码分包导致html需要分段传输,都没有关系。水合时会跳过这些还没有html的组件。
前面的例子我们提到,Comment组件在获取到数据,延迟的渲染完成后重新发送了一段html和内联脚本,内联脚本最后一句就是调用dom元素上的_reactRetry()
。这个方法就是重启Suspense
边界的渲染任务的,因为这个边界内的子树有可能全部渲染完成了。我们搜索下源码,发现它是在beginWork阶段绑定到Suspense的dom元素上的。
// ReactFiberBeginWork.js
function beginWork(current, workInProgress) {
// ...
switch (workInProgress.tag) {
// ...
case SuspenseComponent:
// ...
updateSuspenseComponent();
// ...
}
// ...
}
function updateSuspenseComponent() {
// ...
if (/* ... */) {
updateDehydratedSuspenseComponent();
}
// ...
}
function updateDehydratedSuspenseComponent(suspenseInstance) {
// ...
if (isSuspenseInstancePending(suspenseInstance)) {
const retry = retryDehydratedSuspenseBoundary.bind(null, current);
registerSuspenseInstanceRetry(suspenseInstance, retry);
return null;
}
// ...
}
// ReactFiberConfigDOM.js
export function registerSuspenseInstanceRetry(
instance: SuspenseInstance,
callback: () => void,
) {
instance._reactRetry = callback;
}
总结一下,客户端选择性水合时,未加载html的Suspense的子树会延迟水合。等服务端流发送这段html后,再去重启渲染任务,完成水合。
2. 服务端渲染完成,客户端水合时,发现组件代码还缺失
常见的情况就是我们为了不让一次性加载的js过于庞大,会选择将部分组件进行代码分包。当服务端渲染已经完成时,客户端开始执行水合,发现部分组件代码还没加载。
这就是选择性水合
的另一个使用场景。上节中代码分包的例子中,SideBar
组件代码分包。当水合时,虽然它的ssr的html已经存在,但由于代码还没加载完成,仍然标记为未完成渲染。它外部的Suspense
会跳过它。从而让其他部分先参与水合。
当SideBar
的组件代码终于完成了加载,React会重新渲染并完成该部分的Hydration。
它的原理和前面《数据获取导致的Suspense》中,实现数据获取和重启渲染原理比较像。React.lazy
方法会生成一个LazyComponent
,在beginWork阶段
会挂载LazyComponent
,调用其_init
(代码位置)方法,从而开始加载组件,并抛出Promise。抛出的Promise会被Suspense
捕获,并在Promise被解决时,重新渲染。
// ReactFiberBeginWork.js
function beginWork(current, workInProgress) {
// ...
switch (workInProgress.tag) {
// ...
case LazyComponent:
// ...
mountLazyComponent();
// ...
}
// ...
}
function mountLazyComponent(current, workInProgress, elementType) {
// ...
const lazyComponent = elementType;
const payload = lazyComponent._payload;
const init = lazyComponent._init;
// Lazy组件挂载时开始加载真实组件
let Component = init(payload);
// ...
}
// ReactLazy.js
function lazy(ctor: () => Promise<{ default: T }>) {
return {
_payload: {
_status: -1,
_result: ctor
},
_init: function lazyInitializer(payload) {
const ctor = payload._result;
// 开始加载组件
const thenable = ctor();
// 组件加载完成后设置到payload里,让外部读取
thenable.then((module) => {
// ...
payload._result = module.default;
})
// 抛出Promise,被Suspense捕获
throw payload._result;
},
} as LazyComponent
}
总结一下,客户端选择性水合时,代码未加载完成的Suspense的子树会延迟水合。等组件代码加载完成后,再去重启渲染任务,完成水合。
3. 用户已经开始交互,客户端水合还没结束
React18的并发模式,让渲染任务分割成时间切片去执行,从而让浏览器有时间去处理用户交互。那么就有一种可能性发生,那就是服务端html已经显示出来,但是客户端还没有完成水合,这时候用户点击了未完成水合的组件按钮,怎么办?
还是之前的例子,SideBar
和Comments
组件都被Suspense包裹,假如它们的代码都已经加载完成,按照顺序,SideBar
会先被执行Hydration。如图:
假如用户点击了Comments
组件。
由于点击交互优先级最高。React将以同步优先级立刻hydrate该Comments
组件。
由于是同步的,Comments
组件hydrate结束后,能同步执行事件处理器,从而响应用户交互。
等它们都结束后,重新恢复SideBar
的Hydration。
原理是触发点击时,React判断当前组件是否被block,如果block了,则去同步的对该fiber执行Hydration(代码位置),等执行结束,再去实际的调用event回调。
水合过程是从HostRoot根组件、以及Suspense组件开始,它的粒度也是把这两类组件作为整体。即如果
Suspense
组件下的Comments
组件的第一个Comment被点击,那么这个Suspense
整体都会同步Hydration,而不是只水合第一个Comment。
全文总结
本文写的很长,从老的SSR原理、实现、存在的问题,讲到新SSR特性的原理、功能和解决的问题。讲得很多,可能需要一段时间才能消化。
最后再总结一下两个SSR主要特性:
-
流式HTML:让你可以尽早的发送一些html到客户端,并在其他耗时的内容准备ok后,再次发送到客户端,从而更快速、平滑的显示内容。
-
选择性水合:让你尽可能早的水合已经准备好的内容,使其可交互。并在其他内容准备好后立即水合。并且支持优先水合正在交互的部分。
再回顾一下React老SSR解决不了的三个问题,在新SSR特性下,是如何被解决的:
-
必须获取所有数据,才能显示内容
流式HTML的出现,让我们不再需要等待所有数据获取。
-
必须加载所有js,才能开始水合
水合不再需要所有js加载完成,所以我们可以使用代码分包,让较大的js块从初始的bundle分离出去,从而让水合更快开始。
-
必须完成整个水合过程,页面才能交互
水合不再是整体进行,而是可以部分完成。水合完成的部分可以交互。未完成的部分,如果用户与其交互,React会以最高优先级,立刻让其完成水合,并立刻响应交互。所以用户感觉它就是瞬间完成的。
可以试想下,老SSR的顺序串行,且相互依赖的fetch data => SSR => load js => Hydration低效运行模式,在新SSR特性下,转变为并行、异步、流式渲染的高效运行模式下,性能会有多大的提升。让我们拭目以待吧!
本文未介绍到的相关内容包括以下几部分,后续可能考虑单独出一些文章来介绍。
- renderToPipeableStream的实现原理
- Suspense的原理及其它特性
- 客户端Hydration的原理