16-Vite搭建 SSR 框架
# 0.前言
本节博客代码仓库 (opens new window) ,使用
Vite
从零开始搭建一个SSR
框架
# 1.概念
# 问题1:CSR 存在的问题,SSR 的优势?
发展历史:
早期使用的是
JSP
模板语法编写页面,存放在服务端,在服务端填入数据并渲染完整页面,此种做法为天然的服务端渲染。随着
Ajax
页面无刷技术,以及各种前端框架Vue/React
,自此进入到前后端分离时代,此时是由浏览器处理页面的渲染【Client Side Render
客户端渲染】。随着客户端渲染发展到一定阶段,对首屏性能以及 SEO 的要求越来越高,
Nuxt/Next
等服务端框架,以及vuepress/vitepress/island
等SSG
框架又火起来了。现今,前端轮子已经造到了一个极致,每天都有大量的新技术出现,而这些技术的更新是需要成本的,由此产生出各种元框架,国外有
Next/Astro/Remix
等,国内有umi/morden/icejs
等,其核心目的就是帮助完成基础技术选型,提供一揽子服务,由框架层面负责对底层技术的维护及更新,如路由(文件式路由系统)、编译工具(vite/webapck/turbopack....),样式(less/sass/unocss....),状态管理(dva/redux)。开发者只需面向业务开发,在框架约定的特定位置特定文件处进行编码即可,框架会读取这些文件并转化为真实浏览器可执行代码。
CSR 存在的问题:
以下为客户端渲染模式的 HTML
结构:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
<link rel="stylesheet" href="xxx.css" />
</head>
<body>
<!-- 一开始没有页面内容 -->
<div id="root"></div>
<!-- 通过 JS 执行来渲染页面 -->
<script src="xxx.chunk.js"></script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
可以发现没有任何数据,客户端如果先把数据渲染在页面上,需要经过如下两步:
- 拉取远端的
js
代码,比如基于React
编写的页面组件。 - 执行代码,拉取远端数据,并通过
React.render
等方式将组件渲染到页面上。
这样做存在的问题:
- 首屏加载速度比较慢,在网络不佳的场景下,会有一段空白期。
- 对搜索引擎优化不优化,无法被搜索引擎爬虫获取信息。
**SSR 的特性 **
优点:
解决
CSR
存在的两个问题。直接返回带有数据的
html
信息,并且在博客等纯静态页面而言,可退化为SSG
模式。
缺点:
- 服务端的压力比较大。
- 只解决首屏加载问题,但此时不具备交互能力,需要在注水
hydrate
后生效。由于一套代码同时在客户端 + 服务端进行渲染,因此SSR
应用也被称为 同构应用。
# 问题2:SSR 的两大生命周期是什么?
SSR
应用有两大生命周期:构建时
和 运行时
构建阶段:打包两份代码
csr-entry
:就是将React.hydrate
代替原有入口React.createRoot/render
ssr-entry
:本质就是暴露出renderToString
组件,准备两份东西(首屏数据+组件html
格式)
运行阶段:服务器(案例中使用 express
搭建)
- 基础:需要准备一份模板
html
代码,后续需通过ssr-entry
拼接出完整的html
代码。 - 实现方案:本案例需要接入
vite
中间件机制,如果是开发阶段,可通过vite.ssrLoadModule
导入ssr-entry
代码。若是生产阶段,则直接require("dist/server/ssr-entry.js")
代码。
详细的实现细节,见后续的代码实践。
# 2.使用 Vite 搭建 SSR 框架
# 问题3:基础的 SSR 骨架
客户端入口:cient-entry.tsx
// 客户端入口文件
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
/* 注此时,渲染方式不再是 ReactDOM.creatRoot 或 render */
ReactDOM.hydrate(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root"),
);
2
3
4
5
6
7
8
9
10
11
12
13
服务端入口:ssr-entry.tsx
服务端与客户端入口文件的差异是,服务端只需要提供
html
+css
,为后续拼接html
做原始素材。因此client-entry
打包后的产物可以移出掉css
资源文件。
import App from "./App";
import "./index.css";
export function ServerEntry(props: any) {
return <App />;
}
export async function fetchData() {
return {
user: "xxx",
};
}
2
3
4
5
6
7
8
9
10
11
12
基于 Express
框架编写的后端服务代码:
import express from 'express';
async function createServer() {
const app = express();
// 通过中间件的方式嵌入核心处理逻辑
app.use(await createSsrMiddleware(app));
app.listen(3000, () => {
console.log('Node 服务器已启动~')
console.log('http://localhost:3000');
});
}
createServer();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 问题4:中间件是如何拼接出 html
的?【生产版】
首先讨论生产阶段的代码实践(和仓库中的最终代码有差别),因为生产阶段不需要处理开发阶段的冗余功能(静态服务器、hmr
)
将 html
代码=》通过 ssr-entry
中取出 fetchData + renderToString
=》拼接出完整的 html
=》最后返回给浏览器(res.end(html)
)
/* 中间件 */
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
return async (req, res, next) => {
try {
/* 获取 template html */
const templatePath = path.join(cwd, "dist/client/index.html")
let template = fs.readFileSync(templatePath, "utf-8");
/* 浏览器地址:处理如 `localhost:3000/` */
const url = req.originalUrl;
/* 传入 vite , 在生产阶段为 null */
/* 1、加载 ssr-entry.js 文件: */
const { ServerEntry, fetchData } = await import(path.join(cwd, "dist/server/ssr-entry.js"))
const data = await fetchData();
const appHtml = renderToString(
React.createElement(ServerEntry, { data }),
);
/* 2、手动模板引擎 */
const html = template
.replace("<!-- SSR_APP -->", appHtml)
.replace(
"<!-- SSR_DATA -->",
`<script>window.__SSR_DATA__=${JSON.stringify(data)}</script>`,
);
/* 3、通过中间件给客户端返回 html 字符 */
res.status(200).setHeader("Content-Type", "text/html").end(html);
} catch (e: any) {
vite?.ssrFixStacktrace(e);
console.error("ssr端构建失败:", e);
res.status(500).end(e.message);
}
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 问题5:中间件是如何拼接出 html
的?【开发版】
开发版相对于生产版多了很多功能,主要有如下:
手动创建
vite-dev-server
,特别注意的是express
服务端是通过接入vite
中间件形式接入的,如app.use(vite.middlewares)
。if (!isProd) { vite = await ( await import("vite") ).createServer({ root: cwd, server: { middlewareMode: true, }, appType: "custom", }); /* 将 vite 内部的中间件继承到 express 中间件 */ app.use(vite.middlewares); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14改造文件获取入口,主要是处理
template html
以及ssr-entry.tsx
这两部分。
html
的处理
/* html 文件不会发生改变,只是位置挪了下 */
export function resolveTemplatePath() {
return isProd
? path.join(cwd, "dist/client/index.html")
: path.join(cwd, "index.html");
}
2
3
4
5
6
ssr-entry
的处理
export async function loadSsrEntryModule(vite: ViteDevServer | null) {
if (isProd) {
/* 生产模式:直接从 dist 取出构建后的 js 文件 */
const entryPath = path.join(cwd, "dist/server/ssr-entry.js");
return import(entryPath);
} else {
/* 如果是开发模式,代码就在 client-entry下 */
const entryPath = path.join(cwd, "src/ssr-entry.tsx");
/* 使用 vite.ssrLoadModule 传入的模块无需打包,vite 帮会自动将依赖构建打包成 js 可运行的文件 */
return vite!.ssrLoadModule(entryPath);
}
}
2
3
4
5
6
7
8
9
10
11
12
开发阶段,这里可以使用 vite.ssrLoadModule
这个 api
导入 ts
模块。
开发阶段如何处理静态文件,如
css
、js
以及图片?注意,前面已使用
app.use(vite.middlewares)
接入代码,且自动享有vite-dev-server
静态服务器的能力,我们只需要在中间中改造路由即可:/* 如浏览器访问: localhost:3000/ 地址 */ const url = req.originalUrl; /* 1. 对于 `/` 路径,则返回 `html` 代码 */ if (!matchPageUrl(url)) { /* 2. 对于 http:localhost:3000/src/csr-entry.js 等静态资源,则 next 接入静态服务器 */ return await next(); }
1
2
3
4
5
6
7处理
React
的热更新处理。/* 若为开发阶段 */ if (!isProd && vite) { /* html 代码由 vite.transformIndexHtml 获取 */ template = await vite.transformIndexHtml(url, template); }
1
2
3
4
5可以观察
vite.transformIndexHtml
构建后的结果<head> <script type="module"> import RefreshRuntime from "/@react-refresh" RefreshRuntime.injectIntoGlobalHook(window) window.$RefreshReg$ = () => {} window.$RefreshSig$ = () => (type) => type window.__vite_plugin_react_preamble_installed__ = true </script> <script type="module" src="/@vite/client"></script> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite App</title> </head> <body> <div id="root"><!-- SSR_APP --></div> <script type="module" src="/src/client-entry.tsx"></script> <!-- SSR_DATA --> </body> </html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22由于存在
plugin-react
插件,因此通过vite.transformIndexHtml
会在html
头部注入react
热更新相关代码。
# 3.SSR工程化需要关注的点
# 问题6:如何处理路由?
暂时没有代码验证
# 问题7:如何处理状态?
ssr
状态处理的逻辑,在 ssr-entry
中获取数据,将 <!-- SSR_DATA -->
转译为<script>window.__SSR_DATA__=${JSON.stringify(data)}</script>
,流程如下:
也就是使用 window
对数据进行共享:
const data = window.__SSR_DATA__;
ReactDOM.hydrate(
<App data={data} />,
document.getElementById("root"),
);
2
3
4
5
# 问题8:需要对 CSR
进行降级?
在某些比较极端的情况下,我们需要降级到 CSR,也就是客户端渲染。一般而言包括如下的降级场景:
数据预处理失败,保证客户端也具有获取数据的能力。
import { fetchData } from "./ssr-entry"; const data = window.__SSR_DATA__ ?? fetchData();
1
2服务器出错,需要在模板中返回兜底的
CSR
模板,完全降级。async function createSsrMiddleware(app: Express): Promise<RequestHandler> { return async (req, res, next) => { try { // SSR 的逻辑省略 } catch(e: any) { vite?.ssrFixStacktrace(e); console.error(e); // 响应 CSR 模板内容 const templatePath = path.join(cwd, "dist/client/index.html"); let template = fs.readFileSync(templatePath, "utf-8"); res.status(500).setHeader("Content-Type", "text/html").end(template); } } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14当
localhost:3000/?csr
本地开发时,具备跳过SSR
能力,仅进行CSR
。if ("csr" in req.query) { // 响应 CSR 模板内容, 在开发模式中,需要 vite.transformIndexHtml 转换后返回。 const html123 = await vite?.transformIndexHtml(url, template); res.status(200).setHeader("Content-Type", "text/html").end(html123); return; }
1
2
3
4
5
6
# 问题9:如何自定义 Head
头?
# 问题10:流式渲染?
ssr
生成 html
也是需要一定时间的,框架需要具备 边渲染边响应 的能力。
在 React
中使用 renderToNodeStream
实现流式渲染的能力,需要将 res.stats().end(html)
改为如下代码:
// 占位符:不会写......后续填坑
# 问题11: SSR 缓存
SSR
需要实时返回渲染的 html
页面,需尽可能减少 CPU
密集型操作,具体解决思路就是缓存:
服务器内存。需额外阅读缓存淘汰方案
lru-cache
(基于LRU
算法)如读取文件时,避免重复磁盘:
// 高阶函数 function crateMemoReadFile(){ const fileConentMap = new Map(); // 使用键值对缓存文件的 key return async (filePath) => { // 1. 先看有没有缓存 const cacheResult = fileContentMap.get(filePath); if(cacheResult) return cacheResult; // 2. 如果没有缓存,则存进去 const fileContent = await fs.readFile(filePath); fileContentMap.set(filePath, fileContent); return fileContent; } }; // 这里高阶函数,主要目的是:为了使用闭包创建独立的 map 对象 const memoReadFile = createMemoReadFile(); memoReadFile('/filePath'); // 直接复用缓存 memoReadFile('/filePath');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21Redis
数据库缓存。CDN
对静态资源缓存,可将资源信息提前存到OSS
服务器上。
# 问题12:性能监控
在实际项目中,会遇到线上新能问题,需要简历一个完整的性能监控机制,常用指标如下:
SSR
产物构建时间。(仓库有示例)- 数据预获取的时间。
- 组件渲染的时间。
- 服务端接受请求到响应的时间。
SSR
命中缓存情况。SSR
成功率、错误日志。
具体可见另一篇博客《perf_hooks》 对 node
内置的模块的一个简单上手。
# 问题13:如何将 SSR
退化为 SSG
?
具体可见 hello-island-dev
项目代码 (opens new window),此项目为一个博客站点类框架。
此框架在 build
阶段将 md
对应的路由基于 mpa
模式生成响应的html
(拼接后) ,并通过 island
架构对 SSG
模式进行优化。