vite 搭建服务端渲染(SSR)工程
SSR 概念
SSR(Server-side Rendering,服务器端渲染)是指将前端页面的渲染过程从浏览器端转移到服务器端,将页面的 HTML 代码在服务器端生成然后返回给客户端。
主要目的是提高页面的首屏渲染速度,因为浏览器端进行渲染需要下载 HTML、CSS、JavaScript 等文件,经过解析、编译、执行等环节,非常耗时,而服务器端渲染可以直接输出已经渲染好的 HTML,很快就能够呈现在客户端浏览器上。另一方面也能将完整的页面内容展现给搜索引擎的爬虫,利于 SEO。
SSR 中只能生成页面的内容和结构,并不能完成事件绑定,因此需要在浏览器中执行 CSR(客户端渲染) 的 JS 脚本,完成事件绑定,让页面拥有交互的能力,这个过程被称作 hydrate(水合)。
同样的像这种在服务端和客户端双端渲染的方式也叫做同构渲染。
SSR 生命周期
SSR 的生命周期可以分为构建时和运行时
构建时:
解决模块加载问题,在原有的构建过程之外,需要加入 SSR 构建的过程 ,另外生成一份 CommonJS 格式的产物,使之能在 Node.js 正常加载。随着 Node.js 本身对 ESM 的支持越来越成熟,也可以复用前端 ESM 格式的代码,Vite 在开发阶段进行 SSR 构建也是这样的思路。
移除样式代码的引入,Node.js 并不能解析 CSS 的内容。但是 css module 例外:
import styles from "./index.module.css";
// 这里的 styles 是一个对象,如{ "container": "xxx" },而不是 CSS 代码
console.log(styles);- 依赖外部化(external)。对于某些第三方依赖并不需要使用构建后的版本,而是直接从 node_modules 中读取,比如 react-dom,这样在 SSR 构建的过程中将不会构建这些依赖,从而极大程度上加速 SSR 的构建。
运行时:
- 加载 SSR 入口模块。确定 SSR 构建产物的入口,即组件的入口在哪里,并加载对应的模块。
- 进行数据预取.
- 渲染组件.
- HTML 拼接 拼接完整的 HTML 字符串,并将其作为响应返回给浏览器。
基于 Vite 搭建 SSR 项目
SSR 构建 API
- 开发环境 vite.ssrLoadModule 传入入口模块路径
// 加载服务端入口模块
const xxx = await vite.ssrLoadModule("/src/entry-server.tsx");- 生产环境 Vite 会默认进行打包,通过 package.json 配置
{
"build:ssr": "vite build --ssr 服务端入口路径"
}项目骨架搭建
src 目录新建 entry-client.tsx 和 entry-server.tsx
// entry-client.ts
// 客户端入口文件
import React from "react";
import { hydrateRoot } from "react-dom/client";
import "../index.css";
import App from "./App";
// @ts-ignore
const data = window.__SSR_DATA__;
const root = document.getElementById("root") as Element;
hydrateRoot(
root,
<React.StrictMode>
<App data={data} />
</React.StrictMode>
);
// entry-server.ts
// 导出 SSR 组件入口
import App from "./App";
import "./index.css";
function ServerEntry(props: any) {
return <App />;
}
export { ServerEntry };SSR 运行时实现
以中间件的形式实现
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
let vite: ViteDevServer | null = null;
if (!isProd) {
vite = await (
await import("vite")
).createServer({
root: process.cwd(),
server: {
middlewareMode: "ssr",
},
});
// 注册 Vite Middlewares
// 主要用来处理客户端资源
app.use(vite.middlewares);
}
return async (req, res, next) => {
// SSR 的逻辑
// 1. 加载服务端入口模块
// 2. 数据预取
// 3. 「核心」渲染组件
// 4. 拼接 HTML,返回响应
};
}
async function createServer() {
const app = express();
// 加入 Vite SSR 中间件
app.use(await createSsrMiddleware(app));
app.listen(3000, () => {
console.log("Node 服务器已启动~");
console.log("http://localhost:3000");
});
}
createServer();第一步,加载服务端入口模块
区分生产环境和开发环境,不同加载方式
async function loadSsrEntryModule(vite: ViteDevServer | null) {
// 生产模式下直接 require 打包后的产物
if (isProd) {
const entryPath = path.join(cwd, "dist/server/entry-server.js");
return require(entryPath);
}
// 开发环境下通过 vite.ssrLoadModule no-bundle 方式加载
else {
const entryPath = path.join(cwd, "src/entry-server.tsx");
return vite!.ssrLoadModule(entryPath);
}
}
const { ServerEntry } = await loadSsrEntryModule(vite);第二步,数据预取
export async function fetchData() {
// 数据获取逻辑
}第三步,「核心」渲染组件
React SSR 服务端通过 renderToString 把组件树渲染成 html 字符串 第一步拿到了入口,创建组件 -> 渲染成 html 字符串
import { renderToString } from "react-dom/server";
// 创建组件同时 塞入预取的data
const appHtml = renderToString(React.createElement(ServerEntry, { data }));第四步,拼接 html
- html 中添加插槽 插入 html 字符串 中通过一个 script 插入之前预取的 data
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<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/entry-client.tsx"></script>
<!-- SSR_DATA -->
</body>
</html>function resolveTemplatePath() {
return isProd
? path.join(cwd, "dist/client/index.html")
: path.join(cwd, "index.html");
}
const templatePath = resolveTemplatePath();
let template = fs.readFileSync(templatePath, "utf-8");
// 开发模式下需要注入 HMR、环境变量相关的代码,因此需要调用 vite.transformIndexHtml
if (!isProd && vite) {
template = await vite.transformIndexHtml(url, template);
}
const html = template
.replace("<!-- SSR_APP -->", appHtml)
// 注入数据标签,用于客户端 hydrate
.replace(
"<!-- SSR_DATA -->",
`<script>window.__SSR_DATA__=${JSON.stringify(data)}</script>`
);生产环境的静态资源处理
开发阶段的静态资源 Vite Dev Server 的中间件会处理,生产环境所有的资源都已经打包完成,需要启用单独的静态资源服务来承载这些资源
import serve from "serve-static";
async function createServer() {
const app = express();
// 加入 Vite SSR 中间件
app.use(await createSsrMiddleware(app));
// 注册中间件,生产环境端处理客户端资源
if (isProd) {
app.use(serve(path.join(cwd, "dist/client")));
}
app.listen(3003, () => {
console.log("Node 服务器已启动~");
console.log("http://localhost:3003");
});
}路由管理
通过 StaticRouter 配合 location 参数
// 导出 SSR 组件入口
import App from "./App";
import "../index.css";
import { StaticRouter } from "react-router-dom/server";
import Routers from "../routers";
function ServerEntry(props: any) {
return <StaticRouter location={props.url}>{Routers}</StaticRouter>;
}
export { ServerEntry };浏览器 API 兼容
通过 import.meta.env.SSR 内置的 vite 环境变量 判断是否在 ssr 环境
if (import.meta.env.SSR) {
// 服务端执行的逻辑
} else {
// 在此可以访问浏览器的 API
}SSR 缓存
- 文件读取缓存
function createMemoryFsRead() {
const fileContentMap = new Map();
return async (filePath) => {
const cacheResult = fileContentMap.get(filePath);
if (cacheResult) {
return cacheResult;
}
const fileContent = await fs.readFile(filePath);
fileContentMap.set(filePath, fileContent);
return fileContent;
};
}
const memoryFsRead = createMemoryFsRead();
memoryFsRead("file1");
// 直接复用缓存
memoryFsRead("file1");- 预取数据缓存
- HTML 渲染缓存
性能监控
通过 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"] });
// 接下来我们在 SSR 进行打点
// 以 renderToString 为例
performance.mark("render-start");
// renderToString 代码省略
performance.mark("render-end");
performance.measure("renderToString", "render-start", "render-end");