跳到主要内容

服务端渲染(SSR)

服务端渲染(Server-Side Rendering,SSR)是指在服务器端将前端应用渲染为 HTML,然后发送给客户端,客户端再进行"水合"(hydration)使其具有交互性。相比纯客户端渲染,SSR 可以提供更好的首屏性能和 SEO 友好性。

Vite 提供了内置的 SSR 支持,让你可以在开发和生产环境中使用相同的配置和工具链。

SSR 基本概念

为什么需要 SSR?

传统的单页应用(SPA)存在以下问题:

  1. 首屏加载慢:浏览器需要下载并执行 JavaScript 后才能渲染内容
  2. SEO 不友好:搜索引擎爬虫可能无法正确索引 JavaScript 渲染的内容
  3. 弱网体验差:在网速较慢的环境下,用户可能长时间看到空白页面

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 应用的生产构建需要两个步骤:

  1. 客户端构建:生成客户端 JavaScript bundle
  2. 服务端构建:生成服务端渲染代码

构建命令

# 客户端构建
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 属性:

  • resolveId
  • load
  • transform
export function mySSRPlugin() {
return {
name: 'my-ssr-plugin',
transform(code, id, options) {
if (options?.ssr) {
// 执行 SSR 特定的转换
return transformForSSR(code)
}
return transformForClient(code)
},
}
}

常见问题

环境差异

服务端没有浏览器的 API(如 windowdocument)。在服务端代码中访问这些 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 服务器是单进程多请求的。如果多个请求共享同一个应用实例:

  1. 请求 A 设置了用户数据到 store
  2. 请求 B 开始处理,看到了请求 A 的用户数据
  3. 导致数据泄露和安全问题

全局变量也要注意:不仅是应用实例,任何模块级的全局变量都可能导致问题:

// ❌ 危险:模块级变量会在请求间共享
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 环境中运行,需要:

  1. 检查依赖是否支持 SSR
  2. 使用 ssr.external 外部化问题依赖
  3. 寻找替代方案

常见问题依赖类型

  • 直接使用 windowdocument 的库
  • 使用浏览器专有 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 实践。开发过程中要注意避免服务端和客户端环境的差异问题,比如不要在服务端代码中访问 windowdocument