一、流程

首先我们知道要想构建一个ssr,理解这个张图是必不可少的。从左边开始看起store、router和components就是平时写的页面里面用到的基本组件/逻辑。重点来看app.js对于csr来说项目只有一个app.js入口文件就可以了。但是ssr因为需要首屏渲染,也就是说直接请求页面时返回的不是空的html里面是有内容的。所有从app.js文件里面就开始不一样了。开始分为客户端打包和服务端打包。通过生成的serverBundle和clientBundle 一起在客户端激活。
二、ssr 开发环境
安装必要依赖
json
{
"devDependencies": {
"babel-loader": "^10.1.1",
"css-loader": "^7.1.4",
"html-webpack-plugin": "^5.6.6",
"koa-connect": "^2.1.1",
"style-loader": "^4.0.0",
"vue-loader": "^17.4.2",
"vue-ssr-assets-plugin": "^0.4.8",
"webpack": "^5.105.4",
"webpack-cli": "^7.0.0",
"webpack-dev-middleware": "^7.4.5",
"webpack-hot-middleware": "^2.26.1",
"webpack-merge": "^6.0.1",
"webpack-node-externals": "^3.0.0"
},
"dependencies": {
"@koa/router": "^15.3.1",
"@unhead/vue": "^2.1.12",
"koa": "^3.1.2",
"koa-static": "^5.0.0",
"vue": "^3.5.30",
"vue-router": "^5.0.3",
"vuex": "^4.1.0"
}
}
创建基础目录结构

