在非常早期的 Web 开发中,大家还在使用 JSP 这种古老的模板语法来编写前端的页面,然后直接将 JSP 文件放到服务端,在服务端填入数据并渲染出完整的页面内容,可以说那个时代的做法是天然的服务端渲染。但随着 AJAX 技术的成熟以及各种前端框架(如 Vue、React)的兴起,前后端分离的开发模式逐渐成为常态,前端只负责页面 UI 及逻辑的开发,而服务端只负责提供数据接口,这种开发方式下的页面渲染也叫客户端渲染(Client Side Render,简称 CSR)。
但客户端渲染也存在着一定的问题,例如首屏加载比较慢、对 SEO 不太友好,因此 SSR (Server Side Render)即服务端渲染技术应运而生,它在保留 CSR 技术栈的同时,也能解决 CSR 的各种问题。
首先,我们来分析一下 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>
接下来,我们简单的回顾一下浏览器的渲染流程,下面是一个简单的示意图。
当浏览器拿到如上的 HTML 内容之后,其实并不能渲染完整的页面内容,因为此时的 body 中基本只有一个空的 div 节点,并没有填入真正的页面内容。接下来浏览器才开始下载并执行 JS 代码,经历了框架初始化、数据请求、DOM 插入等操作之后才能渲染出完整的页面。也就是说,在 CSR 中完整的页面内容本质上通过 JS 代码执行之后才能够渲染。这会导致两个方面的问题:
那么 SSR 是如何解决这些问题的呢?首先,在 SSR 的场景下,服务端生成好完整的 HTML 内容,直接返回给浏览器,浏览器能够根据 HTML 渲染出完整的首屏内容,而不需要依赖 JS 的加载,这样在一定程度上能够降低首屏渲染的时间,另一方面也能将完整的页面内容展现给搜索引擎的爬虫,有利于做SEO。
当然,SSR 中只能生成页面的内容和结构,并不能完成事件绑定,因此需要在浏览器中执行 CSR 的 JS 脚本,完成事件绑定,让页面拥有交互的能力,这个过程被称作hydrate(翻译为注水或者激活)。同时,像这样服务端渲染 + 客户端 hydrate 的应用也被称为同构应用。
我们说 SSR 会在服务端提前渲染出完整的 HTML 内容,那这是如何做到的呢?
首先,需要保证前端的代码经过编译后放到服务端中能够正常执行,其次在服务端渲染前端组件,生成并组装应用的 HTML。这就涉及到 SSR 应用的两大生命周期: 构建时和运行时,我们不妨来仔细梳理一下。
SSR的构建阶段主要做了如下的一些事情:
import styles from './index.module.css'
//styles 是一个对象,如{ "container": "xxx" },而不是 CSS 代码
console.log(styles)
3,依赖外部化(external) 。对于某些第三方依赖我们并不需要使用构建后的版本,而是直接从 node_modules 中读取,比如 react-dom,这样在 SSR 构建的过程中将不会构建这些依赖,从而极大程度上加速 SSR 的构建。
对于 SSR 的运行时,一般可以拆分为比较固定的生命周期阶段,简单来讲可以整理为以下几个核心的阶段:
下面具体解释下这几个阶段:
可以发现,SSR 其实是构建和运行时互相配合才能实现的,也就是说,仅靠构建工具是不够的,因此开发一个 Vite 插件严格意义上无法实现 SSR 的能力,我们需要对 Vite 的构建流程做一些整体的调整,并且加入一些服务端运行时的逻辑才能实现。
先 Vite 作为一个构建工具,是如何支持 SSR 构建的呢?换句话说,它是如何让前端的代码也能顺利在 Node.js 中成功跑起来的呢?
这里分两种情况进行说明,在开发环境下,Vite 依然秉承 ESM 模块按需加载即 no-bundle的理念,对外提供了ssrLoadModule API,可以无需打包项目,将入口文件的路径传入ssrLoadModule 即可:
// 加载服务端入口模块
const xxx = await vite.ssrLoadModule('/src/entry-server.tsx')
而在生产环境下,Vite 会默认进行打包,对于 SSR 构建输出 CommonJS 格式的产物。我们可以在package.json中加入这样类似的构建指令:
{
"build:ssr": "vite build --ssr 服务端入口路径"
}
这样 Vite 会专门为 SSR 打包出一份构建产物。可以看到,大部分 SSR 构建时的事情,Vite 已经帮我们提供了开箱即用的方案。
接下来,正式开始 SSR 项目的搭建,你可以通过脚手架初始化一个react+ts的项目,命令如下。
npm init vite
npm i
打开工程,然后删除项目自带的src/main.ts,然后在 src 目录下新建entry-client.tsx和entry-server.tsx两个入口文件。其中,entry-client.tsx的代码如下:
// entry-client.ts
// 客户端入口文件
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
ReactDOM.hydrate(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
)
entry-server.ts的代码如下:
// entry-server.ts
// 导出 SSR 组件入口
import App from "./App";
import './index.css'
function ServerEntry(props: any) {
return (
<App/>
);
}
export { ServerEntry };
接下来,我们以 Express 框架为例来实现 Node 后端服务,后续的 SSR 逻辑会接入到这个服务中。当然你需要安装以下的依赖:
npm i express -S
npm i @types/express -D
接着,在src目录下新建一个ssr-server/index.ts文件,代码如下:
// src/ssr-server/index.ts
// 后端服务
import express from 'express';
async function createServer() {
const app = express();
app.listen(3000, () => {
console.log('Node 服务器已启动~')
console.log('http://localhost:3000');
});
}
createServer();
然后,在package.json中添加如下的 scripts脚本:
{
"scripts": {
// 开发阶段启动 SSR 的后端服务
"dev": "nodemon --watch src/ssr-server --exec 'esno src/ssr-server/index.ts'",
// 打包客户端产物和 SSR 产物
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
// 生产环境预览 SSR 效果
"preview": "NODE_ENV=production esno src/ssr-server/index.ts"
},
}
其中,项目中需要用到两个额外的工具,给大家解释一下:
我们先安装这两个插件:
npm i esno nodemon -D
现在,基本的项目骨架已经搭建完成了,接下来只需要专注于 SSR 运行时的实现逻辑即可。
SSR 作为一种特殊的后端服务,我们可以将其封装成一个中间件的形式,后面使用起来就方便许多,代码如下所示:
import express, { RequestHandler, Express } from 'express';
import { ViteDevServer } from 'vite';
const isProd = process.env.NODE_ENV === 'production';
const cwd = process.cwd();
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();
接下来,我们把焦点放在中间件内 SSR 的逻辑实现上,首先实现第一步即加载服务端入口模块,代码如下:
async function loadSsrEntryModule(vite: ViteDevServer | null) {
// 生产模式下直接 require 打包后的产物
if (isProd) {
const entryPath = path.join(cwd, 'dist/server/entry-server.js');
return require(entryPath);
}
// 开发环境下通过 no-bundle 方式加载
else {
const entryPath = path.join(cwd, 'src/entry-server.tsx');
return vite!.ssrLoadModule(entryPath);
}
}
其中,中间件内的逻辑如下:
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
// 省略前面的代码
return async (req, res, next) => {
const url = req.originalUrl;
// 1. 服务端入口加载
const { ServerEntry } = await loadSsrEntryModule(vite);
// ...
}
}
接下来,我们来实现服务端的数据预取操作,可以在entry-server.tsx中添加一个简单的获取数据的函数,代码如下:
export async function fetchData() {
return { user: 'xxx' }
}
接下来,就可以在 SSR 中间件中完成数据预取的操作了。
// src/ssr-server/index.ts
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
// 省略前面的代码
return async (req, res, next) => {
const url = req.originalUrl;
// 1. 服务端入口加载
const { ServerEntry, fetchData } = await loadSsrEntryModule(vite);
// 2. 预取数据
const data = await fetchData();
}
}
接着,我们进入到核心的组件渲染阶段:
// src/ssr-server/index.ts
import { renderToString } from 'react-dom/server';
import React from 'react';
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
// 省略前面的代码
return async (req, res, next) => {
const url = req.originalUrl;
// 1. 服务端入口加载
const { ServerEntry, fetchData } = await loadSsrEntryModule(vite);
// 2. 预取数据
const data = await fetchData();
// 3. 组件渲染 -> 字符串
const appHtml = renderToString(React.createElement(ServerEntry, { data }));
}
}
由于在第一步之后我们拿到了入口组件,现在可以调用前端框架的renderToStringAPI 将组件渲染为字符串,组件的具体内容便就此生成了。接下来,我们还需要在根目录下的 HTML 中提供相应的插槽,方便内容的替换。
// 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>
紧接着,我们在 SSR 中间件中补充 HTML 拼接的逻辑。
// src/ssr-server/index.ts
function resolveTemplatePath() {
return isProd ?
path.join(cwd, 'dist/client/index.html') :
path.join(cwd, 'index.html');
}
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
// 省略之前的代码
return async (req, res, next) => {
const url = req.originalUrl;
// 省略前面的步骤
// 4. 拼接完整 HTML 字符串,返回客户端
const templatePath = resolveTemplatePath();
let template = await 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>`
);
res.status(200).setHeader('Content-Type', 'text/html').end(html);
}
}
在拼接 HTML 的逻辑中,除了添加页面的具体内容,同时我们也注入了一个挂载全局数据的script标签,这是用来干什么的呢?
在 SSR 的基本概念中我们就提到过,为了激活页面的交互功能,我们需要执行 CSR 的 JavaScript 代码来进行 hydrate 操作,而客户端 hydrate 的时候需要和服务端同步预取后的数据,保证页面渲染的结果和服务端渲染一致,因此,我们刚刚注入的数据 script 标签便派上用场了。由于全局的 window 上挂载服务端预取的数据,我们可以在entry-client.tsx也就是客户端渲染入口中拿到这份数据,并进行 hydrate。
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
// @ts-ignore
const data = window.__SSR_DATA__;
ReactDOM.hydrate(
<React.StrictMode>
<App data={data}/>
</React.StrictMode>,
document.getElementById('root')
)
现在,我们基本开发完了 SSR 核心的逻辑,然后执行npm run dev命令启动项目。
打开浏览器后查看页面源码,就可以发现 SSR 生成的 HTML 已经顺利返回了,如下图。
如果我们现在执行npm run build及npm run preview进行生产环境的预览,会发现 SSR 可以正常返回内容,不过所有的静态资源及 CSR 的代码都失效了。
不过开发阶段并没有这个问题,这是因为对于开发阶段的静态资源 Vite Dev Server 的中间件已经帮我们处理了,而生产环境所有的资源都已经打包完成,我们需要启用单独的静态资源服务来承载这些资源。
对于这种问题,我们可以使用serve-static中间件来完成这个服务。首先,吓一跳安装对应第三方包:
npm i serve-static -S
接着,我们到 server 端进行注册。
// 过滤出页面请求
function matchPageUrl(url: string) {
if (url === '/') {
return true;
}
return false;
}
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
return async (req, res, next) => {
try {
const url = req.originalUrl;
if (!matchPageUrl(url)) {
// 走静态资源的处理
return await next();
}
// SSR 的逻辑省略
} catch(e: any) {
vite?.ssrFixStacktrace(e);
console.error(e);
res.status(500).end(e.message);
}
}
}
async function createServer() {
const app = express();
// 加入 Vite SSR 中间件
app.use(await createSsrMiddleware(app));
// 注册中间件,生产环境端处理客户端资源
if (isProd) {
app.use(serve(path.join(cwd, 'dist/client')))
}
// 省略其它代码
}
这样一来,我们就解决了生产环境下静态资源失效的问题。不过,一般情况下,我们会将静态资源部上传到 CDN 上,并且将 Vite 的 base 配置为域名前缀,这样我们可以通过 CDN 直接访问到静态资源,而不需要加上服务端的处理。
以上我们基本实现了 SSR 核心的构建和运行时功能,可以初步运行一个基于 Vite 的 SSR 项目,但在实际的场景中仍然是有不少的工程化问题需要我们注意。
在 SPA 场景下,对于不同的前端框架,一般会有不同的路由管理方案,如 Vue 中的 vue-router、React 的react-router。不过归根结底,路由方案在 SSR 过程中所完成的功能都是差不多的。
对于全局的状态管理而言,对于不同的框架也有不同的生态和方案,比如 Vue 中的 Vuex、Pinia,React 中的 Redux、Recoil。各个状态管理工具的用法并不是本文的重点,接入 SSR 的思路也比较简单,在预取数据阶段初始化服务端的 store ,将异步获取的数据存入 store 中,然后在 拼接 HTML阶段将数据从 store 中取出放到数据 script 标签中,最后在客户端 hydrate 的时候通过 window 即可访问到预取数据。
在某些比较极端的情况下,我们需要降级到 CSR,也就是客户端渲染。一般而言包括如下的降级场景:
对于第一种情况,在客户端入口文件中需要有重新获取数据的逻辑,我们可以进行如下的补充。
// entry-client.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
async function fetchData() {
// 客户端获取数据
}
async fucntion hydrate() {
let data;
if (window.__SSR_DATA__) {
data = window.__SSR_DATA__;
} else {
// 降级逻辑
data = await fetchData();
}
// 也可简化为 const data = window.__SSR_DATA__ ?? await fetchData();
ReactDOM.hydrate(
<React.StrictMode>
<App data={data}/>
</React.StrictMode>,
document.getElementById('root')
)
}
对于第二种场景,即服务器执行出错,我们可以在之前的 SSR 中间件逻辑追加try/catch 逻辑。
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
return async (req, res, next) => {
try {
// SSR 的逻辑省略
} catch(e: any) {
vite?.ssrFixStacktrace(e);
console.error(e);
// 在这里返回浏览器 CSR 模板内容
}
}
}
对于第三种情况,我们可以通过通过 ?csr 的 url query 参数来强制跳过 SSR,在 SSR 中间件添加如下逻辑。
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
return async (req, res, next) => {
try {
if (req.query?.csr) {
// 响应 CSR 模板内容
return;
}
// SSR 的逻辑省略
} catch(e: any) {
vite?.ssrFixStacktrace(e);
console.error(e);
}
}
}
由于 Node.js 中不能使用浏览器里面诸如 window、document之类的 API,因此一旦在服务端执行到这样的 API 会报如下的错误:
对于这个问题,我们首先可以通过import.meta.env.SSR这个 Vite 内置的环境变量来判断是否处于 SSR 环境,以此来规避业务代码在服务端出现浏览器的 API。
if (import.meta.env.SSR) {
// 服务端执行的逻辑
} else {
// 在此可以访问浏览器的 API
}
当然,我们也可以通过 polyfill 的方式,在 Node 中注入浏览器的 API,使这些 API 能够正常运行起来,解决如上的问题。我推荐使用一个比较成熟的 polyfill 库 jsdom,使用方式如下:
const jsdom = require('jsdom');
const { window } = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
const { document } = window;
// 挂载到 node 全局
global.window = window;
global.document = document;
在 SSR 的过程中,我们虽然可以在决定组件的内容,即<div id="root"></div>这个容器 div 中的内容,但对于 HTML 中head的内容我们无法根据组件的内部状态来决定。不过,React 生态中的 react-helmet 以及 Vue 生态中的 vue-meta 库就是为了解决这样的问题,让我们可以直接在组件中写一些 Head 标签,然后在服务端能够拿到组件内部的状态。
以react-helmet例子来说明:
// 前端组件逻辑
import { Helmet } from "react-helmet";
function App(props) {
const { data } = props;
return {
<div>
<Helmet>
<title>{ data.user }的页面</title>
<link rel="canonical" href="http://mysite.com/example" />
</Helmet>
</div>
}
}
// 服务端逻辑
import Helmet from 'react-helmet';
// renderToString 执行之后
const helmet = Helmet.renderStatic();
console.log("title 内容: ", helmet.title.toString());
console.log("link 内容: ", helmet.link.toString())
启动服务后访问页面,可以发现终端能打印出我们想要的信息。如此一来,我们就能根据组件的状态确定 Head 内容,然后在拼接 HTML阶段将这些内容插入到模板中。
在不同前端框架的底层都实现了流式渲染的能力,即边渲染边响应,而不是等整个组件树渲染完毕之后再响应,这么做可以让响应提前到达浏览器,提升首屏的加载性能。而Vue 中的 renderToNodeStream 和 React 中的 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 中填入一些与组件状态相关的内容,则不能使用流式渲染。比如react-helmet中自定义的 head 内容,即便在渲染组件的时候收集到了 head 信息,但在流式渲染中,此时 HTML 的 head 部分已经发送给浏览器了,而这部分响应内容已经无法更改,因此 react-helmet 在 SSR 过程中将会失效。
SSR 是一种典型的 CPU 密集型操作,为了尽可能降低线上机器的负载,设置缓存是一个非常重要的环节。在 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');
对于以上的缓存内容,具体的缓存位置可以是:
在实际的 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"] })
// 接下来我们在 SSR 进行打点
// 以 renderToString 为例
performance.mark('render-start');
// renderToString 代码省略
performance.mark('render-end');
performance.measure('renderToString', 'render-start', 'render-end');
接着我们启动服务后访问,可以看到打点日志信息。同样的,我们可以将其它阶段的指标通过上述的方式收集起来,作为性能日志;另一方面,在生产环境下,我们一般需要结合具体的性能监控平台,对上述的各项指标进行打点上报,完成线上的 SSR 性能监控服务。
有时候对于一些静态站点(如博客、文档),不涉及到动态变化的数据,因此我们并不需要用上服务端渲染。此时只需要在构建阶段产出完整的 HTML 进行部署即可,这种构建阶段生成 HTML 的做法也叫SSG(Static Site Generation,静态站点生成)。
SSG 与 SSR 最大的区别就是产出 HTML 的时间点从 SSR 运行时变成了构建时,但核心的生命周期流程并没有发生变化:
下面是一段简单的实现代码:
// scripts/ssg.ts
// 以下的工具函数均可以从 SSR 流程复用
async function ssg() {
// 1. 加载服务端入口
const { ServerEntry, fetchData } = await loadSsrEntryModule(null);
// 2. 数据预取
const data = await fetchData();
// 3. 组件渲染
const appHtml = renderToString(React.createElement(ServerEntry, { data }));
// 4. HTML 拼接
const template = await resolveTemplatePath();
const templateHtml = await fs.readFileSync(template, 'utf-8');
const html = templateHtml
.replace('<!-- SSR_APP -->', appHtml)
.replace(
'<!-- SSR_DATA -->',
`<script>window.__SSR_DATA__=${JSON.stringify(data)}</script>`
);
// 最后,我们需要将 HTML 的内容写到磁盘中,将其作为构建产物
fs.mkdirSync('./dist/client', { recursive: true });
fs.writeFileSync('./dist/client/index.html', html);
}
ssg();
接着,在package.json中加入这样一段 npm scripts就可以使用了。
{
"scripts": {
"build:ssg": "npm run build && NODE_ENV=production esno scripts/ssg.ts"
}
}
这样我们便初步实现了 SSG 的逻辑。当然,除了 SSG,业界还流传着一些其它的渲染模式,诸如SPR、ISR,听起来比较高大上,但实际上只是 SSR 和 SSG 所衍生出来的新功能罢了,这里简单给大家解释一下: