手写Vue SSR服务:从Express+Webpack原理到生产部署
1. 项目概述:为什么在2024年还要手写一个基础SSR服务?
Vue.js的 Server Side Rendering(服务端渲染) ,不是过时的概念,而是被很多人“用错了”的关键技术。我最近帮三个团队做前端架构复盘,发现一个共性问题:他们全都在用Nuxt或Vite SSR插件,但页面首屏加载时间反而比纯客户端渲染还慢300ms以上——根本原因,是把SSR当成了“开箱即用的黑盒”,却没搞懂它底层到底在做什么、为什么这么做。
这个标题《Basic Server Side Rendering with Vue.js and Express》看似简单,但它直指SSR最核心的“最小可运行闭环”: 用最轻量的Express启动一个HTTP服务,用vue-server-renderer把Vue组件在Node.js里同步执行并生成HTML字符串,再把结果直接吐给浏览器 。没有路由守卫、没有状态管理注入、没有数据预取逻辑——就只做一件事:把 <App /> 变成 <div id="app"><h1>Hello World</h1></div> ,原封不动塞进HTML模板里返回。
这恰恰是绝大多数人跳过的“地基环节”。你可能已经会用Nuxt的 asyncData ,但当你看到 renderToString(app) 返回的字符串里, <script> 标签里嵌着 window.__INITIAL_STATE__ = {...} 这段JSON时,是否真正理解它为什么必须存在?为什么不能直接 JSON.stringify(store.state) ?为什么 vue-server-renderer 必须和Vue版本严格匹配?这些细节,全藏在这个“basic”里。
关键词里的 webpack 不是配角——它是连接前后端的“翻译官”:一边把 .vue 单文件组件编译成Node.js能执行的CommonJS模块,一边把 server-bundle.js 和 client-bundle.js 分开打包,确保服务端不引入浏览器专属API(比如 document )。而 Express 在这里也不是“随便选的后端框架”,它的中间件机制刚好卡在请求生命周期最干净的位置: req → middleware → res.send(html) ,没有Koa的洋葱模型干扰,也没有Fastify的Schema验证侵入,最适合教学级解耦。
适合谁看?如果你正在评估是否要上SSR,或者刚被Nuxt报错 Cannot use 'in' operator to search for 'isServer' in undefined 卡住三天,又或者想搞懂Vite SSR为何默认禁用 <script setup> 中的顶层await——那这篇就是为你写的。它不教你“怎么快速上线”,而是带你亲手拧紧每一颗螺丝,直到你闭着眼都能写出 createBundleRenderer 的参数对象。
2. 整体设计思路:为什么放弃Nuxt/Vite SSR,坚持手写Express?
2.1 三层隔离架构:服务端渲染的本质是“进程隔离”
SSR真正的难点从来不是“怎么渲染”,而是“怎么让同一份Vue代码,在两个完全不同的环境里正确运行”。浏览器里有 window 、 document 、 fetch ;Node.js里只有 global 、 require 、 fs 。强行混用,必然崩溃。所以我们的设计第一原则是: 物理隔离 。
- Client Bundle(客户端包) :专供浏览器执行,包含所有交互逻辑、事件监听、
mounted钩子。它必须通过<script src="/client.js">加载,且不能包含任何服务端专用代码。 - Server Bundle(服务端包) :专供Node.js执行,只负责调用
createApp()、renderToString(),生成初始HTML。它必须剔除所有浏览器API调用,连console.log都要谨慎——因为日志会污染HTML输出流。 - Shared Entry(共享入口) :一个纯JavaScript模块,导出
createApp工厂函数。它既被Client Bundle引用,也被Server Bundle引用,但内部逻辑必须是环境无关的(比如用process.env.SSR === 'true'判断分支,而不是直接写if (typeof window !== 'undefined'))。
这个架构下, vue-server-renderer 不是“魔法”,它只是个执行器:读取Server Bundle导出的 createApp ,实例化Vue应用,调用 renderToString() ,拿到虚拟DOM树后序列化为HTML字符串。整个过程不涉及网络请求、不操作真实DOM、不触发浏览器事件——它就是一个纯函数调用。
提示:很多初学者以为SSR是“服务端把页面画好再发给浏览器”,这是严重误解。SSR只生成 初始HTML骨架 ,后续所有交互、数据更新、路由跳转,100%由Client Bundle接管。服务端渲染完就结束,绝不参与后续生命周期。
2.2 Webpack双构建模式:为什么必须配置两个webpack.config.js?
Webpack在这里承担“环境翻译”任务。同一个 .vue 文件,在Client Bundle里要编译成带 document.createElement 的代码;在Server Bundle里要编译成能被 vm.runInThisContext 执行的字符串。这就要求两套独立配置:
- client.webpack.config.js :目标设为
web,启用VueLoaderPlugin,输出dist/client.js。关键点在于externals: { vue: 'vue' }——告诉Webpack不要把Vue打进包里,因为浏览器会通过CDN或<script>标签单独加载,避免重复打包。 - server.webpack.config.js :目标设为
node,externals: /^\.\/.*\.js$/——把所有相对路径的JS模块都视为外部依赖,因为Server Bundle本身不执行业务代码,只提供createApp入口。输出dist/vue-ssr-server-bundle.json,这是一个描述模块依赖关系的JSON文件,供vue-server-renderer按需加载。
你可能会问:为什么不用Vite的 build.ssr ?因为Vite的SSR构建默认启用 ssrExternal ,会把 vue 、 vue-router 等全部外置,导致Server Bundle无法找到 createApp 。而手写Webpack,你能精确控制每个 externals 规则——比如只外置 vue ,但把 @vueuse/core 打进Server Bundle,因为某些组合式函数需要服务端执行。
2.3 Express中间件链:为什么用 res.send() 而不是 res.render() ?
Express原生不支持Vue SSR,所以不能用 res.render('index', { data }) 这种模板语法。我们必须手动拼接HTML:
// 错误示范:用EJS模板硬塞
res.render('index.ejs', { html: renderedHtml })
// 正确做法:纯字符串拼接
const template = fs.readFileSync('./index.template.html', 'utf-8')
const html = template.replace('<!-- APP -->', renderedHtml)
res.send(html)
index.template.html 长这样:
<!DOCTYPE html>
<html>
<head>
<title>My SSR App</title>
</head>
<body>
<div id="app"><!-- APP --></div>
<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>
<script src="/client.js"></script>
</body>
</html>
注意 <script> 里那行 window.__INITIAL_STATE__ ——这是SSR的“灵魂补丁”。Client Bundle启动时,会优先检查这个全局变量,如果存在就直接用它初始化Vuex/Pinia store,避免重复请求API。而这个 initialState 必须在服务端渲染前就准备好(比如从数据库查数据),然后作为 renderToString(app, { initialState }) 的第二个参数传入。这就是为什么Express中间件必须是同步的: await renderToString() 之后,才能拿到 initialState 去拼HTML。
3. 核心细节解析:从零搭建SSR服务的7个关键节点
3.1 创建共享入口: entry-server.js 与 entry-client.js 的分工
所有SSR项目必须有两个入口文件,它们长得像双胞胎,但基因完全不同:
src/entry-server.js (服务端入口):
import { createApp } from './app.js'
// 这个函数必须返回一个Promise,因为可能需要异步获取数据
export function createApp(context) {
const { app, router, store } = createApp()
// 关键:根据context.url设置路由位置,否则服务端不知道该渲染哪个页面
router.push(context.url)
// 等待路由就绪(解决路由守卫、组件内asyncData)
return router.isReady().then(() => {
// 检查是否有store.state需要序列化
const initialState = store.state ? store.state : {}
context.initialState = initialState
return { app, router, store }
})
}
src/entry-client.js (客户端入口):
import { createApp } from './app.js'
// 客户端入口必须检查window.__INITIAL_STATE__
const { app, router, store } = createApp()
// 如果服务端已提供state,直接替换store
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
// 挂载到#app
app.mount('#app')
// 关键:启用客户端路由导航守卫
router.isReady().then(() => {
// 这里可以加全局前置守卫
})
注意:
entry-server.js里不能出现app.mount(),因为服务端没有DOM;entry-client.js里不能出现renderToString(),因为浏览器里没有vue-server-renderer。这两个文件必须严格隔离,否则Webpack打包时会报错Can't resolve 'fs'。
3.2 构建 app.js :Vue应用工厂函数的3个强制约束
src/app.js 是共享核心,它必须满足三个硬性条件:
- 必须导出
createApp工厂函数 ,不能直接new Vue()或createApp()。因为服务端需要多次调用创建新实例(每次请求一个新App),避免状态污染。 - 不能在顶层作用域访问
window、document。所有浏览器专属API必须包裹在onMounted、onBeforeMount等生命周期钩子里。 - 路由和状态管理必须通过参数注入 ,不能直接
import { createRouter } from 'vue-router'。因为服务端需要控制路由实例的创建时机(比如根据context.url动态设置base)。
标准写法:
import { createApp } from 'vue'
import { createRouter } from 'vue-router'
import { createStore } from 'vuex' // 或 createPinia()
import App from './App.vue'
// 路由配置必须是函数,返回新router实例
export function createRouter() {
return createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('./views/Home.vue') },
{ path: '/about', component: () => import('./views/About.vue') }
]
})
}
// Store同理
export function createStore() {
return createStore({
state: () => ({ count: 0 }),
mutations: { increment(state) { state.count++ } }
})
}
// 最终的createApp函数
export function createApp() {
const app = createApp(App)
const router = createRouter()
const store = createStore()
app.use(router)
app.use(store)
// 全局属性注入(如$api)
app.config.globalProperties.$api = createApi()
return { app, router, store }
}
3.3 vue-server-renderer 的初始化: createBundleRenderer 的5个必填参数
vue-server-renderer 不是直接用 renderToString() ,而是通过 createBundleRenderer 创建一个渲染器实例。这个实例必须传入5个关键参数:
import { createBundleRenderer } from 'vue-server-renderer'
import serverBundle from './dist/vue-ssr-server-bundle.json'
import clientManifest from './dist/vue-ssr-client-manifest.json'
const renderer = createBundleRenderer(serverBundle, {
// 1. clientManifest:告诉渲染器如何映射chunk到<script>标签
clientManifest,
// 2. template:HTML模板字符串,必须包含<!-- APP -->占位符
template: fs.readFileSync('./index.template.html', 'utf-8'),
// 3. inject:是否自动注入<link>和<script>,设为false手动控制
inject: false,
// 4. shouldPrefetch:决定哪些资源需要prefetch,通常返回false
shouldPrefetch: () => false,
// 5. runInNewContext:安全选项,设为false提升性能(默认true)
runInNewContext: false
})
其中 clientManifest 是Webpack插件 webpack-plugin-vue-ssr-client-manifest 生成的,它记录了每个JS/CSS文件的hash、文件名、依赖关系。比如:
{
"publicPath": "/",
"all": ["client.js", "0.js"],
"initial": ["client.js"],
"async": ["0.js"],
"modules": { "Home.vue": ["0.js"] }
}
没有它,渲染器就不知道该在HTML里插入哪些 <script src="..."> ,会导致页面白屏。
3.4 Express路由处理:如何正确传递 context 对象
Express中间件里, context 不是全局变量,而是每次请求都新建的对象,它承载着服务端渲染所需的所有上下文信息:
app.get('*', async (req, res) => {
// 1. 创建空context对象
const context = { url: req.url }
try {
// 2. 调用renderer.renderToString(),传入context
const html = await renderer.renderToString(context)
// 3. 检查context中是否有重定向指令(比如路由守卫里调用next('/login'))
if (context.redirect) {
return res.redirect(302, context.redirect)
}
// 4. 检查context中是否有HTTP状态码(比如404页面)
if (context.status) {
res.status(context.status)
}
// 5. 拼接最终HTML
const template = fs.readFileSync('./index.template.html', 'utf-8')
const finalHtml = template.replace('<!-- APP -->', html)
res.send(finalHtml)
} catch (e) {
// 6. 渲染错误统一处理
if (e.url) {
res.redirect(e.url) // 重定向到错误页
} else {
res.status(500).send('Internal Server Error')
}
}
})
context 对象的特殊字段:
url:必须赋值,否则路由无法匹配redirect:字符串,表示重定向目标status:数字,表示HTTP状态码initialState:对象,会被序列化到window.__INITIAL_STATE__meta:对象,用于注入<meta>标签(需配合vue-meta插件)
3.5 Webpack服务端构建: target: 'node' 带来的3个陷阱
server.webpack.config.js 设 target: 'node' 后,Webpack会自动做三件事,而这三件事全是坑:
-
自动添加
node:前缀 :比如require('fs')会被转成require('node:fs')。但vue-server-renderer内部仍用老式写法,导致Cannot find module 'node:fs'。解决方案:在resolve.alias里强制映射:resolve: { alias: { 'node:fs': 'fs', 'node:path': 'path', 'node:crypto': 'crypto' } } -
忽略
browser字段 :package.json里的"browser": { "fs": false }会被无视,导致fs模块被打包进来。解决方案:显式声明externals: ['fs', 'path', 'crypto']。 -
不处理
.vue文件 :vue-loader默认只在target: 'web'下生效。解决方案:在module.rules里为服务端配置单独的vue-loader规则,并指定loaders: { js: 'babel-loader' }。
完整配置节选:
module.exports = {
target: 'node',
entry: './src/entry-server.js',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
externals: [
/node_modules/,
'fs', 'path', 'crypto', 'os', 'net', 'tls', 'http', 'https', 'url', 'zlib'
],
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {
js: 'babel-loader'
}
}
}
]
}
}
3.6 客户端水合(Hydration):为什么 app.mount('#app') 必须在 isReady() 之后
水合是SSR最关键的一步:浏览器拿到服务端生成的HTML后,Vue必须“认出”这个DOM是自己生成的,然后接管事件绑定、状态响应式,而不是重新创建DOM。这个过程叫 hydrate 。
但 hydrate 有个硬性前提: 服务端生成的HTML结构,必须和客户端 createApp() 后首次渲染的虚拟DOM结构完全一致 。如果服务端渲染 <div>{{ msg }}</div> ,客户端却因为 msg 是异步获取的,初始渲染成 <div></div> ,就会触发“水合失败”,Vue会抛弃服务端HTML,重新生成DOM,造成闪屏。
解决方案就是 router.isReady() :
// entry-client.js
const { app, router } = createApp()
// 必须等待路由准备就绪,确保所有路由组件已加载
router.isReady().then(() => {
// 此时router.currentRoute.value已确定,组件内setup()已执行
app.mount('#app')
})
isReady() 内部做了什么?它等待所有 import('./views/Home.vue') 动态导入完成,并执行组件内的 setup() 函数。只有这时, <template> 里的 {{ msg }} 才有了初始值,虚拟DOM结构才稳定,水合才能成功。
3.7 开发环境热更新:为什么 webpack-dev-middleware 不适用于SSR
开发时,你不能像普通Vue项目那样 npm run serve 。因为SSR需要同时启动两个进程:Webpack Dev Server(提供静态资源)和Express Server(提供SSR服务)。而 webpack-dev-middleware 只能挂载到Express上,无法处理服务端代码变更。
正确方案是用 nodemon 监听 server-bundle.js 变化:
{
"scripts": {
"dev:client": "webpack --config client.webpack.config.js --watch",
"dev:server": "webpack --config server.webpack.config.js --watch",
"dev": "concurrently \"npm run dev:client\" \"npm run dev:server\" \"nodemon dist/server.js\""
}
}
dist/server.js 是Express服务入口,它会 require('./vue-ssr-server-bundle.json') 。当 nodemon 检测到这个JSON文件变化,就重启Express进程,实现服务端热更新。
实操心得:我踩过最大的坑是忘记在
server.js里加process.env.NODE_ENV = 'development'。结果vue-server-renderer在开发模式下会注入大量调试信息,导致HTML体积暴涨5倍,Chrome直接卡死。后来改成:if (process.env.NODE_ENV === 'development') { renderer = createBundleRenderer(serverBundle, { ... }) } else { renderer = createBundleRenderer(serverBundle, { runInNewContext: false }) }
4. 实操过程详解:从初始化到部署的完整流水线
4.1 初始化项目结构:5个核心目录的职责划分
一个可维护的SSR项目,目录结构必须清晰隔离关注点。我推荐以下结构(已验证在12个生产项目中稳定运行):
my-ssr-app/
├── build/ # Webpack配置文件存放处
│ ├── client.webpack.config.js
│ └── server.webpack.config.js
├── dist/ # 构建输出目录(git ignore)
│ ├── client.js # 客户端主包
│ ├── server-bundle.js # 服务端主包(实际不使用,仅作调试)
│ └── vue-ssr-server-bundle.json # 服务端bundle描述文件
├── public/ # 静态资源(无需Webpack处理)
│ └── index.template.html # HTML模板
├── src/ # 源码目录
│ ├── app.js # 应用工厂函数
│ ├── entry-client.js # 客户端入口
│ ├── entry-server.js # 服务端入口
│ ├── router/ # 路由配置
│ ├── store/ # 状态管理
│ └── views/ # 页面组件
└── server.js # Express服务入口
关键点:
public/index.template.html必须是纯HTML,不能有<%= %>模板语法。因为vue-server-renderer不解析EJS。dist/目录必须加入.gitignore,但vue-ssr-server-bundle.json要提交——因为CI/CD需要它来构建生产环境。server.js里不能import任何.vue文件,只能require('./dist/vue-ssr-server-bundle.json'),否则Node.js会尝试解析.vue导致报错。
4.2 Webpack双构建脚本: npm run build 背后的3次编译
执行 npm run build 时,实际上触发了三次独立编译:
-
Client Bundle编译 :
webpack --config build/client.webpack.config.js --mode production- 输出
dist/client.js和dist/client.js.map - 启用
TerserPlugin压缩,CssExtractPlugin抽离CSS
- 输出
-
Server Bundle编译 :
webpack --config build/server.webpack.config.js --mode production- 输出
dist/vue-ssr-server-bundle.json - 关键:
libraryTarget: 'commonjs2'确保导出的是CommonJS模块
- 输出
-
Client Manifest生成 :
webpack --config build/client.webpack.config.js --mode production --env ssr-manifest- 这个命令会额外运行
webpack-plugin-vue-ssr-client-manifest插件 - 输出
dist/vue-ssr-client-manifest.json
- 这个命令会额外运行
client.webpack.config.js 中识别 ssr-manifest 环境的写法:
module.exports = (env, argv) => {
if (argv.env && argv.env.ssrManifest) {
return {
plugins: [
new VueSSRClientPlugin({
filename: 'vue-ssr-client-manifest.json'
})
]
}
}
// 正常client配置...
}
4.3 Express服务启动: server.js 的12行核心代码
server.js 是整个SSR服务的“心脏”,它必须做且只做5件事:
const express = require('express')
const fs = require('fs')
const { createBundleRenderer } = require('vue-server-renderer')
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const template = fs.readFileSync('./public/index.template.html', 'utf-8')
const renderer = createBundleRenderer(serverBundle, {
clientManifest,
template,
runInNewContext: false
})
const app = express()
app.use('/dist', express.static('./dist')) // 提供client.js静态服务
app.use('/public', express.static('./public')) // 提供图片等静态资源
app.get('*', async (req, res) => {
const context = { url: req.url }
try {
const html = await renderer.renderToString(context)
res.send(template.replace('<!-- APP -->', html))
} catch (e) {
res.status(500).send('Server Error')
}
})
app.listen(3000, () => console.log('SSR server running on http://localhost:3000'))
注意第9行 app.use('/dist', ...) :这是关键! client.js 必须通过HTTP路径 /dist/client.js 被浏览器加载,而不能是 ./dist/client.js 。因为服务端渲染时, clientManifest 里的 publicPath 是 /dist/ ,渲染器会自动在HTML里插入 <script src="/dist/client.js"> 。
4.4 生产环境优化:3个必须开启的性能开关
上线前,这3个配置不检查,你的SSR性能至少损失40%:
-
关闭
runInNewContext:createBundleRenderer默认runInNewContext: true,每次渲染都创建全新V8上下文,内存占用翻倍。生产环境必须设为false,但要确保server-bundle.js里没有全局变量污染。 -
启用
cache选项 :为渲染器添加LRU缓存,避免重复渲染相同URL:const LRU = require('lru-cache') const renderer = createBundleRenderer(serverBundle, { cache: new LRU({ max: 1000, maxAge: 1000 * 60 * 15 // 15分钟 }) }) -
预加载关键资源 :在
index.template.html里添加<link rel="preload">:<head> <link rel="preload" href="/dist/client.js" as="script"> <link rel="preload" href="/dist/0.js" as="script"> </head>这能让浏览器在解析HTML时就并发下载JS,减少白屏时间。
4.5 Docker部署: Dockerfile 里的4个致命细节
用Docker部署SSR,最容易翻车的4个点:
FROM node:18-alpine
# 1. 必须复制dist和public,不能复制src(避免意外require .vue文件)
COPY dist/ /app/dist/
COPY public/ /app/public/
# 2. WORKDIR必须设为/app,否则require路径出错
WORKDIR /app
# 3. NODE_ENV=production必须在RUN阶段就设置,否则webpack插件行为异常
ENV NODE_ENV=production
# 4. 使用--no-cache-dir跳过npm缓存,减小镜像体积
RUN npm ci --only=production --no-cache-dir
CMD ["node", "server.js"]
特别注意第2点:如果 WORKDIR 设成 /app/src , require('./dist/vue-ssr-server-bundle.json') 会变成 /app/src/dist/... ,而实际文件在 /app/dist/ ,直接404。
4.6 CI/CD流水线:GitHub Actions的3个关键步骤
一个健壮的CI/CD流程,必须包含:
name: Build and Deploy SSR
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Build client bundle
run: npm run build:client
- name: Build server bundle
run: npm run build:server
- name: Generate client manifest
run: npm run build:manifest
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: dist
- name: Deploy to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.KEY }}
source: "dist/**"
target: "/var/www/my-ssr-app/"
关键点: build:manifest 必须在 build:server 之后执行,因为 vue-ssr-client-manifest.json 依赖 client-bundle.js 的hash值,而hash值在 build:client 时才确定。
5. 常见问题与排查技巧实录:12个真实踩坑案例
5.1 “ReferenceError: document is not defined” —— 服务端执行了浏览器代码
现象 :启动Express服务时报错,堆栈指向某个 .vue 文件的 mounted() 钩子里的 document.getElementById() 。
根因 : mounted 钩子在服务端也会执行(因为Vue实例在服务端创建),但服务端没有 document 。
解决方案 :
- 方案1(推荐):用
onMounted(() => { if (typeof document !== 'undefined') { /* 浏览器专属代码 */ } }) - 方案2:把DOM操作移到
onBeforeMount之后,但必须加环境判断 - 方案3:在
app.js里全局注入$isServer: typeof window === 'undefined',组件内用v-if="$isServer"控制
实操心得:我曾经在一个轮播图组件里写了
document.addEventListener('visibilitychange'),本地开发一切正常,上线后服务端崩溃。后来改成用useVisibilityChange()组合式函数,它内部自动判断环境,彻底解决。
5.2 “SyntaxError: Unexpected token '<'” —— Webpack打包后HTML被当作JS执行
现象 :浏览器控制台报错 Uncaught SyntaxError: Unexpected token '<' ,打开 /dist/client.js 发现内容是HTML页面(404页面)。
根因 :Express静态服务路径配置错误, /dist/client.js 请求被路由 app.get('*', ...) 捕获,返回了HTML而非JS文件。
排查步骤 :
- 在浏览器直接访问
http://localhost:3000/dist/client.js,看是否返回JS代码 - 检查
app.use('/dist', express.static('./dist'))是否在app.get('*', ...)之前注册 - 检查
dist/目录下是否存在client.js文件(注意大小写,Linux区分大小写)
修复 :确保静态资源中间件在路由中间件之前,且路径完全匹配。
5.3 “Hydration failed” —— 水合失败导致页面闪屏
现象 :页面先显示服务端渲染的内容,瞬间变成空白,再显示客户端渲染内容。
根因 :服务端和客户端渲染的DOM结构不一致。常见原因:
- 服务端
<div>{{ count }}</div>,客户端初始count为undefined,渲染成<div></div> - 组件内用
Math.random()生成不同key v-if条件在服务端和客户端计算结果不同(比如基于Date.now())
解决方案 :
- 所有动态内容必须有默认值:
data() { return { count: 0 } } - 避免在模板里用
new Date()、Math.random()等非纯函数 - 用
v-show替代v-if做条件显示(v-show不影响DOM结构)
5.4 “Cannot find module 'vue'” —— Webpack externals配置错误
现象 : node dist/server.js 时报错 Cannot find module 'vue' 。
根因 : server.webpack.config.js 里 externals 把 vue 也排除了,但 vue-server-renderer 需要 vue 模块。
修复 : externals 必须精确排除Node内置模块,但保留 vue :
externals: [
/node_modules/,
'fs', 'path', 'crypto', 'os', 'net', 'tls', 'http', 'https', 'url', 'zlib'
// 不要写 'vue',否则服务端找不到Vue构造函数
]
5.5 “Maximum call stack size exceeded” —— 服务端无限递归渲染
现象 :Express服务启动后,CPU飙升100%,日志刷屏 RangeError: Maximum call stack size exceeded 。
根因 : entry-server.js 里 router.push(context.url) 触发了路由守卫,守卫里又调用 next() ,形成循环。
排查方法 :在路由守卫里加日志:
router.beforeEach((to, from, next) => {
console.log('SSR route guard:', to.path, 'from:', from.path)
next()
})
如果看到同一路径反复打印,就是循环了。
修复 :服务端路由守卫必须加环境判断:
router.beforeEach((to, from, next) => {
if (process.env.SSR) {
// 服务端只做简单校验,不重定向
next()
} else {
// 客户端正常守卫逻辑
}
})
5.6 “window is not defined” —— 第三方库未做SSR适配
现象 :引入 vue-chartjs 后,服务端报错 ReferenceError: window is not defined 。
根因 : vue-chartjs 内部直接用了 window.Chart 。
解决方案 :
- 方案1(推荐):用
defineAsyncComponent动态导入,只在客户端加载import { defineAsyncComponent } from 'vue' const ChartComponent = defineAsyncComponent(() => import('@/components/Chart.vue') ) - 方案2:在
entry-client.js里用if (typeof window !== 'undefined') { ... }包裹 - 方案3:找SSR友好的替代库,比如
chart.js官方支持ssr: true选项
5.7 “504 Gateway Timeout” —— 服务端渲染超时
现象 :Nginx反代后,大流量下出现504错误。
根因 : renderToString() 执行时间超过Nginx默认60秒超时。
优化方向 :
- 加缓存:
renderer.renderToString()结果缓存15分钟 - 降级:对非关键页面,超时后返回纯客户端渲染HTML
- 监控:用
performance.now()记录渲染耗时,告警超过1s的请求
5.8 “FOUT/FOIT” —— 字体闪烁问题
现象 :页面加载时,文字先显示系统字体,再切换成自定义字体。
根因 : @font-face 加载时机问题,服务端无法预加载字体。
解决方案 :
- 在
index.template.html里用<link rel="preload" as="font">预加载 - CSS里用
font-display: swap,确保字体加载完成前显示后备字体 - 关键文字用
更多推荐
所有评论(0)