来自掘金小册 深入浅出 Vite 的学习实践与总结
概述
名称解释
- CSR:客户端渲染(Client Side Render)
- SSR:服务端渲染(Server Side Render))
- SSG:静态站点生成(Static Site Generation)
SSR 所能解决的问题
- 首屏加载速度比较慢:一般都是依赖于 JS 来渲染页面内容,那么就得等 JS 文件加载完毕,然后还要执行等等,都需要时间
- SEO(搜索引擎优化)不友好的问题: 基于 SPA 的页面,返回的 HTML 基本上没有什么具体的内容可以让爬虫去处理,那么就会导致网站排名不行
SSR 的应用
- 在服务端只能生成页面的内容,而无法完成事件的绑定,这个是需要去到浏览器中进行处理的,处理好后,页面就有了交互功能,这个过程被称为 hydrate(注水或者激活)
- SSR + CSR hydrate 的应用被称为同构应用
SSR 生命周期
SSR 应用的两大声明周期为构建时与运行时。
构建时
首先是构建时需要做的事情:
- 解决模块加载问题。在原有的构建过程中加入 SSR 构建的过程,生成一份能在 NodeJS 中执行的产物(CommonJS)。
- 移除样式代码的引入语句。如果是在文件中直接引入的 CSS 模块,则服务端无法解析,除了 CSS Modules,因为它是一个映射对象。
- 依赖外部化(external) 。对于某些第三方的依赖可以直接从 node_modules 中读取,提高 SSR 的构建速度。
运行时
运行时可以拆分为几个固定的生命周期阶段:
- 加载 SSR 入口模块。获取组件入口的模块,进行加载,例如
App.vue。 - 进行数据预取。在服务端通过直接查询数据库或者调用接口获取页面所需的数据。
- 渲染组件。将第 1 步获取到的组件渲染成 HTML 字符串或者 Stream 流。
- HTML 拼接。将渲染好的组件内容拼接成完整的 HTML 字符串,返回给客户端进行展示。
搭建基础的 SSR 项目
使用 Vite 创建一个基础的项目,将 main.tsx 删除掉,分别创建出客户端与服务端的入口文件。
// entry-server.tsx
import App from './App';
import './index.css';
/**
* 服务端入口组件
*/
function serverEntry(params: User) {
return <App data={params} />;
}
/**
* 页面初始化时需要的数据
*/
async function fetchData(): Promise<User> {
return {
name: '金小钗',
};
}
export { serverEntry, fetchData };
entry-client.tsx:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { User } from './entry-server';
// @ts-ignore
// 服务端渲染时,注入的数据
const data = window.__SSR_DATA__ as User;
ReactDOM.hydrate(
<React.StrictMode>
<App data={data} />
</React.StrictMode>,
document.getElementById('root')
);
修改 index.html 文件内容,打上标志,用于服务端生成 HTML。
<html lang="en">
<!-- 省略内容... -->
<body>
<div id="root"><!-- SSR_APP --></div>
<script type="module" src="/src/entry-client.tsx"></script>
<!-- SSR_DATA -->
</body>
</html>
搭建后台服务,编写 SSR 处理逻辑:
// ssr-server/index.ts
/**
* 创建 vite ssr 中间件
*/
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
let vite: ViteDevServer | null = null;
if (!isProd) {
// 处于开发环境时,创建 vite 开发服务器
vite = await (
await import('vite')
).createServer({
root: process.cwd(),
server: {
middlewareMode: 'ssr',
},
});
// 注册 Vite Middlewares,主要用来处理客户端资源
app.use(vite.middlewares);
}
return async (req, res, next) => {
try {
const { originalUrl } = req;
if (!matchPageUrl(originalUrl)) {
// 当前请求的是静态资源,则不进行处理
return next();
}
// 1.加载服务端入口组件模块
const { serverEntry, fetchData } = await loadSsrEntryModule(vite);
// 2.数据预取
const data: User = await fetchData();
// 3.渲染服务端组件,转换为 HTML 字符串
const appHtml = renderToString(
React.createElement(serverEntry, { data })
);
// 4.拼接完整的 HTML 字符串返回给客户端
const templatePath = resolveTemplatePath();
let template = fs.readFileSync(templatePath, 'utf-8');
if (!isProd && vite) {
// 当前环境为开发环境
// 所以需要注入 HMR、环境变量相关的代码
template = await vite.transformIndexHtml(originalUrl, template);
}
const html = template
.replace('<!-- SSR_APP -->', appHtml)
// 注入数据标签,用于客户端注水
.replace(
'<!-- SSR_DATA -->',
`<script>window.__SSR_DATA__=${JSON.stringify(data)}</script>`
);
res.status(200).setHeader('Content-Type', 'text/html').end(html);
} catch (error: any) {
vite?.ssrFixStacktrace(error);
console.error(error);
res.status(500).end(error.message);
}
};
}
async function createServer() {
const app = express();
// 加入 vite ssr 中间件
app.use(await createSsrMiddleware(app));
// 用于处理静态资源
if (isProd) {
// serveStatic 来自第三方包 serve-static
app.use(serveStatic(path.join(process.cwd(), 'dist/client')));
}
app.listen(5004, () => {
console.log('服务器已启动');
console.log('http://localhost:5004');
});
}
createServer();
ssr-server/util.ts:
/**
* 根据环境加载服务端入口模块
* @param vite vite 开发服务器
*/
export async function loadSsrEntryModule(vite: ViteDevServer | null) {
if (isProd) {
// 生产环境:使用 require 加载打包好的模块
const entryPath = path.join(cwd, 'dist/server/entry-server.js');
return require(entryPath);
} else {
// 开发环境:使用 vite 开发服务器加载模块
const entryPath = path.join(cwd, 'src/entry-server.tsx');
return vite!.ssrLoadModule(entryPath);
}
}
/**
* 根据环境获取 index.html 的路径
*/
export function resolveTemplatePath() {
return isProd
? path.join(cwd, 'dist/client/index.html')
: path.join(cwd, 'index.html');
}
给项目添加一些脚本:
nodemon:监听文件变化然后自动重启服务器
esno:用于 Node 环境执行 TS 文件,底层使用 Esbuild 实现
// package.json
{
"scripts": {
// 开发环境
"dev": "nodemon --watch src/ssr-server/index.ts --exec esno src/ssr-server/index.ts",
// 编译客户端产物
"build:client": "tsc && vite build --outDir dist/client",
// 编译服务端产物
"build:server": "tsc && vite build --ssr src/entry-server.tsx --outDir dist/server",
// 预览生产环境产物的效果
"preview": "cross-env NODE_ENV=production esno src/ssr-server/index.ts"
}
}
运行npm run dev,访问服务端地址,可以看到返回的 HTML 内容如下:
总结
项目搭建思路整理
SSR 应用工程化问题
SSR 应用在实际的应用场景中还有不少工程化相关的问题需要注意,主要有:
- 路由管理
- 全局状态管理
- CSR 降级
- 浏览器 API 兼容
- 自定义
- 流式渲染(边渲染边响应)
- SSR 缓存
- 性能监控
- SSG/ISR/SPR
路由管理
在 SPA 场景下,不同的前端框架都有对应的路由解决方案。但路由方案在 SSR 过程中所执行的功能是差不多的:
- 告诉框架现在渲染哪个路由
-
- Vue 使用
router.push确定渲染的路由 - React 通过 StaticRouter 配合 location 参数来确定渲染的路由
- Vue 使用
- 设置 base 前缀。规定路径的前缀,如 vue-router 中 base 参数、react-router 中 StaticRouter 组件的 basename
全局状态管理
全局状态管理(VueX、Pinia、Redux)接入 SSR 的思路大概是这样:在预取数据阶段初始化服务端的store,将一部获取的数据存入store中,然后在拼接 HTML 时将数据从 store 中取出放到数据 script 标签中,最后在客户端 hydrate 的时候通过 window 访问到预取数据。
- 不同的请求需要分别创建各自的
store,避免造成全局状态污染问题
CSR 降级
在某些比较极端的情况下,需要降级到 CSR,主要包括以下场景:
- 服务端预取数据失败,降级到客户端获取数据
// 服务端如果没有注入到数据,则需要自己发起请求去获取数据
const data = window.__SSR_DATA__ ?? await fetchData();
ReactDOM.hydrate(
<React.StrictMode>
<App data={data} />
</React.StrictMode>,
document.getElementById('root')
);
- 服务器出现异常,需要返回兜底的 CSR 模板,完全降级为 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 模板内容
}
}
}
- 本地开发调试,有时需要跳过 SSR,仅进行 CSR
通过在 URL 上面加参数,来强制跳过 SSR:
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
return async (req, res, next) => {
try {
if (req.query?.csr) {
// 响应 CSR 模板内容
return;
}
// SSR 处理逻辑
} catch(e) {
}
}
}
浏览器 API 兼容
在服务端中无法使用window、document等一些 API,所以需要规避这些 API 的使用或注入 polyfill
- 通过 VIte 内置变量
import.meta.env.SSR进行判断是否存在 SSR 环境
if (import.meta.env.SSR) {
// 处在服务端环境,需要规避浏览器 API 的使用
} else {
// 处在客户端环境,可以访问浏览器的 API
}
- 使用 jsdom 注入 polyfill
const jsdom = require('jsdom');
const { window } = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
const { document } = window;
// 挂载到 node 全局
global.window = window;
global.document = document;
自定义
根据组件状态动态修改<head>中的内容,React 提供了 react-helmet,Vue 则提供了 vue-meta 来解决这个问题。
react-helmet 的使用如下:
// App.tsx
// 省略了一些代码...
import { Helmet } from 'react-helmet';
function App() {
return (
<Helmet>
<title>{name}的页面</title>
</Helmet>
);
}
index.html:
<html lang="en">
<head>
<!-- SSR_APP_TITLE -->
</head>
</html>
ssr-server/index.ts:
import Helmet from 'react-helmet';
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
return async (req, res, next) => {
try {
// 生成 <head> 标签的内容
const helmet = Helmet.renderStatic();
// 在拼接完整 HTML 内容的地方进行处理即可
const html = template
.replace('<!-- SSR_APP_TITLE -->', helmet.title.toString())
} catch () {}
};
}
现在就可以根据组件状态动态修改<title>了:
流式渲染
流式渲染是指页面边渲染边响应,而不是等整个组件树渲染完毕之后再响应,这样可以让响应提前到达客户端,提升了首屏加载速度。
React 提供了 rendertonodestream(官方文档显示已弃用),Vue 则提供了 renderToNodeStream。
大概使用方式:
import { renderToNodeStream } from 'react-dom/server';
// 返回一个 Nodejs 的 Stream 对象
const stream = renderToNodeStream(element);
let html = ''
stream.on('data', data => {
html += data.toString()
// 发送响应
})
stream.on('end', () => {
console.log(html) // 渲染完成
// 发送响应
})
stream.on('error', err => {
// 错误处理
})
流式渲染是存在限制的,如果需要在 HTML 中填入一些与组件状态相关的内容,则不能使用流式渲染。例如上面提到的<head>内容自定义,即便在渲染组件的时候收集到了<head>信息,但在流式渲染中,此时 HTML 的<head>部分已经发送给浏览器了,而这部分响应内容已经无法更改,因此 react-helmet 在 SSR 过程中将会失效。
SSR 缓存
SSR 是一种典型的 CPU 密集型操作,为了尽可能降低线上机器的负载,所以需要缓存。缓存的内容可以分为这么几个部分:
- 文件读取缓存。将磁盘的 IO 操作结果缓存起来,尽量减少重复读取磁盘的操作。
- 预取数据缓存。对于某些实时性不高的接口数据,采取缓存的策略,在下次相同的请求进来时复用之前预取数据的结果,减少预取数据过程的各种 IO 消耗,也可以一定程度上减少首屏时间。
- HTML 渲染缓存。将拼接好的 HTML 内容进行缓存,减少组件渲染成 HTML 等相关的操作,提高服务器性能。
缓存位置选择:
- 服务器内存。如果是放到内存中,需要考虑缓存淘汰机制,防止内存过大导致服务宕机,一个典型的缓存淘汰方案是 lru-cache (基于 LRU 算法)。
- Redis。NoSQL 数据库。
- CDN 服务。将页面内容缓存到 CDN 服务上,减少消费源服务器的资源,相关文章:juejin.cn/post/688788…
Vue 中另外实现了组件级别的缓存,这部分缓存一般放在内存中,可以实现更细粒度的 SSR 缓存。
性能监控
搭建一个完整的性能监控机制,用于发现和排查 SSR 应用的性能问题,性能数据的一些通用指标如下:
- SSR 产物加载时间
- 数据预取的时间
- 组件渲染的时间
- 服务端接受请求到响应的完整时间
- SSR 缓存命中情况
- SSR 成功率、错误日志
使用perf_hooks完成数据采集:
import { performance, PerformanceObserver } from 'perf_hooks';
// 初始化监听器逻辑,用于性能监控
const perfObserver = new PerformanceObserver((items) => {
items.getEntries().forEach((entry) => {
console.log('[performance]', entry.name, entry.duration.toFixed(2), 'ms');
});
performance.clearMarks();
});
perfObserver.observe({ entryTypes: ['measure'] });
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
return async (req, res, next) => {
try {
//* 3.渲染服务端组件,转换为 HTML 字符串
performance.mark('render-start');
const appHtml = renderToString(
React.createElement(serverEntry, { data })
);
performance.mark('render-end');
performance.measure('renderToString', 'render-start', 'render-end');
} catch () {}
};
}
访问页面后,控制台输出:
[performance] renderToString 8.27 ms
[performance] renderToString 6.59 ms
SSG/ISR/SPR
某些博客、文档等页面都是静态的,不设计数据动态变化,所以在构建时就将 HTML 文件都打包好,部署即可,这就是 SSG。
SSG 与 SSR 最大的区别就是产出 HTML 的时间点从运行时变成了构建时,但核心的生命周期流程并没有变化:
SPR(Serverless Pre Render)与 ISR(Incremental Site Rendering)都是基于 SSR 和 SSG 进行延伸的一种解决方案。
- SPR:把 SSR 的服务部署到 Serverless(FaaS) 环境中,实现服务器实例的自动扩缩容,降低服务器运维的成本
- ISR:增量站点渲染,将一部分的 SSG 逻辑从构建时搬到了 SSR 运行时,解决的是大量页面 SSG 构建耗时长的问题