Appearance
第 17 章 SSR 与同构渲染
本章要点
- SSR 的本质:在服务端将组件树渲染为 HTML 字符串,在客户端"激活"为可交互应用
- Vue 3 SSR 架构:@vue/server-renderer 的流式渲染管线
- renderToString vs renderToStream:一次性输出与流式输出的权衡
- Hydration(激活)的完整流程:从静态 HTML 到响应式组件树的 12 个步骤
- Hydration Mismatch 的检测机制与常见陷阱
- Vue 3.6 的 Lazy Hydration:按需激活与性能优化
- 同构代码的约束:生命周期、平台 API、状态污染的三重挑战
- SSR 与 Suspense 的协作:异步数据获取的服务端解决方案
- Nuxt 3 的 SSR 引擎:Nitro + Vue SSR 的工程化实践
"首屏白屏 3 秒"——这大概是 SPA 最让人头疼的问题。用户点开链接,看到的是一片空白,浏览器还在忙着下载 JavaScript、解析执行、请求数据、渲染 DOM。而 SSR 的核心思想非常直白:既然浏览器渲染慢,那就让服务器先把 HTML 拼好,直接发给浏览器。
但事情远没有这么简单。服务端渲染完 HTML 后,客户端还需要"接管"这些 HTML,让它变成可交互的 Vue 应用——这个过程叫 Hydration(激活/注水)。整个流程涉及两套渲染器的协调、状态的序列化与反序列化、生命周期的差异处理等一系列精密的工程实现。
17.1 整体架构
Vue 3 的 SSR 由三个核心包协作完成:
createSSRApp vs createApp
SSR 应用的入口是 createSSRApp 而不是 createApp。它们的核心差异在于渲染器的选择:
typescript
// @vue/runtime-dom 中的实现
export const createSSRApp = ((...args) => {
const app = ensureHydrationRenderer().createApp(...args)
// 注入 SSR 上下文
const { mount } = app
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
const container = normalizeContainer(containerOrSelector)
if (container) {
return mount(container, true, resolveRootNamespace(container))
// ^^^^ isHydrate = true
}
}
return app
}) as CreateAppFunction<Element>关键在第二个参数 isHydrate = true。这告诉渲染器:不要从零创建 DOM,而是复用已有的 HTML 节点。
服务端渲染管线
typescript
// @vue/server-renderer 的核心流程
export async function renderToString(
input: App | VNode,
context: SSRContext = {}
): Promise<string> {
// 1. 创建渲染缓冲区
const buffer: SSRBuffer = createBuffer()
// 2. 安装 SSR 上下文
if (isVNode(input)) {
// 直接渲染 VNode
renderVNode(buffer.push, input, context)
} else {
// App 实例:安装后渲染根组件
const vnode = createVNode(input._component, input._props)
vnode.appContext = input._context
renderComponentVNode(buffer.push, vnode, context)
}
// 3. 等待所有异步操作完成
const result = await unrollBuffer(buffer)
await context.__watcherEffects?.forEach(e => e())
return result
}SSRBuffer 是一个特殊的数据结构,它可以包含字符串和 Promise。这使得渲染过程可以处理异步组件和 <Suspense>:
typescript
type SSRBufferItem = string | SSRBuffer | Promise<SSRBuffer>
type SSRBuffer = SSRBufferItem[] & { hasAsync?: boolean }
function createBuffer(): SSRBuffer {
let appendable = false
const buffer: SSRBuffer = [] as any
return {
getBuffer: () => buffer,
push(item: SSRBufferItem) {
const isStringItem = isString(item)
// 优化:连续字符串合并,减少数组元素数量
if (appendable && isStringItem) {
buffer[buffer.length - 1] += item as string
} else {
buffer.push(item)
}
appendable = isStringItem
if (!isStringItem && (item as any).hasAsync) {
buffer.hasAsync = true
}
}
}
}注意字符串合并优化:连续的字符串被拼接为一个,减少了最终 unrollBuffer 时的遍历次数。这个小优化在渲染大型页面时效果显著——一个包含 1000 个静态节点的页面,合并后可能只有几十个 buffer 元素。
17.2 服务端组件渲染
组件渲染为字符串
在服务端,Vue 不需要创建真实 DOM 节点,只需要输出 HTML 字符串。这意味着整个渲染流程可以大幅简化:
typescript
function renderComponentVNode(
push: PushFn,
vnode: VNode,
parentComponent: ComponentInternalInstance | null = null,
slotScopeId?: string
): void {
const instance = createComponentInstance(vnode, parentComponent, null)
// 1. 设置组件(与客户端相同)
const setupResult = setup
? callWithErrorHandling(setup, instance, [instance.props, setupContext])
: undefined
// 2. 处理异步 setup(服务端特有)
if (isPromise(setupResult)) {
const p = setupResult.then(/* ... */)
push(p as any)
return
}
// 3. 渲染子树
const subTree = (instance.subTree = renderComponentRoot(instance))
renderVNode(push, subTree, instance)
}注意一个关键差异:服务端的 setup 可以是异步的。在客户端,异步 setup 必须配合 <Suspense> 使用,但在服务端,所有异步操作都会被自然地等待。这是因为 renderToString 本身就返回 Promise,而 SSRBuffer 天然支持嵌套 Promise。
指令的服务端处理
大多数指令在服务端没有意义(比如 v-on 绑定事件),但有些需要特殊处理:
typescript
// v-show 的 SSR 处理
function ssrRenderStyle(raw: unknown): string {
// v-show="false" → style="display:none"
if (!raw) return ''
if (isString(raw)) return raw
// 对象语法
let styles = ''
for (const key in raw as Record<string, string>) {
const value = (raw as Record<string, string>)[key]
if (value != null && value !== '') {
styles += `${hyphenate(key)}:${value};`
}
}
return styles
}
// v-model 的 SSR 处理
function ssrRenderAttrs(attrs: Record<string, unknown>): string {
let result = ''
for (const key in attrs) {
if (key === 'innerHTML' || key === 'textContent') continue
const value = attrs[key]
if (key === 'class') {
result += ` class="${ssrRenderClass(value)}"`
} else if (key === 'style') {
result += ` style="${ssrRenderStyle(value)}"`
} else {
result += ssrRenderDynamicAttr(key, value)
}
}
return result
}v-model 在服务端被渲染为对应的属性值。比如 <input v-model="name"> 在服务端会渲染为 <input value="John">,而 v-on 的事件绑定则被完全忽略。
17.3 流式渲染
为什么需要流式渲染
renderToString 需要等整个页面渲染完成后才能发送响应。对于大型页面,这意味着用户要等待几百毫秒甚至几秒才能看到第一个字节。流式渲染(renderToStream)解决了这个问题:
renderToStream 的实现
typescript
export function renderToStream(
input: App | VNode,
context: SSRContext = {}
): Readable {
const stream = new Readable({
read() {} // 由 push 驱动
})
// 异步渲染,渐进式推送
Promise.resolve(renderToString(input, context))
.then(html => {
stream.push(html)
stream.push(null) // 结束信号
})
.catch(err => {
stream.destroy(err)
})
return stream
}
// Vue 3.3+ 的 Web Streams API 支持
export function renderToWebStream(
input: App | VNode,
context: SSRContext = {}
): ReadableStream {
return new ReadableStream({
start(controller) {
renderToString(input, context).then(html => {
controller.enqueue(new TextEncoder().encode(html))
controller.close()
})
}
})
}在实际生产环境中,更高效的方式是利用 SSRBuffer 的分层结构实现真正的逐块推送:
typescript
// 更细粒度的流式渲染(概念实现)
async function* renderToIterator(
input: App | VNode,
context: SSRContext = {}
): AsyncGenerator<string> {
const buffer = createBuffer()
renderVNode(buffer.push, createVNode(input), context)
// 遍历 buffer,遇到 Promise 则等待
for (const item of buffer.getBuffer()) {
if (isString(item)) {
yield item
} else if (isPromise(item)) {
const resolved = await item
yield* yieldBuffer(resolved)
}
}
}TTFB 优化策略
流式渲染的核心价值在于降低 TTFB(Time To First Byte)。结合以下策略可以进一步优化:
typescript
// 策略 1:提前发送 <head>,不等组件渲染
app.use((req, res) => {
// 立刻发送 HTML 头部(包含 CSS link)
res.write(`<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/style.css">
<link rel="preload" href="/app.js" as="script">
</head>
<body>
`)
// 然后流式渲染 Vue 应用
const stream = renderToStream(app)
stream.pipe(res, { end: false })
stream.on('end', () => {
res.end('</body></html>')
})
})
// 策略 2:利用 Suspense 实现分块推送
const App = {
template: `
<header>立刻渲染的导航</header>
<Suspense>
<template #default>
<AsyncMainContent />
</template>
<template #fallback>
<div class="skeleton">加载中...</div>
</template>
</Suspense>
`
}