Appearance
SSR 之 renderToString:服务器端如何将 vnode 渲染成 HTML 字符串
服务器端渲染(SSR)的目标是将 Vue 组件 VNode 转换为 HTML 字符串。@vue/server-renderer 包的核心 renderToString 函数负责此任务。
然而,Vue 3 的 setup 可以是 async 的,serverPrefetch 钩子也返回 Promise。这就带来一个巨大挑战:渲染器如何在“等待”一个异步 Promise 的同时,继续渲染其他“同步”的部分?
答案是:缓冲区(Buffer)。Vue SSR 不是在“同步”地生成一个长字符串,而是在“异步”地构建一个包含 string 和 Promise 的数组,最后再将其“展开”(unroll)为最终的 HTML。
核心机制:SSRBuffer (缓冲区)**
renderToString 的所有工作都围绕 SSRBuffer 展开。
typescript
// core/packages/server-renderer/src/render.ts
// 缓冲区是一个“混合数组”
export type SSRBuffer = SSRBufferItem[] & { hasAsync?: boolean }
// 缓冲区项:可以是字符串,可以是 Promise(它会 resolve 为另一个缓冲区)
export type SSRBufferItem = string | SSRBuffer | Promise<SSRBuffer>
// 推送函数
export type PushFn = (item: SSRBufferItem) => void
// 创建缓冲区
export function createBuffer() {
let appendable = false // 字符串合并优化
const buffer: SSRBuffer = []
return {
getBuffer(): SSRBuffer { return buffer },
push(item: SSRBufferItem): void {
const isStringItem = isString(item)
// 1. 【字符串合并】
// 如果连续 push 字符串,则合并它们,
// 而不是 push 多个数组项
if (appendable && isStringItem) {
buffer[buffer.length - 1] += item as string
return
}
buffer.push(item)
appendable = isStringItem
// 2. 【标记异步】
// 如果 push 了一个 Promise,
// 整个缓冲区就被“污染”为异步
if (isPromise(item) || (isArray(item) && item.hasAsync)) {
buffer.hasAsync = true
}
},
}
}push 函数是 SSR 的“画笔”,所有渲染函数都通过调用 push 来“绘制”HTML。
“异步”的来源:renderComponentVNode
同步渲染很简单(如 renderElementVNode),就是 push('<p>')、push('hello')、push('</p>')。
异步的来源是组件。renderComponentVNode 负责处理组件 VNode,它必须直面 async setup。
typescript
// core/packages/server-renderer/src/render.ts
export function renderComponentVNode(
vnode: VNode,
): SSRBuffer | Promise<SSRBuffer> { // 【注意】返回值可能是 Promise
// 1. 创建实例
const instance = createComponentInstance(vnode, ...)
// 2. 【核心】执行 setup
const res = setupComponent(instance, true /* isSSR */)
const hasAsyncSetup = isPromise(res)
const prefetches = instance.sp /* serverPrefetch */
// 3. 【异步路径】
// 如果 setup 是 async 或有 serverPrefetch
if (hasAsyncSetup || prefetches) {
const p: Promise<unknown> = hasAsyncSetup
? (res as Promise<void>)
: Promise.resolve()
// 4. 返回一个 Promise,
// 这个 Promise 会“等待” setup 和 prefetch
return p
.then(() => {
if (prefetches) {
// 等待所有 prefetch 完成
return Promise.all(prefetches.map(p => p.call(instance.proxy)))
}
})
.then(() => {
// 【异步完成】
// 当所有异步操作完成后,才真正渲染子树
return renderComponentSubTree(instance)
})
} else {
// 5. 【同步路径】
// 如果组件是同步的,直接渲染子树
return renderComponentSubTree(instance)
}
}renderComponentVNode 的设计是 SSR 异步机制的关键:
- 同步组件:返回一个
SSRBuffer(字符串数组)。 - 异步组件:返回一个
Promise<SSRBuffer>(一个承诺会返回字符串数组的 Promise)。
当 push(renderComponentVNode(...)) 时,push 函数就会智能地将 Promise 存入缓冲区。
renderToString:入口与“展开”
现在我们回看入口函数 renderToString,它的逻辑就非常清晰了:
typescript
// core/packages/server-renderer/src/renderToString.ts
export async function renderToString(
input: App | VNode,
context: SSRContext = {},
): Promise<string> {
// ... (省略 VNode 和 App 的包装) ...
// 1. 【渲染】
// 获取“根缓冲区”,它可能是同步的 (SSRBuffer)
// 也可能是异步的 (Promise<SSRBuffer>)
const buffer = await renderComponentVNode(vnode)
// 2. 【展开】
// 调用 unrollBuffer,它会“解开”所有嵌套的 Promise
// 并拼装成最终的 HTML 字符串
const result = await unrollBuffer(buffer as SSRBuffer)
// 3. (处理 Teleport 和清理)
await resolveTeleports(context)
return result
}
// “展开”缓冲区的实现
async function unrollBuffer(buffer: SSRBuffer): Promise<string> {
let ret = ''
// 1. 遍历缓冲区
for (let i = 0; i < buffer.length; i++) {
let item = buffer[i]
// 2. 【等待】
// 如果这一项是 Promise,就 await 它
if (isPromise(item)) {
item = await item
}
// 3. 【递归/追加】
// 如果(解析后的)项是数组,就递归展开
// 如果是字符串,就追加
if (isArray(item)) {
ret += await unrollBuffer(item)
} else {
ret += item as string
}
}
return ret
}renderToString -> renderComponentVNode -> renderComponentSubTree -> renderVNode... 这个过程像一个递归的“推土机”,push 同步的字符串,**“挂起”异步的 Promise。最后,unrollBuffer 再“收割”**所有 Promise 的结果,组装成 HTML。
“VNode 路径” vs “编译路径”
renderComponentSubTree 实际上有两条路径来渲染组件的“内容”:
typescript
// core/packages/server-renderer/src/render.ts
function renderComponentSubTree(
instance: ComponentInternalInstance,
): SSRBuffer | Promise<SSRBuffer> {
const { getBuffer, push } = createBuffer()
const comp = instance.type
// 1. 【编译路径 - Fast Path】
// 如果编译器提供了 ssrRender 函数 (来自 @vue/compiler-ssr)
const ssrRender = instance.ssrRender || (comp as ComponentOptions).ssrRender
if (ssrRender) {
// 【执行】
// 这个函数被编译成“直接 push 字符串”,
// 而不是创建 VNode。
// e.g., ssrRender(_ctx, _push, _parent, ...)
ssrRender(
instance.proxy,
push, // 直接传递 push 函数
instance,
// ...
)
} else if (instance.render) {
// 2. 【VNode 路径 - Slow Path】
// 如果没有 ssrRender (例如:手写 render 函数)
// 只能先创建 VNode
const vnode = renderComponentRoot(instance)
// 然后再递归地“翻译” VNode
renderVNode(push, vnode, instance)
}
return getBuffer()
}为什么 ssrRender 这么快?@vue/compiler-ssr 会将模板编译成完全不同的代码:
客户端
render(编译产物):javascript// 返回 VNode,用于 diff return (_openBlock(), _createElementBlock("div", null, "Hello"))服务端
ssrRender(编译产物):javascript// 直接操作 buffer (push),没有 VNode function ssrRender(_ctx, _push, _parent, _attrs) { _push(`<div${_ssrRenderAttrs(_attrs)}>Hello</div>`) }
ssrRender 跳过了 VNode 的创建、Diff 和 renderVNode 的递归 switch 判断,直接生成 HTML 字符串,这是 Vue SSR 高性能的真正秘诀。
特殊组件的处理
Suspense (在 SSR 中“失效”)
<Suspense> 在服务端的行为与客户端完全不同。
- 客户端:显示
fallback-> 等待Promise-> 切换到default。 - 服务端:直接等待
Promise(async setup)。它永远不会渲染fallback。
typescript
// core/packages/server-renderer/src/helpers/ssrRenderSuspense.ts
export async function ssrRenderSuspense(
push: PushFn,
{ default: renderContent }: Record<string, (() => void) | undefined>,
): Promise<void> {
// 【关键】
// renderContent() 会返回一个 Promise<SSRBuffer>
// ssrRenderSuspense 会“await”它,
// 等待“默认”内容渲染完毕
if (renderContent) {
await renderContent()
} else {
push(``)
}
// 注意:'fallback' 插槽在这里被“故意忽略”了
}这保证了 SSR 吐出的 HTML 永远是“最终形态”,不会包含“加载中”的状态。
Teleport (重定向到 context)
<Teleport> 在 SSR 中也不能“传送” DOM,它会将内容渲染到 context 对象的一个特殊属性中。
typescript
// core/packages/server-renderer/src/helpers/ssrRenderTeleport.ts
export function ssrRenderTeleport(
push: PushFn,
content: () => void,
target: string, // 'to' 属性
disabled: boolean,
parentComponent: ComponentInternalInstance,
) {
// 1. 如果禁用,在“原地”渲染
if (disabled) {
content()
return
}
// 2. 获取 SSR 上下文
const context = parentComponent.appContext.provides[ssrContextKey]!
// 3. 【关键】
// 创建一个“新”的 buffer
const { getBuffer, push: teleportPush } = createBuffer()
// 4. 将内容 push 到“新” buffer
content(teleportPush)
// 5. 将这个“新” buffer 存入 context
;(context.__teleportBuffers || (context.__teleportBuffers = {}))[target] =
getBuffer()
// (在 renderToString 的最后,resolveTeleports 会展开这些 buffer
// 并附加到 context.teleports[target] 上)
}总结
Vue 3 的 SSR 是一个以“异步优先”为前提设计的精密系统:
async setup是挑战:它使组件渲染(renderComponentVNode)可能返回一个Promise。SSRBuffer是解决方案:它是一个混合数组 ((string | Promise)[]),push函数负责“污染”这个数组,标记其为hasAsync。unrollBuffer是收尾:renderToString必须awaitunrollBuffer,它负责递归地await所有嵌套的Promise,将其“展开”为最终的 HTML 字符串。ssrRender是优化:@vue/compiler-ssr会生成专用于 SSR 的ssrRender函数,它绕过 VNode,直接调用push来“打印”字符串,这是 SSR 的“快速路径”。Suspense在 SSR 中只等待(await),不显示fallback。Teleport在 SSR 中将内容渲染到context,而不是 DOM。
