Skip to content

SSR 之 renderToString:服务器端如何将 vnode 渲染成 HTML 字符串

服务器端渲染(SSR)的目标是将 Vue 组件 VNode 转换为 HTML 字符串。@vue/server-renderer 包的核心 renderToString 函数负责此任务。

然而,Vue 3 的 setup 可以是 async 的,serverPrefetch 钩子也返回 Promise。这就带来一个巨大挑战:渲染器如何在“等待”一个异步 Promise 的同时,继续渲染其他“同步”的部分?

答案是:缓冲区(Buffer)。Vue SSR 不是在“同步”地生成一个长字符串,而是在“异步”地构建一个包含 stringPromise 的数组,最后再将其“展开”(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 是一个以“异步优先”为前提设计的精密系统:

  1. async setup 是挑战:它使组件渲染(renderComponentVNode可能返回一个 Promise
  2. SSRBuffer 是解决方案:它是一个混合数组 ((string | Promise)[]),push 函数负责“污染”这个数组,标记其为 hasAsync
  3. unrollBuffer 是收尾renderToString 必须 await unrollBuffer,它负责递归地 await 所有嵌套的 Promise,将其“展开”为最终的 HTML 字符串。
  4. ssrRender 是优化@vue/compiler-ssr 会生成专用于 SSR 的 ssrRender 函数,它绕过 VNode,直接调用 push 来“打印”字符串,这是 SSR 的“快速路径”。
  5. Suspense 在 SSR 中只等待await),不显示 fallback
  6. Teleport 在 SSR 中将内容渲染到 context,而不是 DOM。

微信公众号二维码

Last updated: