阅读 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仓库直达