通用应用代码
routes/index.js
js
import { createMemoryHistory, createWebHistory, createRouter as createVueRouter } from 'vue-router';
const routes = [
{
path: '/',
component: () => import("../views/index/index.vue")
}
]
export function createRouter() {
const history = __SSR__ ? createMemoryHistory() : createWebHistory();
return createVueRouter({
history: history,
routes,
});
};
SSR 变量是通过webpack进行定义传入,根据客户端和服务端打包配置的不同变量值也不同。
js
new webpack.DefinePlugin({
__SSR__: false // 客户端环境
})
store/index.js
js
import { createStore as createVuexStore } from 'vuex';
export function createStore() {
return createVuexStore({
state() {
return {
count: 0,
posts: []
}
},
mutations: {
increment(state) {
state.count++
},
setPosts(state, posts) {
state.posts = posts
}
},
actions: {
async fetchPosts({ commit }) {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
commit('setPosts', posts)
}
}
// 其他模块、getters 等
})
}
app.js -- 创建 router、store 和根 Vue 实例
js
import { createSSRApp } from 'vue';
import App from './views/app.vue';
import { createRouter } from './routes/index';
import { createStore } from './store/index';
export function createApp() {
const app = createSSRApp(App);
const router = createRouter();
const store = createStore();
app.use(router).use(store);
return {
app,
router,
store
}
}
entry-client.js
js
import { createApp } from './app';
import { createHead } from '@unhead/vue/client';
const { app, router, store } = createApp();
const head = createHead(); // 这里用到了一个meta组件库,用来管理标题
app.use(head);
// 替换服务端注入状态
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
}
// 热更新支持
if (import.meta.webpackHot) {
import.meta.webpackHot.accept();
}
router.isReady().then(() => {
app.mount('#app', true); // 第二个参数一定要传入true 打开ssr功能
});
entry-server.js
js
import { createApp } from './app';
import { createHead } from '@unhead/vue/server';
export default async (param, options = {}) => {
const { app, router, store } = createApp();
const head = createHead();
app.use(head);
// 解析URL,如果 param 是字符串,则视为URL;否则视为context对象
const url = typeof param === 'string' ? param : param.url;
router.push(url);// 切换到该路由下
await router.isReady();
const ssrContext = {
state: store.state,
_matchedComponents: new Set(),
}
// 如果是生产环境(param 是对象),将状态注入到 context 中(bundle renderer 会将其序列化到 HTML)
if (typeof param === 'object' && param !== null) {
param.state = store.state;
}
// 根据 options.returnFull 决定返回值
if (options.returnFull) {
return { app, ssrContext, head }
}
return app;
}
这个文件里面vue3 ssr 和vue2 ssr的一个不同点就是,没有收集asyncData函数的操作,因为在vue3中提供了一个onServerPrefetch函数用于在服务端发送请求。
配置 Webpack
webpack.base.js
js
const { VueLoaderPlugin } = require('vue-loader');
const path = require('path');
const webpack = require('webpack');
module.exports = {
mode: 'development',
output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].bundle.js'
},
module: {
rules: [
{ test: /\.vue$/, loader: 'vue-loader' },
{ test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ },
{ test: /\.css$/, use: ['style-loader', 'css-loader'] }
]
},
plugins: [
new VueLoaderPlugin(),
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: JSON.stringify(true), // 启用选项式 API
__VUE_PROD_DEVTOOLS__: JSON.stringify(false), // 生产环境禁用 devtools
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(false) // 禁用详细的 hydration 错误信息
})
],
resolve: { extensions: ['.js', '.vue'] }
}
webpack.client.dev.js
js
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { VueSsrAssetsClientPlugin } = require('vue-ssr-assets-plugin');
const baseConfig = require('./webpack.base');
const { merge } = require('webpack-merge');
module.exports = merge(baseConfig, {
target: 'web',
entry: [
'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000&reload=true',
path.resolve(__dirname, '../client/entry-client.js')
],
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/',
filename: 'js/[name].js',
chunkFilename: 'js/[name].[chunkhash].js'
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new VueSsrAssetsClientPlugin({
fileName: './dist/ssr-manifest.json'
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname,'../server/index.html'),
filename: 'index.ssr.html'
}),
new webpack.DefinePlugin({
__SSR__: false // 客户端环境
})
]
})
webpack.server.dev.js
js
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const webpack = require('webpack');
const { VueSsrAssetsServerPlugin } = require('vue-ssr-assets-plugin');
module.exports = merge(baseConfig, {
target: 'node',
entry: { server: path.resolve(__dirname, '../client/entry-server.js') },
output: {
path: path.resolve(__dirname, '../dist'),
chunkFilename: '[name].js', // 异步 chunk 输出到同一目录
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
externalsPresets: {
node: true // 启动 nodejs 预设
},
externals: [nodeExternals()], // 主要作用是自动将 node_modules 里的依赖已经你nodejs原生模块声明为外部依赖,从而避免它们被打包进最终的 bundle中
plugins: [
new VueSsrAssetsServerPlugin(),
new webpack.DefinePlugin({
__SSR__: true // 服务端环境
})
]
})
创建开发环境服务器
js
const webpack = require('webpack');
const path = require('path');
const Koa = require('koa');
const { renderToString } = require('vue/server-renderer');
const koaConnect = require('koa-connect');
const devMiddleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');
const { transformHtmlTemplate } = require('@unhead/vue/server');
const serve = require('koa-static');
const fs = require('fs');
const KoaRouter = require('@koa/router');
const router = new KoaRouter();
// 引入配置
const clientConfig = require('../build/webpack.client.dev');
const serverConfig = require('../build/webpack.server.dev');
// 客户端编译
const clientCompiler = webpack(clientConfig);
// 服务端编译
const serverCompiler = webpack(serverConfig);
// 存储最新的 server bundle 和 client manifest
let serverBundleExports = null; // 服务端入口模块的导出
let clientManifest = null; // 客户端资源列表
// 必须调用 watch 或 run
serverCompiler.watch({}, (err) => {
if (err) console.error('watch error:', err);
});
// 监听服务端编译完成,加载服务端入口
serverCompiler.hooks.done.tap('LoadServerBundle', (stats) => {
if (stats.hasErrors()) {
console.error('Server compile errors:', stats.toJson().errors);
return;
}
if (stats.hasWarnings()) {
console.warn('Server compile warnings:', stats.toJson().warnings);
}
const bundlePath = path.join(serverConfig.output.path, 'server-bundle.js');
// 清除该模块及其所有子模块的缓存,确保下次 require 得到最新代码
Object.keys(require.cache).forEach(key => {
if (key.startsWith(serverConfig.output.path)) {
delete require.cache[key]
}
});
try {
serverBundleExports = require(bundlePath);
console.log('✅ Server bundle updated.');
} catch (e) {
console.error('❌ Failed to load server bundle:', e)
}
});
clientCompiler.hooks.done.tap('SaveClientManifest', async (stats) => {
const manifestPath = path.join(clientConfig.output.path, 'ssr-manifest.json');
try {
const fs = clientCompiler.outputFileSystem;
const manifestJson = await new Promise((resolve, reject) => {
fs.readFile(manifestPath, 'utf-8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
clientManifest = JSON.parse(manifestJson);
console.log('✅ Client manifest updated.')
} catch (e) {
console.error('❌ Failed to read client manifest:', e)
}
});
const app = new Koa();
// 挂载客户端中间件
let devMiddlewareInstance = devMiddleware(clientCompiler, {
publicPath: clientConfig.output.publicPath,
serverSideRender: false,
writeToDisk: false,
});
const hotMiddlewareInstance = hotMiddleware(clientCompiler, {
path: '/__webpack_hmr',
heartbeat: 2000
});
app.use(koaConnect(devMiddlewareInstance));
app.use(koaConnect(hotMiddlewareInstance));
// 生成资源标签
// 生成 CSS link 标签
function generateAssetLinks(manifest, components) {
const links = [];
if (manifest.main) {
manifest.main.css?.forEach(file => links.push(`<link rel="stylesheet" href="${file}">`));
}
components.forEach(name => {
if (manifest[name]) {
manifest[name].css?.forEach(file => links.push(`<link rel="stylesheet" href="${file}">`));
}
});
return [...new Set(links)].join('');
}
// 生成 JS script 标签(不包括状态注入脚本)
function generateAssetScripts(manifest, components) {
const scripts = [];
if (manifest.main) {
manifest.main.js?.forEach(file => scripts.push(`<script src="${file}"></script>`));
}
components.forEach(name => {
if (manifest[name]) {
manifest[name].js?.forEach(file => scripts.push(`<script src="${file}"></script>`));
}
});
return [...new Set(scripts)].join('');
}
router.get('/ssr-manifest.json', async (ctx) => {
ctx.body = clientManifest;
});
router.get('/favicon.ico', async (ctx) => {
ctx.body = fs.createReadStream(path.resolve(__dirname, '../favicon.ico'));
});
router.get(/.*/, async (ctx) => {
// 等待编译完成
if (!serverBundleExports || !clientManifest) {
ctx.body = '⏳ 等待编译完成,请稍后刷新...'
return
}
try {
// 获取服务端入口的 render 函数
const render = serverBundleExports.default || serverBundleExports.render;
// 执行 render,传入 url 和 options,得到 app 和 ssrContext
const { app, ssrContext, head } = await render(ctx.url, { returnFull: true })
// 渲染为 HTML
const appHtml = await renderToString(app, ssrContext);
const registeredComponents = ssrContext._matchedComponents || new Set();
const headerLinks = generateAssetLinks(clientManifest, registeredComponents);
const footerScripts = generateAssetScripts(clientManifest, registeredComponents);
let template = require('fs').readFileSync(path.resolve(__dirname, './index.html'), 'utf-8');
template = template.replace('<div id="app"></div>', `<div id="app">${appHtml}</div>`);
const htmlWithUnhead = await transformHtmlTemplate(head, template);
const stateScript = `<script>window.__INITIAL_STATE__ = ${JSON.stringify(ssrContext.state).replace(/</g, '\\u003c')}</script>`;
// 6. 最终拼装:先插入 CSS 链接到 </head> 前,再插入状态和 JS 脚本到 </body> 前
let finalHtml = htmlWithUnhead
.replace('</head>', `${headerLinks}</head>`)
.replace('</body>', `${stateScript}${footerScripts}</body>`);
ctx.body = finalHtml
} catch (e) {
console.error('❌ Render error:', e);
ctx.status = 500;
ctx.body = 'Internal Server Error';
}
});
app.use(serve(path.resolve(__dirname, '../dist'))); // 假设静态文件放在 public 目录
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, () => {
console.log('🚀 Koa SSR server running on http://localhost:3000')
})
这里有几点需要注意的地方
- serverCompiler 实例创建后必须要用 watch或run来进去启动
- 由于vue3在整体设计上相对于vue2的差别,所以serverCompiler实例创建后它的文件系统并没有使用内存而是直接用输出到磁盘。所以服务端的模块引入使用"写入磁盘 + 清除缓存"的策略。 vue2的处理策略是:createBundleRenderer 接收的不是一个普通的 CommonJS 模块,而是一个由 vue-server-renderer/server-plugin 生成的特殊的 JSON 文件(vue-ssr-server-bundle.json)。这个 JSON 包含了服务端入口代码的所有内容,但它不需要通过 Node.js 的 require 去加载。createBundleRenderer 内部自己解析这个 JSON,并直接执行其中的代码。因此,它完美地绕过了 require 必须从磁盘读取文件的限制。webpack 将这个 JSON 输出到内存后,renderer 直接从内存中读取并使用,自然就不会有任何问题
- 根据生成的clientManifest 自定义函数来拼接请求字符串
启动
js
"scripts": {
"build:client": "webpack --config build/webpack.client.prod.js",
"build:server": "webpack --config build/webpack.server.prod.js",
"build": "npm run build:client && npm run build:server",
"dev": "node ./server/index.dev.js",
"start": "node ./server/index.js"
},
生成环境
webpack.client.prod.js
js
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const { VueSsrAssetsClientPlugin } = require('vue-ssr-assets-plugin');
module.exports = merge(baseConfig, {
target: 'web',
mode: 'production',
entry: { client: './client/entry-client.js' },
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/',
filename: 'js/[name].[contenthash].js',
chunkFilename: 'js/[name].[contenthash].js'
},
plugins: [
new VueSsrAssetsClientPlugin({
fileName: './dist/ssr-manifest.json'
}),
new HtmlWebpackPlugin({
template: './server/index.html',
filename: 'index.ssr.html',
minify: { removeComments: true, collapseWhitespace: true }
}),
new webpack.DefinePlugin({
__SSR__: false // 客户端环境
})
]
})
webpack.server.prod.js
js
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const webpack = require('webpack');
const { VueSsrAssetsServerPlugin } = require('vue-ssr-assets-plugin');
module.exports = merge(baseConfig, {
mode: 'production',
target: 'node',
entry: { server: './client/entry-server.js' },
output: {
libraryTarget: 'commonjs2'
},
externalsPresets: {
node: true // 启动 nodejs 预设
},
externals: [nodeExternals()], // 主要作用是自动将 node_modules 里的依赖已经你nodejs原生模块声明为外部依赖,从而避免它们被打包进最终的 bundle中
plugins: [
new webpack.DefinePlugin({
__SSR__: true // 服务端环境
}),
new VueSsrAssetsServerPlugin()
]
})
server/index.js
js
const koa = require('koa');
const serve = require('koa-static');
const Router = require('@koa/router');
const path = require('path');
const fs = require('fs');
const { renderToString } = require('vue/server-renderer');
const { transformHtmlTemplate } = require('@unhead/vue/server');
const app = new koa();
const router = new Router();
// 静态文件服务
app.use(serve(path.join(__dirname, '../dist')));
const template = fs.readFileSync(path.join(__dirname, '../dist/index.ssr.html'), 'utf-8');
const createApp = require('../dist/server.bundle.js').default;
const clientManifest = require('../dist/ssr-manifest.json');
router.get('/favicon.ico', async (ctx) => {
ctx.body = fs.createReadStream(path.resolve(__dirname, '../favicon.ico'));
});
// 辅助函数:生成 CSS 链接标签
function generateAssetLinks(manifest, components) {
const links = [];
if (manifest.main) {
manifest.main.css?.forEach(file => links.push(`<link rel="stylesheet" href="${file}">`));
}
components.forEach(name => {
if (manifest[name]) {
manifest[name].css?.forEach(file => links.push(`<link rel="stylesheet" href="${file}">`));
}
});
return [...new Set(links)].join('');
}
// 辅助函数:生成 JS 脚本标签(不包括状态脚本)
function generateAssetScripts(manifest, components) {
const scripts = [];
if (manifest.main) {
manifest.main.js?.forEach(file => scripts.push(`<script src="${file}"></script>`));
}
components.forEach(name => {
if (manifest[name]) {
manifest[name].js?.forEach(file => scripts.push(`<script src="${file}"></script>`));
}
});
return [...new Set(scripts)].join('');
}
router.get(/.*/, async (ctx) => {
try {
const { app, head, ssrContext } = await createApp(ctx.url, { returnFull: true });
const appContent = await renderToString(app, ssrContext);
// 获取当前路由匹配的组件集合(用于资源注入)
const components = ssrContext._matchedComponents || new Set();
// 生成资源标签
const headerLinks = generateAssetLinks(clientManifest, components);
const footerScripts = generateAssetScripts(clientManifest, components);
let html = template.replace('<div id="app">', `<div id="app">${appContent}`);
// 使用 Unhead 注入由组件声明的 head 标签(title、meta 等)
html = await transformHtmlTemplate(head, html);
// 生成状态注入脚本
const stateScript = `<script>window.__INITIAL_STATE__ = ${JSON.stringify(ssrContext.state).replace(/</g, '\\u003c')}</script>`;
// 最终拼装:CSS 链接插入 </head> 前,状态和 JS 脚本插入 </body> 前
html = html.replace('</head>', `${headerLinks}</head>`)
.replace('</body>', `${stateScript}${footerScripts}</body>`);
ctx.type = 'text/html';
ctx.body = html;
} catch (error) {
console.log('请求错误', error);
ctx.status = 500;
ctx.body = '服务器错误';
}
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, () => {
console.log('Koa SSR server running on http://localhost:3000')
});