Skip to content

Suspense:处理组件的异步依赖

引言

Vue 3 允许 setup 函数是一个 async 函数,这意味着组件可以在 setup 内部等待异步操作(如 await fetchData())完成后再渲染。

vue
<script setup>
const userData = await fetchUser(props.userId) // "暂停"执行
</script>
<template>
  <div>{{ userData.name }}</div>
</template>

但这带来一个问题:在 fetchUser 完成之前,组件该渲染什么?

<Suspense> 是 Vue 为此提供的“异步边界”解决方案。它允许你“捕获”子孙组件中的异步依赖,并在“等待”期间,显示一个 fallback(回退)内容。

核心:async setup 如何“通知” Suspense?

Suspense 的机制围绕一个“通知与回调”的流程。

流程的起点在“子组件”

  1. 当一个带 async setup 的子组件(如 UserProfile)被创建时,setup 函数会返回一个 Promise。这个 Promise 被存储在 instance.asyncDep 上。
  2. 子组件开始挂载(mountComponent)。渲染器发现 instance.asyncDep 存在。
  3. 此时,子组件会向上查找组件树,寻找离它最近的 parentSuspense(即 <Suspense> 组件创建的“边界”)。
  4. 如果找到了,它会立即“注册依赖”:parentSuspense.registerDep(instance, setupRenderEffect)

这个 registerDep 调用,就是子组件在向 <Suspense> 发起通知:“我有一个异步依赖(asyncDep)还没完成,请你等待我。


子组件的“依赖登记处”:registerDep

registerDep (packages/runtime-core/src/components/Suspense.ts) 是 <Suspense> 边界对象上的一个方法。它负责监听子组件的 Promise

typescript
// SuspenseBoundary.registerDep (简化)
registerDep(instance: ComponentInternalInstance, setupRenderEffect) {
  const suspense = this // 'this' 是 Suspense 边界
  
  // 1. 【依赖计数】
  //    告诉 Suspense:“你现在有 1 个依赖正在等待”
  suspense.deps++ 
  
  // 2. 【监听 Promise】
  //    监听子组件的 async setup Promise
  instance.asyncDep!
    .catch(err => {
      handleError(err, instance, ErrorCodes.SETUP_FUNCTION)
    })
    .then(asyncSetupResult => {
      // 3. 【Promise 完成了】
      
      // 检查:如果 Suspense 已经切换了(pendingId 不匹配)
      // 或组件已卸载,就什么都不做
      if (instance.isUnmounted || suspense.isUnmounted || 
          suspense.pendingId !== instance.suspenseId) {
        return
      }
      
      // 4. 【依赖递减】
      //    告诉 Suspense:“我准备好了”
      suspense.deps--
      
      // 5. 【子组件收尾】
      //    子组件现在可以完成自己的渲染了
      instance.asyncResolved = true
      handleSetupResult(instance, asyncSetupResult, false) // 处理 setup 结果
      setupRenderEffect(instance, ...) // 执行子组件的真实渲染
      
      // 6. 【检查是否全部完成】
      //    如果我是最后一个完成的 (deps === 0)
      if (suspense.deps === 0) {
        // 通知 Suspense:“所有人都准备好了,你可以切换了!”
        suspense.resolve()
      }
    })
}

“Suspense”的“分流”策略:mountSuspense

Suspense 组件自己在 mount 时,会通过 mountSuspense 函数处理其渲染逻辑。

