客户端渲染
回顾一下,在浏览器当中一个网页是如何显示出来的
当浏览器向服务器发送请求获取 HTML 文件时,HTML 文件通常包含 <link>
和 <script>
元素,这些元素分别指向了外部的 CSS 样式表文件和 JavaScript 脚本文件。
这些文件被解析的顺序如下:
- 浏览器首先解析 HTML 文件,并从中识别出所有的
<link>
和<script>
元素,获取它们指向的外部文件的链接。 - 继续解析 HTML 文件的同时,浏览器根据外部文件的链接向服务器发送请求,获取并解析 CSS 文件和 JavaScript 脚本文件。
- 接着浏览器会给解析后的 HTML 文件生成一个 DOM 树,会给解析后的 CSS 文件生成一个 CSSOM 树,并且会编译和执行解析后的 JavaScript 脚本文件。
- 伴随着构建 DOM 树、应用 CSSOM 树的样式、以及执行 JavaScript 脚本文件,浏览器会在屏幕上绘制出网页的界面;用户看到网页界面也就可以跟网页进行交互了。
客户端渲染和服务端渲染都符合上述的过程,区别在于客户端渲染的 HTML 文件中是空白的,大部分内容是通过 JavaScript 修改 DOM 树生成的。
也就是当用户看到一个完整网页之前,客户端渲染会经历如下步骤
一个典型的客户端渲染网页如下所示:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/favicon.ico">
<link rel="modulepreload" crossorigin href="/assets/1f58954.js">
<link rel="modulepreload" crossorigin href="/assets/18400eb.js">
<link rel="modulepreload" crossorigin href="/assets/1edb978.js">
<link rel="modulepreload" crossorigin href="/assets/19ea55a.js">
<link rel="modulepreload" crossorigin href="/assets/1e1c3d6.js">
<link rel="modulepreload" crossorigin href="/assets/1f50164.js">
<link rel="modulepreload" crossorigin href="/assets/189b608.js">
<link rel="modulepreload" crossorigin href="/assets/17f1009.js">
<!-- ... -->
<link rel="stylesheet" href="/assets/1d09918.css">
<link rel="stylesheet" href="/assets/164e345.css">
<link rel="stylesheet" href="/assets/11584ae.css">
<link rel="stylesheet" href="/assets/1391c67.css">
<link rel="stylesheet" href="/assets/10163ac.css">
<!-- ... -->
</head>
<body>
<div id="app"></div>
</body>
</html>
网页必须等待所有的JavaScript文件和CSS文件下载完成,才能开始渲染页面。
客户端渲染的问题在哪
-
慢 由于只有一个空白的html文件,只有当所有打包之后的JavaScript文件下载完成,才开始渲染页面。因此在低网速和低性能设备上首屏渲染的速度更慢。而且会出现 白屏的情况
-
SEO SEO是指搜索引擎优化,是指通过了解搜索引擎的运作规则,调整网站内部和外部的优化,提高网站在搜索引擎中的自然排名。 由于大部分搜索引擎的爬虫是直接抓取 HTML 页面的,尽管搜索引擎可以执行同步的JavaScript,但是由于ajax等技术的发展。网页的部分内容是异步动态渲染的,这就会导致搜索引擎无法抓取到完整的网页内容
定义服务端渲染
和客户端渲染不同,服务端渲染选择在服务器上直接组装好html文件,提供用户下载。用户只要下载完成html文件,经过解析之后就能看到界面
服务端渲染的流程如下:
可以看到和客户端的主要区别在于,服务端一开始得到的就是一个在服务器上组装好的的html文件。
因此,服务端渲染的优势就是客户端渲染的劣势
React 服务端渲染实现
React提供了一个react-dom/server
模块,用于在服务器端将 React Node 渲染为静态Html文件
import { renderToString } from "react-dom/server"
app.get('/', async (ctx) => {
// 将JSX编译为JavaScript
await build();
// 将React组件渲染为HTML
const Page = (await import("./build/page.js")).default;
const html = renderToString(createElement(Page));
// 发送给客户端
return ctx.html(html)
})
流式渲染
React 提供了 renderToPipeableStream
和renderToReadableStream
方法将 React 组件渲染为流式数据
前者pipeable stream
是 Node.js 中特有的流。而Readable Stream
是 Web Streams API
下面展示如何将一个React组件渲染为流式数据
// page.tsx
import { Album } from "./album";
import { Suspense } from "react";
export default function Page() {
return (
<html>
<h1>Pop Music</h1>
<input type="text" />
<Suspense fallback="loading">
<Album />
</Suspense>
</html>
);
}
以上是一个简单的 React 组件,它包含了一个Album
组件,Album
组件是一个异步组件,其中会通过网络请求一些数据,在数据请求完成之前,Album 组件会被挂起,显示一个loading的文本
export async function Album() {
const response = await fetch("https://jsonplaceholder.typicode.com/albums");
const albums = await response.json();
await new Promise((resolve) => setTimeout(resolve, 2000));
return (
<div>
<h1>Albums</h1>
<ul>
{albums.map((album: any) => (
<li key={album.id}>{album.title}</li>
))}
</ul>
</div>
);
}
在 <Suspense>
组件之外的内容,我们称之为Shell, 用户会第一时间看到 Shell 中的内容。在所有的shell内容准备好之后,会调用 onShellReady
, 将流式数据传递给客户端
app.get('/', async (ctx) => {
// 编译 jsx
await build();
// 导入组件
const Page = (await import("./build/page.js")).default;
// 创建一个 react 元素
const comp = createElement(Page);
// 将组件渲染为流式数据
const { pipe } = renderToPipeableStream(comp, {
onShellReady() {
// 等待所有的shell内容准备好之后,将流式数据通过 pipe 传递给客户端
pipe(...)
},
})
})
尽管目前Node.js已经支持 Web Streams API 但是不知道为什么在Node.js环境中,react-dom/server
没有导出renderToReadableStream()
方法。所以这里需要将 pipeable stream 转换为 readable stream
import { Readable, Transform } from 'node:stream';
app.get('/', async (ctx) => {
// 编译 jsx
await build();
// 导入组件
const Page = (await import("./build/page.js")).default;
// 创建一个 transform stream
const transform = new Transform({
transform(chunk, encoding, callback) {
this.push(chunk.toString())
callback();
},
})
// 创建一个React组件
const comp = createElement(Page);
// 将组件渲染为流式数据
const { pipe } = renderToPipeableStream(comp, {
onShellReady() {
pipe(transform)
},
})
// 将node stream 转换为 web stream 发送给客户端
const stream = Readable.toWeb(transform) as ReadableStream<BodyData>;
return new Response(stream, {
"headers": {
"Content-Type": "text/html",
"Transfer-Encoding": "chunked"
}
})
})
使用 curl 命令测试
curl -v http://localhost:3000
可以明显的观察到,首页我们收到了 Shell,然后当<Album>
组件准备好之后,我们收到了<Album>
组件的内容。不需要等待所有数据获取完毕就能看到页面
水合
以上只是渲染了 React → HTML的过程,但是页面还不具有交互性。为了让页面具有交互性,需要进行 "水合",也就是将给DOM节点绑定各种事件以及添加状态。
在react-dom/client
中提供了hydrateRoot
方法,用于将服务端渲染的内容与客户端渲染的内容进行同步
import { hydrateRoot } from 'react-dom/client';
import App from './App.js';
hydrateRoot(
document.getElementById('root'),
<App />
);