在十分晚期的 Web 开发中,大家还在应用 JSP 这种古老的模板语法来编写前端的页面,而后间接将 JSP 文件放到服务端,在服务端填入数据并渲染出残缺的页面内容,能够说那个时代的做法是人造的服务端渲染。但随着 AJAX 技术的成熟以及各种前端框架 (如 Vue、React) 的衰亡,前后端拆散的开发模式逐步成为常态,前端只负责页面 UI 及逻辑的开发,而服务端只负责提供数据接口,这种开发方式下的页面渲染也叫客户端渲染(Client Side Render,简称 CSR)。
但客户端渲染也存在着肯定的问题,例如首屏加载比较慢、对 SEO 不太敌对,因而 SSR (Server Side Render)即服务端渲染技术应运而生,它在保留 CSR 技术栈的同时,也能解决 CSR 的各种问题。
一、SSR 基本概念
首先,咱们来剖析一下 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 代码执行之后才可能渲染。这会导致两个方面的问题:
- 首屏加载速度比较慢。首屏加载须要依赖 JS 的执行,下载和执行 JS 都可能是十分耗时的操作,尤其是在一些网络不佳的场景,或者性能敏感的低端机下。
- 对 SEO(搜索引擎优化) 不敌对。页面 HTML 没有具体的页面内容,导致搜索引擎爬虫无奈获取关键词信息,导致网站排名受到影响。
那么 SSR 是如何解决这些问题的呢?首先,在 SSR 的场景下,服务端生成好残缺的 HTML 内容,间接返回给浏览器,浏览器可能依据 HTML 渲染出残缺的首屏内容,而不须要依赖 JS 的加载,这样在肯定水平上可能升高首屏渲染的工夫,另一方面也能将残缺的页面内容展示给搜索引擎的爬虫,有利于做 SEO。
当然,SSR 中只能生成页面的内容和构造,并不能实现事件绑定,因而须要在浏览器中执行 CSR 的 JS 脚本,实现事件绑定,让页面领有交互的能力,这个过程被称作 hydrate(翻译为注水或者激活)。同时,像这样服务端渲染 + 客户端 hydrate 的利用也被称为同构利用。
二、SSR 生命周期
咱们说 SSR 会在服务端提前渲染出残缺的 HTML 内容,那这是如何做到的呢?
首先,须要保障前端的代码通过编译后放到服务端中可能失常执行,其次在服务端渲染前端组件,生成并组装利用的 HTML。这就波及到 SSR 利用的两大生命周期: 构建时和运行时,咱们无妨来认真梳理一下。
构建时
SSR 的构建阶段次要做了如下的一些事件:
- 解决模块加载问 题。在原有的构建过程之外,须要退出 SSR 构建的过程,具体来说,咱们须要另外生成一份 CommonJS 格局的产物,使之能在 Node.js 失常加载。当然,随着 Node.js 自身对 ESM 的反对越来越成熟,咱们也能够复用前端 ESM 格局的代码,Vite 在开发阶段进行 SSR 构建也是这样的思路。
- 移除款式代码的引入。间接引入一行 css 在服务端其实是无奈执行的,因为 Node.js 并不能解析 CSS 的内容。但 CSS Modules 的状况除外,如下所示:
import styles from './index.module.css'
//styles 是一个对象,如{"container": "xxx"},而不是 CSS 代码
console.log(styles)
3,依赖内部化(external)。对于某些第三方依赖咱们并不需要应用构建后的版本,而是间接从 node_modules 中读取,比方 react-dom,这样在 SSR 构建的过程中将不会构建这些依赖,从而极大水平上减速 SSR 的构建。
运行时
对于 SSR 的运行时,个别能够拆分为比拟固定的生命周期阶段,简略来讲能够整顿为以下几个外围的阶段:
上面具体解释下这几个阶段:
- 加载 SSR 入口模块。在这个阶段,咱们须要确定 SSR 构建产物的入口,即组件的入口在哪里,并加载对应的模块。
- 进行数据预取。这时候 Node 侧会通过查询数据库或者网络申请来获取利用所需的数据。
- 渲染组件。这个阶段为 SSR 的外围,次要将第 1 步中加载的组件渲染成 HTML 字符串或者 Stream 流。
- HTML 拼接。在组件渲染实现之后,咱们须要拼接残缺的 HTML 字符串,并将其作为响应返回给浏览器。
能够发现,SSR 其实是构建和运行时互相配合能力实现的,也就是说,仅靠构建工具是不够的,因而开发一个 Vite 插件严格意义上无奈实现 SSR 的能力,咱们须要对 Vite 的构建流程做一些整体的调整,并且退出一些服务端运行时的逻辑能力实现。
三、基于 Vite 搭建 SSR 我的项目
3.1 SSR 构建 API
先 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 曾经帮咱们提供了开箱即用的计划。
3.2 我的项目搭建
接下来,正式开始 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"
},
}
其中,我的项目中须要用到两个额定的工具,给大家解释一下:
- nodemon: 一个监听文件变动主动重启 Node 服务的工具。
- esno: 相似 ts-node 的工具,用来执行 ts 文件,底层基于 Esbuild 实现。
咱们先装置这两个插件:
npm i esno nodemon -D
当初,根本的我的项目骨架曾经搭建实现了,接下来只须要专一于 SSR 运行时的实现逻辑即可。
3.3 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 曾经顺利返回了,如下图。
3.4 生产环境的 CSR 资源解决
如果咱们当初执行 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 我的项目,但在理论的场景中依然是有不少的工程化问题须要咱们留神。
4.1 路由治理
在 SPA 场景下,对于不同的前端框架,个别会有不同的路由治理计划,如 Vue 中的 vue-router、React 的 react-router。不过归根结底,路由计划在 SSR 过程中所实现的性能都是差不多的。
- 通知框架以后须要渲染的路由。在 Vue 中咱们能够通过 router.push 确定行将渲染的路由,React 中则通过 StaticRouter 配合 location 参数来实现。
- 设置 base 前缀。规定门路的前缀,如 vue-router 中 base 参数、react-router 中 StaticRouter 组件的 basename。
4.2 状态治理
对于全局的状态治理而言,对于不同的框架也有不同的生态和计划,比方 Vue 中的 Vuex、Pinia,React 中的 Redux、Recoil。各个状态管理工具的用法并不是本文的重点,接入 SSR 的思路也比较简单,在预取数据阶段初始化服务端的 store,将异步获取的数据存入 store 中,而后在 拼接 HTML 阶段将数据从 store 中取出放到数据 script 标签中,最初在客户端 hydrate 的时候通过 window 即可拜访到预取数据。
4.3 CSR 降级
在某些比拟极其的状况下,咱们须要降级到 CSR,也就是客户端渲染。一般而言包含如下的降级场景:
- 服务器端预取数据失败,须要降级到客户端获取数据。
- 服务器出现异常,须要返回兜底的 CSR 模板,齐全降级为 CSR。
- 本地开发调试,有时须要跳过 SSR,仅进行 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);
}
}
}
4.4 浏览器 API 兼容
因为 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;
4.5 自定义 Head
在 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 阶段将这些内容插入到模板中。
4.6 流式渲染
在不同前端框架的底层都实现了流式渲染的能力,即边渲染边响应,而不是等整个组件树渲染结束之后再响应,这么做能够让响应提前达到浏览器,晋升首屏的加载性能。而 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 过程中将会生效。
4.7 SSR 缓存
SSR 是一种典型的 CPU 密集型操作,为了尽可能升高线上机器的负载,设置缓存是一个十分重要的环节。在 SSR 运行时,缓存的内容能够分为这么几个局部:
- 文件读取缓存。尽可能防止多次重复读磁盘的操作,每次磁盘 IO 尽可能地复用缓存后果。如下代码所示:
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');
- 预取数据缓存。对于某些实时性不高的接口数据,咱们能够采取缓存的策略,在下次雷同的申请进来时复用之前预取数据的后果,这样预取数据过程的各种 IO 耗费,也能够肯定水平上缩小首屏工夫。
- HTML 渲染缓存。拼接实现的 HTML 内容是缓存的重点,如果能将这部分进行缓存,那么下次命中缓存之后,将能够节俭 renderToString、HTML 拼接等一系列的耗费,服务端的性能收益会比拟显著。
对于以上的缓存内容,具体的缓存地位能够是:
- 服务器内存。如果是放到内存中,须要思考缓存淘汰机制,避免内存过大导致服务宕机,一个典型的缓存淘汰计划是 lru-cache (基于 LRU 算法)。
- Redis 数据库。 相当于以传统后端服务器的设计思路来解决缓存。
- CDN 服务。咱们能够将页面内容缓存到 CDN 服务上,在下一次雷同的申请进来时,应用 CDN 上的缓存内容,而不必生产源服务器的资源。
4.8 性能监控
在理论的 SSR 我的项目中,咱们时常会遇到一些 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"] })
// 接下来咱们在 SSR 进行打点
// 以 renderToString 为例
performance.mark('render-start');
// renderToString 代码省略
performance.mark('render-end');
performance.measure('renderToString', 'render-start', 'render-end');
接着咱们启动服务后拜访,能够看到打点日志信息。同样的,咱们能够将其它阶段的指标通过上述的形式收集起来,作为性能日志;另一方面,在生产环境下,咱们个别须要联合具体的性能监控平台,对上述的各项指标进行打点上报,实现线上的 SSR 性能监控服务。
4.9 SSG/ISR/SPR
有时候对于一些动态站点(如博客、文档),不波及到动态变化的数据,因而咱们并不需要用上服务端渲染。此时只须要在构建阶段产出残缺的 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 所衍生进去的新性能罢了,这里简略给大家解释一下:
- SPR 即 Serverless Pre Render,即把 SSR 的服务部署到 Serverless(FaaS) 环境中,实现服务器实例的主动扩缩容,升高服务器运维的老本。
- ISR 即 Incremental Site Rendering,即增量站点渲染,将一部分的 SSG 逻辑从构建时搬到了 SSR 运行时,解决的是大量页面 SSG 构建耗时长的问题。