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)
}