阅读 Vue 文档 这一章里有说过,vue 是支持服务端渲染的。
通过createSSRApp创建 vue 组件实例,并使用renderToString将在服务器渲染好 template 并返回字符串结构,通过替换占位字符将渲染好的字符串输出到 html 上,这样的一个过程就实现了服务端渲染。
Vite 文档也提到了如何去渲染 SSR 并提供了相关示例

那么今天我们就按照官方给的示例来完成 vue ssr 的改造 使用 Node Koa 框架来做服务器,且使用 vue 全家桶(router,pinia)开发项目。 router 配置这里需要注意,在服务器端路由模式为 createMemoryHistory,在客户端则 history or hash 随意
const router = createRouter({
routes,
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
});
使用 pinia 则是为了预取数据,pinia 文档中明确说明了支持 SSR。而我们使用 pinia 则是在服务器端获取数据后 基于服务器时期和客户端时期是两个完全不同的环境,在客户端是没法访问到服务器时期的数据的。所以我们需要在服务器获取数据后保存到 Pinia 里,避免客户端再发起同样的请求。
在根目录的 html 文件里添加占位符(名字可以随意取 但是在 server.js 里 replace 替换的时候记得同步修改)

<!--preload-links--> 预加载的 css style 等资源
<!--pinia-state--> pinia 里保存的数据
<!--ssr-outlet--> ssr 渲染的节点
同时入口文件的地址应由 main 文件改成 enter-client 文件!!因为 html 是运行在浏览器里的,main 文件下文已经改造成了通用逻辑,客户端真正的入口文件应该是 enter-client。
开始

server.js
用于启动 Node 服务来实现服务端渲染,用到的 Node 模块有 fs 文件读取,path 路径以及 url 处理,使用的 node 服务器则是 Koa。
import Koa from "koa";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const resolve = (p) => path.resolve(__dirname, p);
需要注意的是 要区分开发环境与生产环境。开发环境读取本地开发的代码,而生产环境则读取打包后的代码。
async function createServerApp(
root = process.cwd(),
isProd = process.env.NODE_ENV === "production"
) {
const app = new Koa();
const indexProd = isProd
? fs.readFileSync(resolve("./dist/client/index.html"), "utf-8")
: fs.readFileSync(resolve("index.html"), "utf-8");
const manifest = isProd
? fs.readFileSync(resolve("./dist/client/.vite/ssr-manifest.json"), "utf-8")
: undefined;
let vite;
if (isProd) {
// 压缩
app.use((await import("koa-compress")).default());
// 设置静态目录
app.use(
(await import("koa-static")).default(resolve("./dist/client"), {
index: false,
})
);
} else {
// 开发环境
vite = await (
await import("vite")
).createServer({
server: { middlewareMode: true },
appType: "custom",
// base,
});
app.use((await import("koa-connect")).default(vite.middlewares));
}
app.use(async (ctx) => {
try {
let template, render;
if (isProd) {
template = indexProd;
render = (await import("./dist/server/entry-server.js")).render;
} else {
template = await vite.transformIndexHtml(ctx.originalUrl, indexProd);
render = (await vite.ssrLoadModule("/src/entry-server.ts")).render;
}
const {
html: appHtml,
preloadLinks,
piniaState,
} = await render(ctx.originalUrl, manifest);
const html = template
.replace("<!--ssr-outlet-->", appHtml ?? "")
.replace("<!--preload-links-->", preloadLinks ?? "")
.replace("<!--pinia-state-->", piniaState ?? "");
ctx.type = "text/html";
ctx.body = html;
} catch (e) {
// 兜底 防止报错直接崩溃
vite && vite.ssrFixStacktrace(e);
ctx.status = 500;
ctx.body = e.stack;
}
});
return {
app,
};
}
开启服务
createServerApp().then(({ app }) => {
app.listen(2000, () => {
console.log(`[ssr server] run http://localhost:2000`);
});
});
src 目录下改造:
main.js
导出通用的代码,前面提到的 vue ssr 是通过createSSRApp来创建实例。
这里的
initialState就是服务器保存的对象import.meta.env.SSR则是 vite 自带的变量用于判断当前所处的环境
import { createSSRApp } from "vue";
import App from "./App.vue";
import router from "./router";
import { createPinia } from "pinia";
import "./assets/styles";
export function createApp() {
const app = createSSRApp(App);
const store = createPinia();
const initialState: {
pinia: null | typeof store.state.value;
} = {
pinia: null,
};
app.use(router).use(store);
if (import.meta.env.SSR) {
initialState.pinia = store.state.value;
} else {
store.state.value = window.__INITIAL_STATE__;
}
return {
app,
router,
store,
initialState,
};
}
entry-client.js
enter-client 做的事情很简单,就是等路由处理好后挂载到页面上。
import { createApp } from "./main";
const { app, router } = createApp();
router.isReady().then(() => app.mount("#app"));
entry-server.js
entry-server 做的事情是调用renderToString于服务器环境去渲染好页面结构并返回字符串结构方便替换占位符。通过 render 方法把处理好的 html 节点,预渲染的资源以及 pinia 保存的数据 return 出来。
import { renderToString } from "vue/server-renderer";
import { createApp } from "./main";
import { basename } from "node:path";
import devalue from "@nuxt/devalue";
export async function render(path: string, manifest: any) {
const { app, router, initialState } = createApp();
router.push(path);
await router.isReady();
// 在这里预取数据并传回到pinia
const ctx: any = {};
const html = await renderToString(app, ctx);
const preloadLinks = import.meta.env.PROD
? renderPreloadLinks(ctx.modules, manifest)
: undefined;
// https://pinia.vuejs.org/ssr/#state-hydration
const piniaState = devalue(initialState.pinia);
return {
html,
preloadLinks,
piniaState,
};
}
function renderPreloadLinks(modules: string[], manifest: any) {
let links = "";
const seen = new Set();
modules.forEach((id) => {
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 "";
}
}
命令行配置
配置 package.json 里的 scripts脚本命令
"dev": "node 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.ts --outDir dist/server",
"preview": "cross-env NODE_ENV=production node server",
"start": "set NODE_ENV=production && node server"
通过 dev 命令启动开发环境 生产环境则需要先 build 构建完后调用 start 开启服务
本篇代码仓库地址 github 仓库直达~