前言
Vue的代码不仅可以在浏览器中运行并输出DOM,同时还可以在Node.js环境运行,将组件渲染为字符串并发送个浏览器。这两种渲染方式即客户端渲染和服务端渲染。
同构渲染
在传统的SSR中,所有的HTML都在服务端生成(比如PHP、JSP等),页面跳转就是全页面刷新,虽然有着较好的SEO和首屏体验,但用户交互差,前后端耦合,不适合大型项目使用。 后来随着AJAX的出现,页面骨架由服务端渲染,内容通过AJAX动态加载,实现局部更新;此时已有动态交互,但核心页面仍然依赖SSR,也开始出现SEO问题。 再后来SPA(单应用页面)成为主流架构,前端完全接管UI渲染,提供了极佳的用户体验,前后端解耦,前端工程化逐渐成熟。但SEO不友好,首屏白屏时间长等问题难以解决。 由于众多网站有SEO的需求和前端框架能力的增加,同构渲染这一技术出现了,即首屏由服务端渲染保证速度和SEO,激活后转为SPA提供交互体验。这样既能有较好的SEO和用户体验。
Vue3的 SSR基本原理
- 构建两个Bundle
- 服务端Bundle:用于在
Node.js环境中将Vue应用渲染为HTML字符串 - 客户端Bundle:用于在浏览器激活(hydrate)一渲染HTML,并接管后续交互
- 服务端Bundle:用于在
渲染大致流程
- 用户请求页面
- 服务端调用Vue3的
createSSRApp创建应用实例 - 调用
renderToString(app)将应用渲染为HTML字符串 - 服务端将包含预渲染HTML的完整页面(通常嵌入客户端JS脚本)返回给浏览器
- 浏览器加载HTML并显示内容
- 客服端Vue应用启动,执行
hydration,将静态HTML激活为可交互的Vue应用
Hydration
这是SSR的关键步骤:客户端Vue会接管服务端生成的DOM,为其添加事件监听器、响应式绑定等,使其变成一个完整的SPA。Vue3对hydration做了大量优化,支持渐进式hyration和流体SSR(通过renderToStream)
Vue3 SSR的关键注意事项
- 仅在服务端运行的代码:避免在组件中直接使用
window、document等浏览器API,需用if (import.meta.env.SSR)或者typeof window !== 'undefined'等条件判断环境 - 生命周期注意:有些钩子是在服务端执行的,比如beforeCreate和created生命周期在服务端渲染时执行,应该避免在这两个生命周期里产生全局副作用的代码,例如使用setInterval设置定时器,在SSR期间创建了但不会销毁,造成服务器内存溢出。
- 数据预取:在服务端渲染前,必须获取组件所需数据(如API请求),否则HTML中会是空状态,通常通过
onServerPrefetch()生命周期钩子或自定义逻辑实现 - 状态同步问题:服务端获取的数据需注入到客服端,避免客户端重复请求或状态不一致,常见做法,将状态序列化为
window.__INITIAL_STATE__,客户端启动时读取 - 状态管理:使用
vue-router和pinia/vuex时,需确保在服务端和客户端创建新的实例(避免状态污染),每个请求必须创建新的 store 实例,否则用户 A 的数据可能泄露给用户 B - 路由守卫:
beforeEach等路由守卫在服务端也会执行,如果守卫中包含浏览器逻辑,需要判断环境 - 第三方库的兼容性:需要确保适用于服务端还是客户端
| 代码位置 / API | 服务端执行? | 说明 |
|---|---|---|
<script setup> 整体 | ✅ 是 | 包括所有变量、函数、响应式声明 |
ref, reactive, computed | ✅ 是 | 用于生成初始 HTML |
watch, watchEffect | ✅ 是 | 但监听无意义(无交互),可能造成副作用 |
onServerPrefetch | ✅ 是 | 专为 SSR 数据预取设计 |
onBeforeCreate, onCreated | ✅ 是 | 组件创建阶段 |
onBeforeMount, onMounted | ❌ 否 | 涉及 DOM,仅客户端 |
onBeforeUpdate, onUpdated | ❌ 否 | 更新阶段,仅客户端 |
onBeforeUnmount, onUnmounted | ❌ 否 | 仅客户端 |
同构渲染中,服务端渲染(SSR)只发生在「用户首次访问页面」时(即输入 URL 或刷新页面);后续的路由跳转默认是客户端渲染(CSR),除非你显式配置为每次跳转都走 SSR。
用户输入 URL → [服务端] 渲染 HTML → 浏览器显示 → Vue 激活(hydration)
↓
点击链接跳转 → [客户端] Vue Router 切换 → AJAX 请求数据 → 更新 DOM
↓
再次点击 → 同上(始终在客户端)
↓
用户刷新页面 → 回到第一步(再次触发 SSR)
如何实现同构渲染?
手动实现
项目结构
my-vue3-ssr/
├── src/
│ ├── app.js # 创建应用实例(同构)
│ ├── entry-client.js # 客户端入口
│ ├── entry-server.js # 服务端入口
│ ├── router.js # 路由配置
│ ├── store.js # Pinia store
│ ├── App.vue # 根组件
│ └── pages/
│ ├── Home.vue
│ └── Article.vue
├── server.js # Node.js 服务
├── index.html # 客户端模板
├── vite.config.js # Vite 配置
└── package.json
核心代码实现
src/app.js
// src/app.js
import { createSSRApp, h } from 'vue'
import { createRouter } from './router'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp(ssrContext = {}) {
const pinia = createPinia()
const router = createRouter()
const app = createSSRApp(App)
app.use(pinia)
app.use(router)
return { app, router, pinia }
}
src/router.js —— 路由(支持 SSR)
// src/router.js
import { createRouter as _createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
import Home from './pages/Home.vue'
import Article from './pages/Article.vue'
export function createRouter() {
// 服务端用 memory history,客户端用 web history
const history = import.meta.env.SSR
? createMemoryHistory()
: createWebHistory()
const routes = [
{ path: '/', component: Home, name: 'Home' },
{ path: '/article/:id', component: Article, name: 'Article' }
]
return _createRouter({ history, routes })
}
src/store.js —— Pinia Store(示例)
```js
// src/stores/article.js
import { defineStore } from 'pinia'
export const useArticleStore = defineStore('article', {
state: () => ({
title: '',
content: ''
}),
actions: {
async fetchArticle(id) {
// 模拟 API 调用
const res = await fetch(https://jsonplaceholder.typicode.com/posts/${id})
const data = await res.json()
this.title = data.title
this.content = data.body
}
}
})
注意:实际项目中,**API 调用应封装为可同构的函数**
**`src/pages/Article.vue` —— 支持 SSR 数据预取**
```js
<!-- src/pages/Article.vue -->
<script setup>
import { useArticleStore } from '@/stores/article'
import { useRoute } from 'vue-router'
import { onServerPrefetch, onMounted } from 'vue'
import { useHead } from '@vueuse/head' // 可选:SEO 标签
const route = useRoute()
const store = useArticleStore()
// 服务端预取 关键:`onServerPrefetch` 只在服务端执行,确保数据在渲染前加载。
onServerPrefetch(async () => {
await store.fetchArticle(route.params.id)
})
// 客户端 fallback(如直接访问)
onMounted(async () => {
if (!store.title) {
await store.fetchArticle(route.params.id)
}
})
// SEO 优化(需额外安装 @vueuse/head)
useHead({
title: store.title,
meta: [{ name: 'description', content: store.content.substring(0, 100) }]
})
</script>
<template>
<div>
<h1>{{ store.title }}</h1>
<p>{{ store.content }}</p>
</div>
</template>
src/entry-server.js —— 服务端入口
// src/entry-server.js
import { renderToString } from 'vue/server-renderer'
import { createApp } from './app'
export async function render(url, manifest) {
const { app, router, pinia } = createApp()
// 设置路由
await router.push(url)
await router.isReady()
// 渲染 HTML
const appHtml = await renderToString(app)
// 序列化 Pinia 状态
const state = JSON.stringify(pinia.state.value).replace(/</g, '\\u003c')
// 读取客户端入口文件名(用于注入 script)
const entryFile = manifest['src/entry-client.js']
return {
appHtml,
state,
entryFile
}
}
src/entry-client.js —— 客户端入口
// src/entry-client.js
import { createApp } from './app'
const { app, router, pinia } = createApp()
// 恢复服务端注入的状态
if (window.__INITIAL_STATE__) {
pinia.state.value = window.__INITIAL_STATE__
}
// 激活应用(hydration)
router.isReady().then(() => {
app.mount('#app', true) // true 表示是 hydration
})
server.js —— Node.js 服务
// server.js
import fs from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import express from 'express'
import { render } from './dist/server/entry-server.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const manifest = JSON.parse(
fs.readFileSync(join(__dirname, 'dist/client/ssr-manifest.json'), 'utf-8')
)
const template = fs.readFileSync(
join(__dirname, 'dist/client/index.html'),
'utf-8'
)
const app = express()
// 静态资源
app.use('/assets', express.static(join(__dirname, 'dist/client/assets')))
// SSR 路由
app.get('*', async (req, res) => {
try {
const { appHtml, state, entryFile } = await render(req.url, manifest)
const html = template
.replace('<!--app-html-->', appHtml)
.replace('<!--state-->', `<script>window.__INITIAL_STATE__ = ${state}</script>`)
.replace('<!--entry-->', `<script type="module" src="/${entryFile}"></script>`)
res.setHeader('Content-Type', 'text/html')
res.send(html)
} catch (e) {
console.error(e)
res.status(500).send('Internal Server Error')
}
})
app.listen(3000, () => {
console.log('SSR server running on http://localhost:3000')
})
index.html —— 客户端模板
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My SSR App</title>
</head>
<body>
<div id="app"><!--app-html--></div>
<!--state-->
<!--entry-->
</body>
</html>
vite.config.js —— 构建配置
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
build: {
ssr: true,
rollupOptions: {
input: {
server: resolve(__dirname, 'src/entry-server.js'),
client: resolve(__dirname, 'src/entry-client.js')
},
output: {
entryFileNames: '[name].js'
}
}
},
ssr: {
noExternal: ['vue', 'vue-router', 'pinia'] // 确保这些包被编译
}
})
构建Bundle
# 构建客户端
vite build --outDir dist/client
# 构建服务端(需指定 ssr)
vite build --ssr src/entry-server.js --outDir dist/server
启动服务
node server.js
基于Nuxt 实现
Nuxt 是一个基于 Vue.js 的开源“元框架”(Meta Framework),用于构建服务端渲染(SSR)、静态站点生成(SSG) 和 单页应用(SPA) 的现代化 Web 应用。 它不是替代 Vue,而是 让 Vue 更强大、更开箱即用,尤其在 SEO、性能和开发体验方面。
- 基于文件的路由: 根据您的
app/pages/目录的结构定义路由。这可以使组织您的应用程序更容易,并避免手动路由配置的需要。 - 代码分割: Nuxt 自动将您的代码分割成更小的块,这有助于减少应用程序的初始加载时间。
- 开箱即用的服务器端渲染: Nuxt 附带内置的 SSR 功能,因此您无需自己设置单独的服务器。
- 自动导入: 在各自的目录中编写 Vue 可组合函数和组件,无需导入即可使用,并受益于摇树优化和优化的 JS 包。
- 数据获取实用程序: Nuxt 提供可组合函数来处理兼容 SSR 的数据获取以及不同的策略。
- 零配置 TypeScript 支持: 通过我们自动生成的类型和
tsconfig.json,无需学习 TypeScript 即可编写类型安全的代码。 - 配置的构建工具: 我们默认使用Vite来支持开发中的热模块替换(HMR)以及将您的代码打包用于生产,并内置最佳实践。
结语
今天的 SSR,是由前端工程师主导、基于现代框架、兼顾性能与体验的“智能渲染策略”,而非回到 PHP 时代。历史不是简单的重复,而是更高层次的综合。