唉~发现招聘要求上,好多都写了要会SSR,是时候入这个坑了!
1.服务端渲染
流程
采用 Node.js 部署前端服务器
- 浏览器请求 URL,前端服务器接收到请求后,根据不同 url,前端服务器想后端服务器请求数据。
- 请求完成后,前端服务器会组装一个携带了具体数据的 HTML,并返回给浏览器。
- 浏览器得到 HTML 后开始渲染页面,同时浏览器加载并执行 js,给页面元素绑定事件,让页面变得可交互。
- 当用户与浏览器页面进行交互(如跳到下个页面)时,浏览器会执行 js,向后端服务器请求数据,获取完数据后,再次执行 js,动态渲染页面。
总结:根据url获取前端服务器已经完成数据渲染的HTML,然后在浏览器里面显示出来,后续的操作依旧走浏览器,除非切换路由页面。
好处:
- 利于SEO
- 首屏加载的用户体验优化
坏处:
- 维护兼容node和浏览器端的代码,代码复杂度提升了。
- 部署上处理前端静态资源,还有要搭建node.js前端服务,考虑负载均衡,运维成本增加。
2.开发模式SSR实现
1.创建一个基础的vite+vue+ts的项目
bash
npm create vite@latest
2.修改src/main.ts为ssr渲染
js
import { createSSRApp } from 'vue';
import router from './router/index';
import './style.css';
import App from './App.vue';
// createApp(App).mount('#app');
export function createApp() {
//改成ssr渲染
const app = createSSRApp(App);
app.use(router);
return { app, router };
}
3.创建一个客户端入口src/entry-client.ts
js
import { createApp } from './main';
const { app, router } = createApp();
//针对有懒加载的路由情况,需等待路由解析完
router.isReady().then(() => {
app.mount('#app');
});
4.修改index.html文件,设置渲染替换的插槽
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<!-- 资源文件,用于生产环境 -->
<!--preload-links-->
</head>
<body>
<div id="app">
<!-- 渲染html结果 -->
<!--app-html-->
</div>
<!-- 入口js -->
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
5.创建服务端渲染入口文件src/entry-server.ts
js
import { createApp } from './main';
import { renderToString } from 'vue/server-renderer';
export async function render(url: string) {
const { app, router } = createApp();
router.push(url);
//针对有懒加载的路由情况,需等待路由解析完
await router.isReady();
const ctx = {};
//renderToString将此事的根实例转换成对应的HTML字符串
const html = await renderToString(app, ctx);
return { html };
}
6.添加vue-router,文件路径为src/router/index.ts
js
import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router';
console.log('is SSR', import.meta.env.SSR);
const router = createRouter({
//服务端用内存历史,浏览器端用history
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes: [
{
path: '/hello',
name: 'hello',
component: () => import('@/components/HelloWorld.vue'),
meta: {
title: 'HelloWorld'
}
},
{
path: '/home',
name: 'home',
component: () => import('@/components/Home.vue'),
meta: {
title: 'home'
}
},
{
path: '/error',
name: 'error',
component: () => import('@/components/ErrorPage.vue'),
meta: {
title: 'error'
}
}
]
});
//注意路由守卫只有在浏览器端可用,如果服务端用会报错
if (!import.meta.env.SSR) {
router.beforeEach((to, from, next) => {
if (to.matched.length > 0) {
//有匹配路径
next();
} else {
//没有匹配路径
next('/error');
}
});
router.afterEach((to) => {
if (to.meta?.title) {
document.title = to.meta.title;
}
});
}
export default router;
注意:
- 路由守卫只有在浏览器端可用,如果服务端用会报错,因此记得要加判断。
- 路由模式上,服务端用内存历史
createMemoryHistory
,浏览器端用historycreateWebHistory
7.添加express
,在根目录添加server.js
创建express服务,以中间件模式创建vite应用,让express接管控制.vite的热更新HMR等都会通过express服务返回
js
//server-config.js
//服务配置
export default {
port: '8887',
url: 'http://localhost:8887/'
};
//
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import serverConfig from './server-config.js';
import { createServer as createViteServer } from 'vite';
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实例作为中间件
app.use(vite.middlewares);
app.use('*', async (req, res) => {
//返回给浏览器的html......
});
app.listen(serverConfig.port);
}
console.log(serverConfig.url);
createServer();
8.根据url返回对应的html
js
//返回给浏览器的index.html
const url = req.originalUrl;
console.log('url', url);
try {
let html;
//1.读取index.html
let template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
//2.应用vite html转换
template = await vite.transformIndexHtml(url, template);
//3.加载服务器入口
const { render } = await vite.ssrLoadModule('/src/entry-server.ts');
//4.渲染应用的html
const { html: appHtml } = await render(url);
//5.注入渲染后的应用程序HTML到模板中
html = template.replace(`<!--app-html-->`, appHtml);
//6.返回渲染后的HTML
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (error) {
//如果捕获错误,让vite修复改堆栈,这样它可以映射回你的实际源码中
vite.ssrFixStacktrace(error);
console.log(error);
res.status(500).end(error.message);
}
流程
- 读取index.html作为模板
- 利用vite html转换模板
- 加载服务端渲染入口/src/entry-server.ts,得到render
- 传入url,执行render,渲染得到ssr的html
- 替换模板的html插槽
- 返回结果到浏览器
- 如果报错,则让vit处理错误,并将错误提示返回给浏览器
9.执行node server.js
启动
- 服务端打印:

- 浏览器端打印

3.生产模式SSR实现
1.创建src/entry-server-prod.ts,用于生产环境打包服务端ssr入口
跟开发模式SSR相似,不过添加了资源文件加载的逻辑。
renderToString
时ctx的modules里面会列出该页面需要动态加载的资源文件id。
js
import { createApp } from './main';
import { basename } from 'node:path';
import { renderToString } from 'vue/server-renderer';
type ManifestType = {
[prop: string]: string[];
};
type CtxType = {
modules: string[];
};
export async function render(url: string, manifest: ManifestType) {
const { app, router } = createApp();
router.push(url);
await router.isReady();
const ctx: CtxType = {} as CtxType;
//renderToString将此事的根实例转换成对应的HTML字符串
const html = await renderToString(app, ctx);
//获取首屏需要动态预加载的资源
const preloadLinks = renderPreloadLinks(ctx.modules, manifest);
return { html, preloadLinks };
}
- 根据动态的模块id,遍历
ssr-manifest.json
资源映射表来获取具体文件路径,组装link预加载资源的字符串。
js
//获取preload资源,组装字link符串
function renderPreloadLinks(modules: string[], manifest: ManifestType) {
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;
}
function 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.打包生产环境的服务端和浏览器端文件,直接执行npm run build
json
{
"build:client": "vite build --outDir dist/client --ssrManifest",
"build:server": "vite build --outDir dist/server --ssr src/entry-server-prod.ts",
"build": "npm run build:client && npm run build:server"
}
打包后得到的文件

3.服务端去除vite,改用纯express作为服务
js
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import serverConfig from './server-config.js';
async function createServer() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const resolve = (p) => path.resolve(__dirname, p);
//1.读取index.html
let template = fs.readFileSync(resolve('dist/client/index.html'), 'utf-8');
//2.读取ssr-manifest.json资源映射表
const mainfest = JSON.parse(fs.readFileSync(resolve('dist/client/ssr-manifest.json'), 'utf-8'));
const app = express();
//压缩结果文本内容
app.use((await import('compression')).default());
//客户端静态资源路径
app.use(
'/',
(await import('serve-static')).default(resolve('dist/client'), {
index: false
})
);
app.use('*', async (req, res) => {
//渲染index.html
const url = req.originalUrl;
console.log('url', url);
try {
const render = (await import('./dist/server/entry-server-prod.js')).render;
//4.渲染应用的html,entry-server。
const { html: appHtml, preloadLinks } = await render(url, mainfest);
//5.注入渲染后的应用程序HTML到模板中
let html = template
.replace(`<!--preload-links-->`, preloadLinks)
.replace(`<!--app-html-->`, appHtml);
//6.返回渲染后的HTML
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (error) {
console.log(error);
res.status(500).end(error.message);
}
});
app.listen(serverConfig.port);
}
console.log(serverConfig.url);
createServer();
流程
- 读取index.html模板和打包后的得到的ssr-manifest.json资源映射表
- express添加压缩结果内容和静态资源路径中间件
- 渲染SSR的html:
dist/server/entry-server-prod.js
服务端渲染入口文件获取render方法- 传入url和manifest,执行渲染,得到html和link资源字符串
- 将字符串对应注入到模板index.html中
- 将结果html返回给浏览器显示
4.执行node ./server-prod.js NODE_ENV=production
启动ssr服务
效果跟开发模式SSR一致
4.文件目录

5.package.json命令配置
json
{
"type": "module",
"scripts": {
"dev": "node ./server.js",
"build:client": "vite build --outDir dist/client --ssrManifest",
"build:server": "vite build --outDir dist/server --ssr src/entry-server-prod.ts",
"dev:prod": "node ./server-prod.js NODE_ENV=production",
"build": "npm run build:client && npm run build:server"
}
}
Github地址
https://github.com/xiaolidan00/vue-ssr-demo
参考:
- 《Vue.js3应用开发与核心源码解析》吕鸣.著
- Vite 服务端渲染SSR
- vite-plugin-vue给出的vite+vue的ssr示例
- vue官网ssr