服务端渲染(SSR)
服务端渲染(Server-Side Rendering,SSR)是指在服务器端将前端应用渲染为 HTML,然后发送给客户端,客户端再进行"水合"(hydration)使其具有交互性。相比纯客户端渲染,SSR 可以提供更好的首屏性能和 SEO 友好性。
Vite 提供了内置的 SSR 支持,让你可以在开发和生产环境中使用相同的配置和工具链。
SSR 基本概念
为什么需要 SSR?
传统的单页应用(SPA)存在以下问题:
- 首屏加载慢:浏览器需要下载并执行 JavaScript 后才能渲染内容
- SEO 不友好:搜索引擎爬虫可能无法正确索引 JavaScript 渲染的内容
- 弱网体验差:在网速较慢的环境下,用户可能长时间看到空白页面
SSR 通过在服务器端预渲染页面,解决了这些问题:
- 用户可以更快看到完整内容
- 搜索引擎可以正确索引页面内容
- 在弱网环境下也能提供较好的用户体验
CSR vs SSR
// 客户端渲染(CSR)流程
// 1. 浏览器请求页面
// 2. 服务器返回空白 HTML + JS bundle
// 3. 浏览器下载并执行 JS
// 4. JS 渲染页面内容
// 服务端渲染(SSR)流程
// 1. 浏览器请求页面
// 2. 服务器执行 JS,渲染完整 HTML
// 3. 浏览器显示完整页面
// 4. JS 加载后进行水合,添加交互功能
项目结构
一个典型的 SSR 应用需要分离服务端和客户端的入口文件:
my-ssr-app/
├── index.html # HTML 模板
├── server.js # Express 服务器(开发环境)
├── package.json
├── vite.config.js
└── src/
├── main.js # 通用的应用代码
├── App.vue # 根组件
├── entry-client.js # 客户端入口
└── entry-server.js # 服务端入口
index.html
HTML 文件需要包含一个占位符,用于插入服务端渲染的内容:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR App</title>
</head>
<body>
<!-- 服务端渲染内容的插入点 -->
<div id="app"><!--ssr-outlet--></div>
<!-- 客户端入口 -->
<script type="module" src="/src/entry-client.js"></script>
</body>
</html>
<!--ssr-outlet--> 是占位符,你可以使用任何可精确匹配的字符串替代。
客户端入口
entry-client.js 负责在客户端挂载应用:
// src/entry-client.js
import { createApp } from './main'
const { app } = createApp()
// 挂载应用到 DOM
app.mount('#app')
服务端入口
entry-server.js 导出一个渲染函数,供服务器调用:
// src/entry-server.js
import { createApp } from './main'
export async function render(url) {
const { app, router } = createApp()
// 设置路由
await router.push(url)
await router.isReady()
// 返回渲染的 HTML
return app.renderToString()
}
通用应用代码
main.js 创建一个可以在服务端和客户端共享的应用实例:
// src/main.js
import { createSSRApp } from 'vue'
import { createRouter } from './router'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const router = createRouter()
app.use(router)
return { app, router }
}
注意:每个请求都需要创建一个新的应用实例,避免状态污染。
开发环境配置
在开发环境中,我们需要配置一个服务器来处理 SSR 请求。推荐使用 Vite 的中间件模式。
创建开发服务器
// server.js
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import { createServer as createViteServer } from 'vite'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
async function createServer() {
const app = express()
// 以中间件模式创建 Vite 服务器
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
})
// 使用 Vite 的 connect 实例作为中间件
app.use(vite.middlewares)
// 处理所有请求
app.use('*', async (req, res, next) => {
const url = req.originalUrl
try {
// 1. 读取 index.html
let template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8',
)
// 2. 应用 Vite HTML 转换
// 这会注入 Vite HMR 客户端,并应用插件的 HTML 转换
template = await vite.transformIndexHtml(url, template)
// 3. 加载服务端入口
// ssrLoadModule 自动将 ESM 源代码转换为可在 Node.js 中使用的格式
const { render } = await vite.ssrLoadModule('/src/entry-server.js')
// 4. 渲染应用 HTML
const appHtml = await render(url)
// 5. 将渲染的 HTML 注入模板
const html = template.replace('<!--ssr-outlet-->', appHtml)
// 6. 返回渲染的 HTML
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (e) {
// 让 Vite 修复堆栈跟踪
vite.ssrFixStacktrace(e)
next(e)
}
})
app.listen(5173, () => {
console.log('Server running at http://localhost:5173')
})
}
createServer()
修改 package.json
{
"scripts": {
"dev": "node server.js",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.js --outDir dist/server"
}
}
生产环境构建
SSR 应用的生产构建需要两个步骤:
- 客户端构建:生成客户端 JavaScript bundle
- 服务端构建:生成服务端渲染代码
构建命令
# 客户端构建
vite build --outDir dist/client
# 服务端构建(使用 --ssr 标志)
vite build --ssr src/entry-server.js --outDir dist/server
生产服务器配置
生产环境的服务器需要处理静态文件服务:
// server.js(完整版本)
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import { createServer as createViteServer } from 'vite'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const isProduction = process.env.NODE_ENV === 'production'
async function createServer() {
const app = express()
let vite
if (!isProduction) {
// 开发环境:使用 Vite 中间件
vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
})
app.use(vite.middlewares)
} else {
// 生产环境:提供静态文件服务
app.use(express.static(path.resolve(__dirname, 'dist/client'), {
index: false, // 不自动提供 index.html
}))
}
app.use('*', async (req, res, next) => {
const url = req.originalUrl
try {
let template
let render
if (!isProduction) {
// 开发环境
template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8',
)
template = await vite.transformIndexHtml(url, template)
render = (await vite.ssrLoadModule('/src/entry-server.js')).render
} else {
// 生产环境
template = fs.readFileSync(
path.resolve(__dirname, 'dist/client/index.html'),
'utf-8',
)
render = (await import('./dist/server/entry-server.js')).render
}
const appHtml = await render(url)
const html = template.replace('<!--ssr-outlet-->', appHtml)
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (e) {
if (!isProduction) {
vite.ssrFixStacktrace(e)
}
next(e)
}
})
app.listen(5173)
}
createServer()
预加载指令
为了优化页面加载性能,可以生成预加载指令,让浏览器提前加载所需的资源。
生成 SSR Manifest
在客户端构建时添加 --ssrManifest 标志:
vite build --outDir dist/client --ssrManifest
这会在 dist/client/.vite/ssr-manifest.json 生成一个清单文件,包含模块 ID 到 chunk 和资源文件的映射。
使用 SSR Manifest
// entry-server.js
const manifest = require('../dist/client/.vite/ssr-manifest.json')
export async function render(url, manifest) {
// ... 渲染逻辑
// 获取渲染过程中使用的模块 ID
const usedModules = ctx.modules // Vue 插件自动收集
// 根据 manifest 生成预加载指令
const preloadLinks = renderPreloadLinks(usedModules, manifest)
return {
html: appHtml,
preloadLinks,
}
}
预渲染 / 静态站点生成(SSG)
如果路由和所需数据在构建时就已知,可以预渲染这些路由为静态 HTML。这是一种静态站点生成(SSG)的形式。
// prerender.js
import fs from 'node:fs'
import path from 'node:path'
import { render } from './dist/server/entry-server.js'
// 已知的路由列表
const routes = ['/', '/about', '/contact']
for (const route of routes) {
const { html } = await render(route)
const filePath = route === '/'
? 'dist/client/index.html'
: `dist/client${route}/index.html`
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
// 读取模板并注入渲染内容
let template = fs.readFileSync('dist/client/index.html', 'utf-8')
template = template.replace('<!--ssr-outlet-->', html)
fs.writeFileSync(filePath, template)
console.log(`Pre-rendered: ${route}`)
}
SSR 特定配置
SSR 外部化
默认情况下,Vite 会将依赖从 SSR 转换中"外部化",提高开发和构建速度。
如果某个依赖需要被 Vite 的管道处理(例如使用了未转译的 Vite 特性),可以将其添加到 ssr.noExternal:
// vite.config.js
export default defineConfig({
ssr: {
noExternal: [
// 这些依赖会被 Vite 处理
'my-lib-needs-transformation',
],
},
})
对于链接依赖,默认不会被外部化以支持 HMR。如果不希望这样:
export default defineConfig({
ssr: {
external: ['my-linked-dep'],
},
})
SSR 目标
默认的 SSR 构建目标是 Node.js 环境,但你也可以在 Web Worker 中运行服务器:
export default defineConfig({
ssr: {
target: 'webworker',
},
})
SSR 打包
在某些场景(如 Web Worker 运行时),你可能希望将 SSR 构建打包为单个 JavaScript 文件:
export default defineConfig({
ssr: {
noExternal: true, // 将所有依赖打包进去
},
})
条件逻辑
在代码中区分服务端和客户端环境:
if (import.meta.env.SSR) {
// 仅在服务端运行的代码
console.log('Running on server')
} else {
// 仅在客户端运行的代码
console.log('Running in browser')
}
这段代码会在构建时被静态替换,未使用的分支会被 tree-shaking 移除。
插件的 SSR 支持
一些框架(如 Vue、Svelte)会根据客户端还是 SSR 环境将组件编译为不同格式。Vite 会在以下插件钩子中传递 ssr 属性:
resolveIdloadtransform
export function mySSRPlugin() {
return {
name: 'my-ssr-plugin',
transform(code, id, options) {
if (options?.ssr) {
// 执行 SSR 特定的转换
return transformForSSR(code)
}
return transformForClient(code)
},
}
}
常见问题
环境差异
服务端没有浏览器的 API(如 window、document)。在服务端代码中访问这些 API 会导致错误。
解决方案:使用 import.meta.env.SSR 进行条件判断。
if (!import.meta.env.SSR) {
// 仅在客户端执行
window.addEventListener('resize', handleResize)
}
最佳实践:将浏览器特定的代码封装到独立的函数或模块中,通过条件导入来避免服务端执行:
// utils/browser.js
export function setupBrowserListeners() {
window.addEventListener('resize', handleResize)
document.addEventListener('click', handleClick)
}
// main.js
if (!import.meta.env.SSR) {
import('./utils/browser.js').then(({ setupBrowserListeners }) => {
setupBrowserListeners()
})
}
生命周期钩子
在 SSR 中,某些生命周期钩子不会执行:
onMounted(Vue)componentDidMount(React)useEffect(React)
这些钩子中的代码只会在客户端执行。这是因为 SSR 只生成静态 HTML,不执行交互逻辑。
理解原理:SSR 的目的是生成初始 HTML 内容,而不是运行完整的应用逻辑。事件监听、定时器、网络请求等应该在客户端进行初始化。
// Vue 示例
import { onMounted, onServerPrefetch } from 'vue'
export default {
setup() {
// 服务端预取数据
onServerPrefetch(async () => {
await fetchInitialData()
})
// 客户端挂载后执行
onMounted(() => {
setupEventListeners()
startTimers()
})
}
}
状态共享问题
每个请求都应该创建新的应用实例,避免状态污染:
// ❌ 错误:共享实例会导致请求之间状态混乱
const app = createApp()
export { app }
// ✅ 正确:每个请求创建新实例
export function createApp() {
return createSSRApp(App)
}
为什么这是个问题? Node.js 服务器是单进程多请求的。如果多个请求共享同一个应用实例:
- 请求 A 设置了用户数据到 store
- 请求 B 开始处理,看到了请求 A 的用户数据
- 导致数据泄露和安全问题
全局变量也要注意:不仅是应用实例,任何模块级的全局变量都可能导致问题:
// ❌ 危险:模块级变量会在请求间共享
let currentUser = null
export function setUser(user) {
currentUser = user // 会被其他请求看到
}
// ✅ 正确:使用请求级上下文
export function createContext() {
return {
user: null,
// 其他请求级数据
}
}
数据预取策略
SSR 应用通常需要在服务端预取数据,以便生成完整的 HTML 内容。
方案一:组件级数据预取(推荐)
// 在路由组件中定义数据预取方法
export default {
async serverPrefetch() {
await this.$store.dispatch('fetchData')
}
}
方案二:路由级数据预取
// router.js
const routes = [
{
path: '/user/:id',
component: UserView,
// 定义数据预取函数
async fetchData({ params, store }) {
await store.dispatch('fetchUser', params.id)
}
}
]
// entry-server.js
export async function render(url, manifest) {
const { app, router, store } = createApp()
await router.push(url)
await router.isReady()
// 执行匹配路由的数据预取
const matchedRoutes = router.currentRoute.value.matched
await Promise.all(
matchedRoutes.map(route =>
route.components.default.fetchData?.({
params: router.currentRoute.value.params,
store
})
)
)
return { html: app.renderToString(), state: store.state }
}
依赖兼容性
某些依赖可能无法在 Node.js 环境中运行,需要:
- 检查依赖是否支持 SSR
- 使用
ssr.external外部化问题依赖 - 寻找替代方案
常见问题依赖类型:
- 直接使用
window或document的库 - 使用浏览器专有 API 的库(如 Web Animations API)
- 依赖浏览器环境的 UI 组件库
解决方案:
// vite.config.js
export default defineConfig({
ssr: {
// 外部化不支持 SSR 的依赖
external: ['problematic-lib'],
// 或将某些依赖纳入构建处理
noExternal: ['lib-needs-transformation']
}
})
条件导入依赖:
// 使用动态导入避免服务端执行
let heavyLibrary = null
export async function getHeavyLibrary() {
if (import.meta.env.SSR) {
throw new Error('This library is not available in SSR')
}
if (!heavyLibrary) {
heavyLibrary = await import('heavy-browser-library')
}
return heavyLibrary
}
Environment API(开发中)
Vite 正在开发一个改进的 SSR API,称为 Environment API。这是一个低级别的 API,旨在为框架和库作者提供更灵活的 SSR 控制。
当前状态:Environment API 正在开发中,尚未稳定。Vite 团队正在与生态系统合作,定期举行会议以更好地协作。
主要改进方向:
- 更好的多环境支持(客户端、服务端、Web Worker等)
- 统一的模块处理管道
- 更灵活的构建配置
- 更好的热更新支持
如果你是框架作者,可以关注 Environment API 文档 了解最新进展。
写在最后
SSR 能显著改善首屏加载性能和 SEO,但实现起来相对复杂。Vite 提供了完整的 SSR 支持,包括开发环境的中间件模式和生产环境的分离构建。
如果你刚开始学习 SSR,建议先阅读框架的 SSR 文档(如 Vue SSR 指南 或 React SSR 文档),理解基本概念后再结合 Vite 实践。开发过程中要注意避免服务端和客户端环境的差异问题,比如不要在服务端代码中访问 window 或 document。