# 概述
现代的 SSR 是基于正常的客户端渲染逻辑编写好代码,然后通过构建来生成客户端渲染使用的文件和服务端渲染使用的文件,并结合 Node.js 提供服务。我们这里基于 Vite 的服务端渲染配置,主要的步骤如下:
- 使用 Vite 构建常规的客户端渲染项目
- 改造服务端渲染和客户端渲染的 main.ts
- 创建基于 Node.js 的服务端逻辑(server.ts),结合 Vite 实现开发环境的服务端渲染
- 修改 server.ts,实现生产环境的服务端渲染流程
- 配置 package.json 命令,实现打包和环境运行
# 创建项目
我们采用 pnpm 对项目进行搭建,如果没有可以先安装 pnpm
npm install pnpm -g
创建 vite 的 Vue3 + Ts 项目,这里使用 vite 的模板
pnpm create vite my-vite-vue3-ts-ssr-template --template vue-ts
启动项目,项目的大致文件目录如下
|—— public // 公共文件
|—— dist // 打包输出文件
|—— src // 项目源码目录
| |—— assets // 静态资源目录
| |—— components // 公共组件
| |—— App.vue
| |—— main.ts
|—— vite.config.ts // vite配置文件
|—— index.html // 项目入口
|__ package.json
2
3
4
5
6
7
8
9
10
# 客户端渲染配置
将原来的main.ts
改造作为一个客户端和服务端共用的模块
import { createSSRApp } from "vue";
import App from "./App.vue";
export const createApp = () => {
const app = createSSRApp(App);
return { app, router, pinia };
};
2
3
4
5
6
7
8
添加entry-client.ts
作为客户端渲染逻辑入口文件,其主要逻辑和之前逻辑一样,使用 mount 方法将应用挂载到 DOM 中
import { createApp } from "./main";
import "./style.css";
const { app } = createApp();
app.mount("#app");
2
3
4
5
6
修改index.html
中main.ts
的引入,修改为entry-client.ts
,同时添加 html 替换的占位符<!--ssr-outlet-->
<body>
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.ts"></script>
</body>
2
3
4
在package.json
中添加一个新的命令,用于打包客户端环境
"scripts": {
"dev:client": "vite",
"build:client": "vite build --outDir dist/client --ssrManifest"
}
2
3
4
对于build:client
的命令,--outDir 参数为其指定了构建后所产生的文件存放的目录地址,--ssrManifest 表示在进行客户端生产构建后,会生成一个 ssr-manifest.json 文件,这个文件标识了静态资源的映射信息。后续在服务端渲染部分,我们可以解析这个文件,然后判断需要加载的资源,将其注入到 html 中。
在注入时可以使用preload/prefetch
来优化加载。Vite 主要利用 preload(对于 E6 Modules 时改为 modulepreload)。
# 服务端渲染配置
添加entry-server.ts
作为服务端渲染逻辑入口文件,该文件包含生成 HTML 的主要逻辑。
import { basename } from "node:path";
import { createApp } from "./main";
import { renderToString } from "@vue/server-renderer";
export const render = async (url: string, manifest: any = {}) => {
const { app } = createApp();
// 注入vue ssr中的上下文对象
const renderCtx: { modules?: string[] } = {};
const renderedHtml = await renderToString(app, renderCtx);
const preloadLinks = renderPreloadLinks(renderCtx.modules, manifest);
return { renderedHtml, preloadLinks };
};
// 判断当前加载的模块,在manifest中查找对应的资源文件(主要在生产环境中使用)
const renderPreloadLinks = (modules: any, manifest: any) => {
let links = "";
const seen = new Set();
modules.forEach((id: string) => {
const files = manifest[id];
if (files) {
files.forEach((file: string) => {
if (!seen.has(file)) {
seen.add(file);
const filename = basename(file);
if (manifest[filename]) {
for (const depFile of manifest[filename]) {
links += renderPreloadLink(depFile);
seen.add(depFile);
}
}
links += renderPreloadLink(file);
}
});
}
});
return links;
};
// 根据查找到的资源,添加对应资源标签
const renderPreloadLink = (file: string) => {
if (file.endsWith(".js")) {
return `<link rel="modulepreload" crossorigin href="${file}">`;
} else if (file.endsWith(".css")) {
return `<link rel="stylesheet" href="${file}">`;
} else if (file.endsWith(".woff")) {
return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`;
} else if (file.endsWith(".woff2")) {
return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`;
} else if (file.endsWith(".gif")) {
return ` <link rel="preload" href="${file}" as="image" type="image/gif">`;
} else if (file.endsWith(".jpg") || file.endsWith(".jpeg")) {
return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`;
} else if (file.endsWith(".png")) {
return ` <link rel="preload" href="${file}" as="image" type="image/png">`;
} else {
// TODO
return "";
}
};
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
通过@vue/server-render
提供的renderToString
方法,将当前状态下的 app 根实例转换成对应的 html 代码,后面会将生成的 HTML 代码替换到前面的index.html
的占位符(<!--ssr-outlet-->
)中,最终得到对应的 html。
对于生产环境通过要加载的模块,在客户端打包生成的ssr-manifest.json
文件查找对应的资源文件地址,然后生成对应的标签(加入一些 preload 逻辑),后续插入到index.html
中
在package.json
中添加一个新的命令,用于打包服务端环境
"scripts": {
"build:server": "vite build --ssr src/entry-server.ts --outDir dist/server",
}
2
3
--ssr 标志表明这将会是一个服务端构建,同时需要指定对应文件的入口
# 编写 node 服务
服务端渲染需要利用 Node.js 提供渲染首屏 HTML 的服务,可以利用 express 框架开启一个 node 服务
# 开发环境 Node 服务
在开发模式中,我们可以将 Vite 利用中间件的形式集成到 Express 中,这里 vite 是 ViteDevServer 的一个实例。vite.middlewares 是一个 Connect 实例,它可以在任何一个兼容 connect 的 Node.js 框架中被用作一个中间件。通过 Express 创建一个端口为 8900 的 node 服务,可以在localhost:8900
中访问到服务端渲染的结果。
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";
import { createServer as createViteServer } from "vite";
// 在ts文件中不能直接使用__dirname,所以需要使用这种方法
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function createServer() {
const app = express();
// 以中间件模式创建vite应用,这将禁用Vite本身的HTML服务逻辑
// 并让上级服务接管控制
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
});
// 使用 vite 的 Connect 实例作为中间件
// 如果你使用了自己的 express 路由(express.Router()),你应该使用 router.use
// 必须设置静态资源才能有作用
app.use(vite.middlewares);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
try {
// 1. 读取index.html
let template = fs.readFileSync(
path.resolve(__dirname, "index.html"),
"utf-8"
);
// 2. 应用vite HTML转换,这将会注入ViteHMR客户端
// 同时也会从vite插件应用HTML
// 例如:@vitejs/plugin-react 中的 global preambles
template = await vite.transformIndexHtml(url, template);
/**
* 3. 加载服务入口,vite.ssrLoadModule将自动转换
* 你的ESM源码使之可以在Node.js中运行,无需打包
* 并提供类似HMR的根据情况随时失效
*/
const { render } = await vite.ssrLoadModule("/src/entry-server.ts");
/**
* 4. 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
* 函数调用了适当的 SSR 框架 API。
* 例如 ReactDOMServer.renderToString()
*/
const { renderedHtml, state } = await render(url, {});
// 5. 注入渲染后的应用程序 HTML 到模板中。
const html = template
.replace(`<!--ssr-outlet-->`, renderedHtml)
.replace(`<!--pinia-state-->`, state);
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
// 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回
// 你的实际源码中。
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
app.listen(8900);
console.info("Server is start port at 8900");
}
createServer();
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
对于服务端渲染,其核心就是生成首屏 HTML,上诉的主要代码就是对 HTML 进行拦截,然后进行加工处理
- 获取 index.html 内容,作为初始 HTML 模板
- 在模板基础上应用 vite 的 transformIndexHtml 方法,对 html 进行转换,同时继承了 vite 的 HMR
- 调用
entry-server.ts
中的 render 方法,得到客户端的 html 字符串 - 将
index.html
中的占位符,替换成客户端的 html 字符串,构造完整的 HTML 内容 - 最后通过 Express 构建的服务发送到浏览器中
在package.json
中添加一个新的命令,用于运行生成环境的 ssr
"scripts" :{
"dev:server": "node --loader ts-node/esm server.ts"
}
2
3
# 生产环境 node 服务
生产环境 node 服务大体思路与开发环境一致
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";
const isProd = process.env.NODE_ENV === "production";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const resolve = (p: string) => path.resolve(__dirname, p);
const manifest = isProd
? JSON.parse(
fs.readFileSync(resolve("dist/client/ssr-manifest.json"), "utf-8")
)
: {};
const prodIndex = isProd
? fs.readFileSync(resolve("./dist/client/index.html"), "utf-8")
: "";
async function createServer() {
const app = express();
// 模块使用打包好的
const template = fs.readFileSync(
resolve("./dist/client/index.html"),
"utf-8"
);
// 请求静态资源
// app.use(
// "/assets",
// express.static(resolve("./dist/client/assets"), {
// maxAge: "1000h", // 设置缓存时间
// })
// );
// // 由于浏览器页签图片读取了public下的文件,需要单独设置
// app.use(
// "/vite.svg",
// express.static(resolve("./dist/client/vite.svg"), {
// maxAge: "1000h", // 设置缓存时间
// })
// );
// 设置静态资源的根目录
app.use(
require("serve-static")(resolve("dist/client"), {
index: false,
})
);
app.use("*", async (req, res) => {
const url = req.originalUrl;
try {
const render = (render = (await import("./dist/server/entry-server.js"))
.render);
const { renderedHtml, preloadLinks } = await render(url, manifest);
// 5. 注入渲染后的应用程序HTML 到模板中
const html = template
.replace(`<!--preload-links-->`, preloadLinks)
.replace(`<!--ssr-outlet-->`, renderedHtml);
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
console.info((e as Error).stack);
res.status(500).end((e as Error).stack);
}
});
app.listen(8901);
console.info("Server is start port at 8901");
}
createServer();
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
生产模式下主要对 node 服务主要有以下操作:
- 将 Vite 开发服务器的创建和所有使用都移到开发模式条件分支后面,然后添加 Express 静态文件服务中间件来服务 dist/client 中的文件
- 使用 dist/client/index.html 作为模板,而不是根目录的 index.html,因为前者包含了到客户端构建的正确资源链接。
- 使用 import("./dist/server/entry-server.js"),而不是 vite.ssrLoadModule("/src/entry-server.js"),前者是 SSR 构建后的最终结果
- 将 preload 对应的标签字符串替换到 index.html 中
# package.json 命令
"scripts": {
// 客户端开发模式构建:正常的Vite开发模式
"dev:client": "vite",
// 服务端node服务启动ssr
"dev:ssr": "node --loader ts-node/esm server.ts",
// 生产环境启动ssr
"prod": "set NODE_ENV=production && node --loader ts-node/esm server.ts",
// 同时运行客户端和服务端打包命令
"build": "pnpm build:client && pnpm build:server",
// 客户端打包命令
"build:client": "vite build --outDir dist/client --ssrManifest",
// 服务端打包命令
"build:server": "vite build --ssr src/entry-server.ts --outDir dist/server",
"preview": "vite preview"
},
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 集成 Vue Router
# 创建路由组件
// src/views/Home.vue
<template>
<div>This is Home Page</div>
</template>
<script setup lang="ts"></script>
2
3
4
5
6
// src/views/About.vue
<template>
<div>This is About Page</div>
</template>
<script setup lang="ts"></script>
2
3
4
5
6
// App.vue
<template>
<!-- other -->
<div><RouterView /></div>
</template>
2
3
4
5
# 添加路由
import {
createRouter as _createRouter,
createWebHistory,
createMemoryHistory,
} from "vue-router";
import type { RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
{
path: "/",
redirect: () => ({ name: "Home" }),
},
{
path: "/home",
name: "Home",
component: () => import("../views/Home.vue"),
},
{
path: "/about",
name: "About",
component: () => import("../views/About.vue"),
},
];
export function createRouter() {
return _createRouter({
// use appropriate history implementation for server/client
// import.meta.env.SSR is injected by Vite.
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes,
});
}
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
# 在 main.ts 添加路由
import { createSSRApp } from "vue";
import App from "./App.vue";
import { createRouter } from "./router";
export const createApp = () => {
const app = createSSRApp(App);
const router = createRouter();
app.use(pinia);
return { app, router };
};
2
3
4
5
6
7
8
9
10
11
# 客户端入口添加路由
import { createApp } from "./main";
import "./style.css";
const { app, router } = createApp();
router.isReady().then(() => {
app.mount("#app");
});
2
3
4
5
6
7
8
添加了router.isReady()
,在路由准备好之后再进行组件挂载,这里可以保证客户端挂载的组件和服务端渲染后的得到的组件是匹配的。
# 服务端入口添加路由
//...
export const render = async (url: string, manifest: any) => {
const { app, router, pinia } = createApp();
await router.push(url);
await router.isReady();
// ...
};
2
3
4
5
6
7
8
9
主要是使用router.push(url)
切换路由以及router.isReady()
等待切换完成。
此时我们分别执行生产环境和开发环境的 serve 命令,发现都可以正常运行
# 集成 Pinia
安装 pinia
pnpm install pinia
# main.ts 添加 pinia
// ...
import { createPinia } from "pinia";
export const createApp = () => {
// ...
const pinia = createPinia();
app.use(pinia);
return { app, router, pinia };
};
2
3
4
5
6
7
8
9
10
# 创建 pinia 目录
新建src/pinia/index.ts
import { ref } from "vue";
import type { Ref } from "vue";
import { defineStore } from "pinia";
export const useCountStore = defineStore("count", () => {
const count: Ref<number> = ref(0);
const increaseCount = () => {
count.value++;
};
return { count, increaseCount };
});
2
3
4
5
6
7
8
9
10
11
# 使用 pinia
我们在 Home.vue 文件中简单运用 pinia
<template>
<div>This is Home Page</div>
<span>Count from Pinia:</span>
<button @click="increaseCount">{{ count }}</button>
</template>
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useCountStore } from "../pinia";
const countStore = useCountStore();
const { count } = storeToRefs(countStore);
const { increaseCount } = countStore;
</script>
2
3
4
5
6
7
8
9
10
11
12
13
此时我们分别执行生产环境和开发环境的 serve 命令,发现都可以正常运行,切能查看到对应的值
# 服务端与客户端的通用性
尽管代码可以避免同时维护两个平台的代码,但是我们编写服务端渲染的项目代码时,还有很多注意事项
# 服务端的数据响应性
- 每个请求应该都是全新的、独立的应用程序实例,避免交叉请求的状态污染
- 实际的渲染过程需要确定性,我们也将在服务器上预取数据,这意味着我们开始渲染的时候,我们的应用程序就已经解析完成其状态。所以默认状态下禁用响应式数据,这样可以避免将数据转换为响应式对象的性能开销。
# 组件生命周期钩子函数
- 由于服务端渲染没有动态更新,所有生命周期钩子函数中,只有
beforeCreate
和created
会在服务端渲染过程中被调用。其他生命周期钩子函数中的代码,只会在客户端执行。 - 避免在
beforeCreate
和created
中产生全局副作用代码,例如各种定时器setInterval
,之前我们可以在beforeDestroy
或destroyed
中销毁,但 SSR 中没有这两个生命周期。所以这些副作用代码可以放在 beforeMount 或 mounted 生命周期中。
# 访问特定平台 API
- 通用代码不可接受特定平台的 API,例如:window/document 这种浏览器的全局变量,在 node 中运行就会报错
- 对于仅浏览器可用 API,通常方式是,在纯客户端的生命周期钩子函数中惰性的访问他们
- 可以使用一些共享平台的 API,例如 axios
对于服务端渲染来说,由于采用的 Node.js 环境,所以需要对于 window 对象做兼容处理,推荐使用jsdom
库
在 serve.ts 中添加下述代码就可以在 node 中使用 window 对象了
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
/* 模拟window对象逻辑 */
const resourceLoader = new jsdom.ResourceLoader({
// 模拟UA
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1",
});
const dom = new JSDOM("", {
url: "https://app.nihaoshijie.com.cn/index.html", // 模拟url
resources: resourceLoader,
});
global.window = dom.window;
global.document = window.document;
global.navigator = window.navigator;
window.nodeis = true; //可自行设置给window标识出node环境的标志位
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 预渲染(SSG)
如果服务端渲染的数据完全是静态的,即不依赖于不同用户访问看到的内容不一样。SSG 可以直接通过前端构建生成首屏的静态页面资源,不依赖于 Node 服务。在浏览器端访问时,只用打开预先生成好的 HTML 文件即可。SSG 减少了服务器成本,同时也优化了首屏渲染。我们在 server.ts 同级目录下新建 prerender.ts 文件
// 预渲染出首屏的页面并生成HTML文件
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const resolve = (p: string) => path.resolve(__dirname, p);
// 资源映射文件
const manifest = JSON.parse(
fs.readFileSync(resolve("dist/client/ssr-manifest.json"), "utf-8")
);
// 模板文件
const template = fs.readFileSync(resolve("dist/static/index.html"), "utf-8");
(async () => {
// 预渲染指定路由的首屏页面
// 这里首屏的路由是 /
let url = "/";
// 调用生成模式下的entry-server.js,可以利用这里的逻辑添加preload资源
const render = (await import("./dist/server/entry-server.js")).render;
const { renderedHtml, preloadLinks } = await render(url, manifest);
const html = template
.replace(`<!--preload-links-->`, preloadLinks)
.replace(`<!--ssr-outlet-->`, renderedHtml);
const filePath = `dist/static${url === "/" ? "/index" : url}.html`;
fs.writeFileSync(resolve(filePath), html);
// HTML文件生成后,删除无用文件
fs.unlinkSync(resolve("dist/static/ssr-manifest.json"));
})();
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
在 package.json 中添加执行命令
{
"scripts": {
"prerender": "vite build --ssrManifest --outDir dist/static && pnpm run build:server && node --loader ts-node/esm prerender.ts"
}
}
2
3
4
5
对于博客这种不会变化的网站我们可以用 SSG 来实现,而对于股票代码网站、天气预报网站等需要动态数据的网站,就不能使用 SSG,因为无法保证数据的实时性
# 总结
项目具体实现详见 github (opens new window)