typescript
// core/packages/runtime-core/src/renderer.ts
function mountSuspense(
  vnode: VNode,
  container: RendererElement,
  anchor: RendererNode | null,
  // ...
) {
  const { p: patch, o: { createElement } } = rendererInternals
  
  // 1. 【创建“隐藏容器”】
  //    这是一个离屏的 div,用于“等待室”
  const hiddenContainer = createElement('div')
  
  // 2. 【创建 Suspense 边界】
  //    这是核心控制器,上面有 deps, registerDep, resolve 等
  const suspense = (vnode.suspense = createSuspenseBoundary(
    vnode,
    // ...
    hiddenContainer,
    // ...
  ))

  // 3. 【尝试在“隐藏容器”中渲染默认插槽】
  //    'pendingBranch' (等待分支) 指的是默认插槽内容
  patch(
    null,
    (suspense.pendingBranch = vnode.ssContent!), // ssContent 是 #default 插槽
    hiddenContainer, // 【关键】渲染到“隐藏容器”中
    null,
    parentComponent,
    suspense, // 将“自己”传递下去,供子组件 registerDep
  )
  
  // 4. 【检查依赖】
  //    在 patch 过程中,子组件的 async setup 已经被触发
  //    如果子组件调用了 registerDep, 那么 suspense.deps > 0
  if (suspense.deps > 0) {
    // 4a. 【有异步依赖】:
    //     渲染被“暂停”了
    
    // 触发 onPending, onFallback 事件
    triggerEvent(vnode, 'onPending')
    triggerEvent(vnode, 'onFallback')

    // 【渲染 Fallback】
    // 在“真实 DOM”中渲染 #fallback 插槽
    patch(
      null,
      vnode.ssFallback!, // ssFallback 是 #fallback 插槽
      container, // 【关键】渲染到“真实容器”
      anchor,
    )
    // 标记“激活分支”为 fallback
    setActiveBranch(suspense, vnode.ssFallback!)
    
  } else {
    // 4b. 【无异步依赖】:
    //     子组件是同步的,deps 仍然是 0
    //     直接“解析”,将“隐藏容器”的内容移到“真实 DOM”
    suspense.resolve(false, true)
  }
}

“切换”策略:resolve

deps 计数器归零时,suspense.resolve() 被调用。它的工作是完成内容的“切换”:

typescript
// SuspenseBoundary.resolve (简化)
resolve() {
  const {
    vnode,
    activeBranch,  // 当前激活的分支 (即 fallback VNode)
    pendingBranch, // 等待的分支 (即 default VNode,已在隐藏容器中)
    container,     // 真实的 DOM 容器
    effects        // 挂起的回调 (如子组件的 onMounted)
  } = suspense

  // 1. 【卸载 Fallback】
  //    卸载当前激活的“回退内容”
  if (activeBranch) {
    unmount(activeBranch, parentComponent, suspense, true)
  }
  
  // 2. 【移动 Default】
  //    将“隐藏容器”中已经渲染好的“等待分支” (pendingBranch)
  //    通过 DOM move 操作,“移动”到真实的 DOM 容器中
  move(pendingBranch!, container, anchor, MoveType.ENTER)

  // 3. 【激活 Default】
  //    将“激活分支”设为 default VNode
  setActiveBranch(suspense, pendingBranch!)
  
  // 4. 清理状态
  suspense.pendingBranch = null
  suspense.isInFallback = false

  // 5. 执行挂起的副作用 (如子组件的 onMounted)
  queuePostFlushCb(effects)

  // 触发 @resolve 事件
  triggerEvent(vnode, 'onResolve')
}

“嵌套与超时”

  • 嵌套 Suspense (suspensible: true): 默认情况下,子 Suspense 会独立工作。但如果设置了 suspensible: true,子 Suspense 在创建时(createSuspenseBoundary),会把自己注册为父 Suspense 的一个 dep。 这样,父 Suspense 就会等待子 Suspense 解析完毕后,才能一起 resolve

  • 超时 (timeout): 如果在 timeout 时间内 deps 仍未归零,Suspense 会强制显示 fallback。这是通过在 mountSuspense 中设置一个 setTimeout 来实现的。如果 timeout 触发,它会调用 suspense.fallback(),其逻辑是:卸载 pendingBranch(如果它还在的话),并挂载 fallback


总结

<Suspense> 是一个“异步协调器”,它解耦了“父组件的加载状态”和“子组件的异步逻辑”:

  1. “隐藏容器”:Suspense 在 mount 时创建一个隐藏的 div,用于尝试渲染 #default 内容(pendingBranch)。
  2. registerDep (依赖注册)async setup 子组件在 mount 时会“暂停”,并向上调用 parentSuspense.registerDep()
  3. deps (依赖计数)registerDep 使 suspense.deps 计数器 +1,并监听 Promise
  4. Fallback (回退)mountSuspense 检查 deps > 0,如果为 true,则只渲染 #fallback 内容(activeBranch)到真实 DOM 中。
  5. resolve (解析)Promise 完成后,在 registerDep.then() 回调中 deps--。当 deps 归零时,suspense.resolve() 被调用。
  6. “切换”resolve() 负责卸载 fallback (activeBranch),并将“隐藏容器”中已渲染完毕的 #default (pendingBranch) 移动到真实 DOM 中。

微信公众号二维码

Last updated: