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 是共享核心,它必须满足三个硬性条件:

  1. 必须导出 createApp 工厂函数 ,不能直接 new Vue() createApp() 。因为服务端需要多次调用创建新实例(每次请求一个新App),避免状态污染。
  2. 不能在顶层作用域访问 window document 。所有浏览器专属API必须包裹在 onMounted onBeforeMount 等生命周期钩子里。
  3. 路由和状态管理必须通过参数注入 ,不能直接 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会自动做三件事,而这三件事全是坑:

  1. 自动添加 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'
      }
    }
    
  2. 忽略 browser 字段 package.json 里的 "browser": { "fs": false } 会被无视,导致 fs 模块被打包进来。解决方案:显式声明 externals: ['fs', 'path', 'crypto']

  3. 不处理 .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 时,实际上触发了三次独立编译:

  1. Client Bundle编译 webpack --config build/client.webpack.config.js --mode production

    • 输出 dist/client.js dist/client.js.map
    • 启用 TerserPlugin 压缩, CssExtractPlugin 抽离CSS
  2. Server Bundle编译 webpack --config build/server.webpack.config.js --mode production

    • 输出 dist/vue-ssr-server-bundle.json
    • 关键: libraryTarget: 'commonjs2' 确保导出的是CommonJS模块
  3. 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%:

  1. 关闭 runInNewContext createBundleRenderer 默认 runInNewContext: true ,每次渲染都创建全新V8上下文,内存占用翻倍。生产环境必须设为 false ,但要确保 server-bundle.js 里没有全局变量污染。

  2. 启用 cache 选项 :为渲染器添加LRU缓存,避免重复渲染相同URL:

    const LRU = require('lru-cache')
    const renderer = createBundleRenderer(serverBundle, {
      cache: new LRU({
        max: 1000,
        maxAge: 1000 * 60 * 15 // 15分钟
      })
    })
    
  3. 预加载关键资源 :在 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文件。

排查步骤

  1. 在浏览器直接访问 http://localhost:3000/dist/client.js ,看是否返回JS代码
  2. 检查 app.use('/dist', express.static('./dist')) 是否在 app.get('*', ...) 之前注册
  3. 检查 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 ,确保字体加载完成前显示后备字体
  • 关键文字用

更多推荐