Vue SSR是一种在服务端将 Vue 应用渲染成 HTML 字符串,然后直接发送到客户端的技术。相比传统的客户端渲染,Vue SSR 能带来更好的 SEO 性能和更快的首屏加载时间。下面我们从零到一,结合项目源码,详细讲解如何实现一个 Vue SSR 项目。
1. 项目结构
以下以一个基本的Demo来说明服务端渲染的实现,下图是项目的基本结构:

2. 安装项目依赖
以下是package.json中的配置:
javascript
{
"name": "vue-ssr-example",
"version": "1.0.0",
"scripts": {
"dev": "node server",
"dev:client": "vite",
"dev:server": "node server",
"dev:both": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --ssrManifest --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.js --outDir dist/server",
"serve": "cross-env NODE_ENV=production node server"
},
"dependencies": {
"vue": "^3.5.6",
"vue-router": "^4.0.0",
"pinia": "^2.0.0",
"express": "^4.17.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.0.0",
"cross-env": "^7.0.3",
"concurrently": "^6.2.0"
}
}
3. 配置脚手架
javascript
// vite.config.js
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
build: {
minify: false,
},
});
4. 服务端渲染流程
4.1. 请求阶段
Node服务器接收请求,以下是server代码:
javascript
// server/index.js
// 引入必要的模块
const fs = require("fs");
const path = require("path");
const express = require("express");
const { createServer: createViteServer } = require("vite"); // 重命名Vite的createServer方法
// 创建SSR服务器的主函数,接受生产环境标志参数
async function createServer(isProd = process.env.NODE_ENV === "production") {
// 创建Express实例
const app = express();
let vite;
// 开发环境配置
if (!isProd) {
// 创建Vite开发服务器
vite = await createViteServer({
server: { middlewareMode: true }, // 中间件模式
appType: "custom" // 自定义应用类型(避免Vite的默认SPA处理)
});
app.use(vite.middlewares); // 使用Vite中间件处理请求
} else {
// 生产环境直接使用构建好的静态文件
app.use(express.static(path.resolve(__dirname, "../dist/client")));
}
// 处理所有路由的中间件
app.use("*", async (req, res) => {
const url = req.originalUrl; // 获取请求URL
try {
let template, render;
// 开发环境处理
if (!isProd) {
// 读取HTML模板文件
template = fs.readFileSync(
path.resolve(__dirname, "../index.html"),
"utf-8"
);
// 使用Vite转换HTML模板(包含HMR支持)
template = await vite.transformIndexHtml(url, template);
// 加载服务端入口模块
render = (await vite.ssrLoadModule("/src/entry-server.js")).render;
} else {
// 生产环境处理
template = fs.readFileSync(
path.resolve(__dirname, "../dist/client/index.html"),
"utf-8"
);
// 直接加载构建后的服务端入口
render = require("../dist/server/entry-server.js").render;
}
// 调用渲染函数获取SSR结果
const [appHtml, preloadLinks, initialState] = await render(url);
// 替换模板中的占位符
const html = template
.replace(`<!--app-html-->`, appHtml) // 插入应用HTML
.replace(`"<!--pinia-state-->"`, JSON.stringify(initialState)); // 序列化Pinia状态
// 返回最终HTML
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
// 开发环境下修正错误堆栈跟踪
if (!isProd) {
vite.ssrFixStacktrace(e);
}
res.status(500).end(e.message);
}
});
// 启动服务器
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});
}
// 启动服务器
createServer();
4.2. 应用初始化
创建Vue实例,以下是entry-server.js文件的代码:
javascript
// src/entry-server.js
// 从主模块导入应用创建函数
import { createApp } from "./main";
// 导入Vue服务端渲染工具
import { renderToString } from "vue/server-renderer";
// 服务端渲染函数,接收请求URL作为参数
export async function render(url) {
// 创建Vue应用实例(包含应用、路由和状态管理)
const { app, router, pinia } = createApp();
// 设置当前路由位置
await router.push(url);
// 等待路由导航完成
await router.isReady();
// 创建SSR上下文对象(用于收集渲染过程中的资源信息)
const context = {};
// 将Vue应用渲染为HTML字符串
const appHtml = await renderToString(app, context);
// 序列化Pinia状态(用于客户端hydration)
const initialState = JSON.stringify(pinia.state.value);
// 返回渲染结果数组:
// [0] 应用HTML字符串
// [1] 预加载模块信息(用于资源预加载)
// [2] 初始状态数据
return [appHtml, context.modules, initialState];
}
以下是上面代码中引入的main.js文件代码:
javascript
// /src/main.js
// 导入SSR专用Vue应用创建方法和核心模块
import { createSSRApp } from "vue"; // 服务端渲染专用应用创建方法
import { createRouter } from "./router"; // 自定义路由配置
import { createPinia } from "pinia"; // 状态管理库
import App from "./App.vue"; // 根组件
// 应用工厂函数(SSR核心要求)
export function createApp() {
// 创建SSR应用实例(与客户端createApp的区别在于SSR优化)
const app = createSSRApp(App);
// 初始化路由系统
const router = createRouter();
// 创建Pinia状态管理实例
const pinia = createPinia();
// 注册路由插件(使this.$router可用)
app.use(router);
// 注册状态管理(使this.$pinia可用)
app.use(pinia);
// 返回应用核心三件套,供entry-server和entry-client使用:
// app: Vue应用实例
// router: 路由实例(处理服务端/客户端路由同步)
// pinia: 状态管理实例(保证服务端/客户端状态一致)
return { app, router, pinia };
}
以下是根组件App.vue代码:
html
<template>
<div>
<nav>
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</nav>
<router-view></router-view>
</div>
</template>
<script>
export default {
name: "App",
};
</script>
4.3. 路由解析
通过router.js匹配对应组件文件,以下是router.js文件代码:
javascript
// src/route.js
import {
createRouter as _createRouter,
createMemoryHistory,
createWebHistory,
} from "vue-router";
import Home from "./pages/Home.vue";
import About from "./pages/About.vue";
const routes = [
{ path: "/", component: Home },
{ path: "/about", component: About },
];
export function createRouter() {
return _createRouter({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes,
});
}
4.4. 数据预取
数据预取通常是通过执行组件asyncData方法获取数据注入到组件文件里,本例中为了方便演示已省略。
以下是About.vue文件代码:
html
<!--src/pages/About.vue-->
<template>
<div>
<h1>About</h1>
<p>This is the about page.</p>
</div>
</template>
<script>
export default {
name: "About",
};
</script>
以下是Home.vue文件代码:
html
<!--src/pages/Home.vue-->
<template>
<div>
<h1>Home</h1>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>
<script setup>
import { useCounterStore } from "../store";
import { storeToRefs } from "pinia";
const store = useCounterStore();
const { count } = storeToRefs(store);
const { increment } = store;
</script>
4.5. 状态同步
准备初始状态,以下是store的代码:
javascript
// src/store/counter.js
import { defineStore } from "pinia";
export const useCounterStore = defineStore("counter", {
state: () => ({
count: 10,
}),
actions: {
increment() {
this.count++;
},
},
});
4.6. HTML生成
Vue SSR 将组件树递归渲染为 HTML 字符串,包含初始状态和激活标记,用于服务端返回完整页面结构。
4.7. 响应返回
将响应的结果注入状态到模板中,以下是index.html文件代码:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue 3 SSR Example</title>
</head>
<body>
<div id="app"><!--app-html--></div>
<script>
window.__INITIAL_STATE__ = "<!--pinia-state-->";
</script>
<script type="module" src="/src/entry-client.js"></script>
</body>
</html>
4.8. 客户端激活
客户端激活页面交互,以下是entry-client.js文件代码:
javascript
// /src/entry-client.js
// 导入应用创建函数和状态管理库
import { createApp } from "./main";
import { createPinia } from "pinia";
// 创建Vue应用实例(包含应用、路由和状态管理)
const { app, router, pinia } = createApp();
// 服务端渲染注入的初始状态处理
// 从全局变量获取服务端序列化的状态数据
if (window.__INITIAL_STATE__) {
try {
// 将JSON字符串还原为Pinia状态对象
pinia.state.value = JSON.parse(window.__INITIAL_STATE__);
} catch (e) {
// 解析失败时输出错误信息(开发环境调试用)
console.error("Failed to parse initial state:", e);
}
}
// 等待路由导航准备就绪后挂载应用
// 确保异步路由组件解析完成后再执行挂载
router.isReady().then(() => {
// 将Vue实例挂载到ID为app的DOM节点
// 客户端hydration的入口点
app.mount("#app");
});
5. 效果预览
观察控制台返回的结果,可以清楚的看到文件不再只是一个空壳文件,而是带有样式的页面,在浏览器上点击按钮数字均有变化,说明事件和状态已经被客户端激活了。
