只为用户体验而生的组件:Suspense 组件!

802 阅读17分钟

<Suspense> 是 Vue 的一个内置组件,用来在组件树中协调对异步依赖的处理。他让我们可以在组件树上层等待下层的多个嵌套异步依赖项解析完成,并可以在等待时渲染一个加载状态,即在页面上展示后备内容

使用 <Suspense> 可以避免在加载异步依赖时,页面长时间的空白,尽早地展示内容,从而在一定程度上起到缓解用户焦虑,提升用户体验的效果。

异步依赖

我们想象这样一种组件层级结构:

<Suspense>
└─ <Dashboard>
   ├─ <Profile>
   │  └─ <FriendStatus>(组件有异步的 setup())
   └─ <Content>
      ├─ <ActivityFeed> (异步组件)
      └─ <Stats>(异步组件)

在这个组件树中有多个嵌套组件,要渲染出他们,首先得解析一些异步资源。如果没有 <Suspense>,则他们每个都需要处理自己的加载、报错和完成状态。在最坏的情况下,我们可能会在页面上看到三个旋转的加载态,在不同的时间显示出内容。这可能会破坏用户体验。

有了 <Suspense> 组件后,我们就可以在等待整个多层级组件树中的各个异步依赖获取结果时,在顶层展示出加载中或加载失败的状态。这样无论这个组件树内部有多少嵌套的异步依赖,都只会展示一个旋转的加载态。

<Suspense> 组件可等待的异步依赖有两种类型,分别为异步的 setup 钩子异步组件

async setup()

组合式 API 中组件的 setup() 钩子可以是异步的:

<script>
function getCount() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3)
    }, 3000)
  })
}
// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  async setup() {
    const count = await getCount()
    return {
      count
    }
  }
}
</script>

如果使用 async setup ,顶层 await 表达式会自动让该组件成为一个异步依赖

<script type="text/x-template" id="comp-a">
  <div>
    数量:{{ count }}
  </div>
</script>

<script>
function getCount() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3)
    }, 3000)
  })
}
// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  async setup() {
    const count = await getCount()
    return {
      count
    }
  }
}
</script>

async setup() 的完整例子如下:

<script src="../../../dist/vue.global.js"></script>

<script type="text/x-template" id="comp-a">
  <div>
    数量:{{ count }}
  </div>
</script>

<script>
function getCount() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3)
    }, 3000)
  })
}
// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  async setup() {
    const count = await getCount()
    return {
      count
    }
  }
}
</script>

<div id="demo">  
  <suspense>
    <template #default>
      <comp-a />
    </template>
    <!-- 在 #fallback 插槽中显示 “正在加载中” -->
    <template #fallback>
        正在加载中...
    </template>
  </suspense>
</div>

<script>
const { createApp } = Vue

createApp({
  components: {
    CompA
  }
}).mount('#demo')
</script>

上面这段代码会先在页面上展示正在加载中...的后备内容,3s 后才展示 CompA 组件的内容。

异步组件

异步组件也可被当作 <Suspense> 组件的异步依赖。当异步组件的组件关系链上有一个 <Suspense> 组件时,异步组件的加载状态默认由 <Suspense> 控制,而该异步组件自己的加载、报错、延时和超时等选项都将被忽略。

如下面的异步组件相关的例子,前 3s 页面会展示后备内容,3s 后才会渲染异步组件的具体内容:

<script src="../../../dist/vue.global.js"></script>

<div id="demo">
  <suspense>
    <template #default>
      <async-comp />
    </template>
    <!-- 在 #fallback 插槽中显示 “正在加载中” -->
    <template #fallback>
        正在加载中...
    </template>
  </suspense>
</div>

<script>
const { createApp, defineAsyncComponent } = Vue

// 定义异步组件
const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({template: '<div>异步组件</div>'})
    }, 3000)
  })
})

createApp({
  components: {
    AsyncComp
  }
}).mount('#demo')
</script>

异步组件也可以通过在选项中指定 suspensible: false 表明不用 <Suspense> 控制,并让组件始终自己控制其加载状态。

如下面的例子,在 AsyncComp 异步组件中指定了 suspensible: false ,则页面中不会展示 <Suspense> 组件的后备内容:

<script src="../../../dist/vue.global.js"></script>

<div id="demo">
  <suspense>
    <template #default>
      <async-comp />
    </template>
    <!-- 在 #fallback 插槽中显示 “正在加载中” -->
    <template #fallback>
        正在加载中...
    </template>
  </suspense>
</div>

<script>
const { createApp, defineAsyncComponent } = Vue

// 定义异步组件
const AsyncComp = defineAsyncComponent({
  loader: () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve({template: '<div>异步组件</div>'})
      }, 3000)
    })
  },
  // 由于指定了 suspensible 为 false ,
  // 则不会展示 Suspense 组件中 fallback 插槽中的内容
  suspensible: false
})

createApp({
  components: {
    AsyncComp
  }
}).mount('#demo')
</script>

注意

对于含有异步的 setup 钩子的组件需要嵌套在 <Suspense> 组件中,否则该组件不会渲染具体内容。具体例子如下:

<script src="../../../dist/vue.global.js"></script>

<script type="text/x-template" id="comp-a">
  <div>
    数量:{{ count }}
  </div>
</script>

<script>
function getCount() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3)
    }, 3000)
  })
}
// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  async setup() {
    const count = await getCount()
    return {
      count
    }
  }
}
</script>

<div id="demo">  
  <comp-a />
</div>

<script>
const { createApp } = Vue

createApp({
  components: {
    CompA
  }
}).mount('#demo')
</script>

如下图所示,CompA 组件没有渲染任何内容:

pic37.png

对于异步组件则没有此限制,如下面的例子,AsyncComp 组件可以正常渲染具体内容:

<script src="../../../dist/vue.global.js"></script>

<div id="demo">
  <async-comp />
</div>

<script>
const { createApp, defineAsyncComponent } = Vue

// 定义异步组件
const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({template: '<div>异步组件</div>'})
    }, 3000)
  })
})

createApp({
  components: {
    AsyncComp
  }
}).mount('#demo')
</script>

如下图所示,AsyncComp 组件 3s 后正常渲染出具有内容:

pic38.png

加载中状态

<Suspense> 组件有两个插槽:#default#fallback 。两个插槽都只允许一个直接子节点。

<Suspense>
  <!-- 具有深层异步依赖的组件 -->
  <Dashboard />

  <!-- 在 #fallback 插槽中显示 “正在加载中” -->
  <template #fallback>
    Loading...
  </template>
</Suspense>

在初始渲染时,<Suspense> 将在内存中渲染其默认的插槽内容。如果在这个过程中遇到任何异步依赖,则会进入挂起状态。在挂起状态期间,展示的是后备插槽的内容。当所有遇到的异步依赖都完成后,<Suspense> 会进入完成状态,并将展示出默认插槽的内容。

如果在初次渲染时没有遇到异步依赖,<Suspense> 会直接进入完成状态。

进入完成状态后,只有当默认插槽的根节点被替换时,<Suspense> 才会回到挂起状态。组件树中新的更深层次的异步依赖不会造成 <Suspense> 回退到挂起状态。

发生回退时,后备内容不会立即展示出来。相反,<Suspense> 在等待新内容和异步依赖完成时,会展示之前 #default 插槽的内容。这个行为可以通过一个 timeout prop 进行配置:在等待渲染新内容耗时超过 timeout 之后,<Suspense> 将会切换为展示后备内容。若 timeout 值为 0 将导致在替换默认内容时立即显示后备内容。

没有设置 <Suspense>timeout 属性的例子

如下面的例子所示,此例子没有设置 <Suspense>timeout 属性。

点击 切换外部组件 按钮,由于默认插槽的根节点被替换,<Suspense> 回到挂起状态。发生回退后,后备内容没有展示出来,相反,<Suspense> 在等待新内容和异步依赖完成时,会展示之前 #default 插槽的内容。在此例子中,展示的是 CompA 组件的内容,等 AsyncCompOuter 组件加载完成后,CompA 组件的内容才被替换,从而展示 AsyncCompOuter 组件的内容。

点击 切换内部组件 按钮,由于组件树中新的更深层次的异步依赖不会造成 <Suspense> 回退到挂起状态。所以在 AsyncCompInnerB 组件加载完成前,CompA 组件一开始会渲染空白节点,待 AsyncCompInnerB 组件加载完成后,才会渲染 AsyncCompInnerB 组件的内容。

<script src="../../../dist/vue.global.js"></script>

<script type="text/x-template" id="comp-a">
  <div>
    <button @click="changeInnerComp">切换内部组件</button>
    <async-comp-inner-a v-if="toggleInnerCompVisible" />
    <async-comp-inner-b v-else />
  </div>
</script>

<script type="text/x-template" id="comp-b">
  <div>
    <button @click="changeComp">切换外部组件</button>
    <suspense>
      <template #default>
        <comp-a v-if="toggleCompVisible" />
        <async-comp-outer v-else />
      </template>
      <!-- 在 #fallback 插槽中显示 loading -->
      <template #fallback>
        loading...
      </template>
    </suspense>
  </div>
</script>


<div id="demo">
  <div>
    <comp-b />
  </div>
</div>

<script>
const { createApp, defineAsyncComponent, ref } = Vue

// 定义异步组件 AsyncCompOuter
const AsyncCompOuter = defineAsyncComponent(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({template: '<div>异步组件 outer</div>'})
    }, 3000)
  })
})

// 定义异步组件 AsyncCompInnerA
const AsyncCompInnerA = defineAsyncComponent(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({template: '<div>异步组件 inner a</div>'})
    }, 5000)
  })
})

// 定义异步组件 AsyncCompInnerB
const AsyncCompInnerB = defineAsyncComponent(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({template: '<div>异步组件 inner b</div>'})
    }, 3000)
  })
})

// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  components: {
    AsyncCompInnerA,
    AsyncCompInnerB
  },
  setup() {
    const toggleInnerCompVisible = ref(true)
    const changeInnerComp = () => {
      // 进入完成状态后,只有当默认插槽的根节点被替换时,<Suspense> 才会回到挂起状态。
      // 组件树中新的更深层次的异步依赖不会造成 <Suspense> 回退到挂起状态。
      toggleInnerCompVisible.value = !toggleInnerCompVisible.value
    }
    return {
      toggleInnerCompVisible,
      changeInnerComp
    }
  }
}

// 定义 CompB 组件
const CompB = {
  template: '#comp-b',
  components: {
    CompA,
    AsyncCompOuter
  },
  setup() {
    const toggleCompVisible = ref(true)
    const changeComp = () => {
      // 发生回退时,后备内容不会立即展示出来。
      // 相反,<Suspense> 在等待新内容和异步依赖完成时,会展示之前 #default 插槽的内容。
      toggleCompVisible.value = !toggleCompVisible.value
    }
    return {
      toggleCompVisible,
      changeComp
    }
  }
}

createApp({
  components: {
    CompB,
  }
}).mount('#demo')

</script>

gif7.gif

设置 <Suspense>timeout 属性的例子

此例子设置了 <Suspense>timeout 属性为 0 。

timeout 值为 0 将导致在替换默认内容时立即显示后备内容。因此,点击 切换外部组件 按钮,由于默认插槽的根节点被替换,<Suspense> 回到挂起状态,此时后备内容立马展示出来了,待 AsyncCompOuter 组件加载完成后,后备内容才被替换为 AsyncCompOuter 组件中的内容。

与上小节中的例子一样,点击 切换内部组件 按钮,由于组件树中新的更深层次的异步依赖不会造成 <Suspense> 回退到挂起状态。所以在 AsyncCompInnerB 组件加载完成前,CompA 组件一开始会渲染空白节点,待 AsyncCompInnerB 组件加载完成后,才会渲染 AsyncCompInnerB 组件的内容。

<script src="../../../dist/vue.global.js"></script>

<script type="text/x-template" id="comp-a">
  <div>
    <button @click="changeInnerComp">切换内部组件</button>
    <async-comp-inner-a v-if="toggleInnerCompVisible" />
    <async-comp-inner-b v-else />
  </div>
</script>

<script type="text/x-template" id="comp-b">
  <div>
    <button @click="changeComp">切换外部组件</button>
    <!-- 若 timeout 值为 0 将导致在替换默认内容时立即显示后备内容 -->
    <suspense timeout="0">
      <template #default>
        <comp-a v-if="toggleCompVisible" />
        <async-comp-outer v-else />
      </template>
      <!-- 在 #fallback 插槽中显示 loading -->
      <template #fallback>
        loading...
      </template>
    </suspense>
  </div>
</script>


<div id="demo">
  <div>
    <comp-b />
  </div>
</div>

<script>
const { createApp, defineAsyncComponent, ref } = Vue

// 定义异步组件 AsyncCompOuter
const AsyncCompOuter = defineAsyncComponent(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({template: '<div>异步组件 outer</div>'})
    }, 3000)
  })
})

// 定义异步组件 AsyncCompInnerA
const AsyncCompInnerA = defineAsyncComponent(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({template: '<div>异步组件 inner a</div>'})
    }, 5000)
  })
})

// 定义异步组件 AsyncCompInnerB
const AsyncCompInnerB = defineAsyncComponent(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({template: '<div>异步组件 inner b</div>'})
    }, 3000)
  })
})

// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  components: {
    AsyncCompInnerA,
    AsyncCompInnerB
  },
  setup() {
    const toggleInnerCompVisible = ref(true)
    const changeInnerComp = () => {
      // 进入完成状态后,只有当默认插槽的根节点被替换时,<Suspense> 才会回到挂起状态。
      // 组件树中新的更深层次的异步依赖不会造成 <Suspense> 回退到挂起状态。
      toggleInnerCompVisible.value = !toggleInnerCompVisible.value
    }
    return {
      toggleInnerCompVisible,
      changeInnerComp
    }
  }
}

const CompB = {
  template: '#comp-b',
  components: {
    CompA,
    AsyncCompOuter
  },
  setup() {
    const toggleCompVisible = ref(true)
    const changeComp = () => {
      // 发生回退时,后备内容不会立即展示出来。
      // 相反,<Suspense> 在等待新内容和异步依赖完成时,会展示之前 #default 插槽的内容。
      // 这个行为可以通过一个 timeout prop 进行配置:在等待渲染新内容耗时超过 timeout 之后,
      // <Suspense> 将会切换为展示后备内容。
      // 若 timeout 值为 0 将导致在替换默认内容时立即显示后备内容。
      toggleCompVisible.value = !toggleCompVisible.value
    }
    return {
      toggleCompVisible,
      changeComp
    }
  }
}

createApp({
  components: {
    CompB,
  }
}).mount('#demo')

</script>

gif8.gif

事件

<Suspense> 组件会触发三个事件,分别为:pendingresolvefallback

  • pending 事件:在进入挂起状态时触发

  • resolve 事件:在默认(default)插槽完成获取新内容时触发

  • fallback 事件:在后备(fallback)插槽的内容显示时触发

掘友们可以运行下面的例子,感受一下 pending 事件、resolve 事件和 fallback 事件的执行时机:

<script src="../../../dist/vue.global.js"></script>

<script type="text/x-template" id="comp-a">
  <div>
    数量:{{ count }}
  </div>
</script>

<script>
function getCount() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3)
    }, 3000)
  })
}
// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  async setup() {
    const count = await getCount()
    return {
      count
    }
  }
}
</script>

<div id="demo">  
  <suspense
    @resolve="handleSuspenseResolve"
    @pending="handleSuspensePending"
    @fallback="handleSuspenseFallback"
  >
    <template #default>
      <comp-a />
    </template>
    <!-- 在 #fallback 插槽中显示 “正在加载中” -->
    <template #fallback>
        正在加载中...
    </template>
  </suspense>
</div>

<script>
const { createApp } = Vue

createApp({
  components: {
    CompA
  },
  setup() {
    const handleSuspenseResolve = () => {
      console.log('resolve 事件')
    }
    const handleSuspensePending = () => {
      console.log('pending 事件')
    }
    const handleSuspenseFallback = () => {
      console.log('fallback 事件')
    }
    return {
      handleSuspenseResolve,
      handleSuspensePending,
      handleSuspenseFallback
    }
  }
}).mount('#demo')
</script>

当你不想使用后备(fallback)插槽时,可以借助这些事件在加载新组件时在之前的 DOM 最上层显示一个加载指示器。

如下面的例子所示:

<script src="../../../dist/vue.global.js"></script>

<script type="text/x-template" id="comp-a">
  <div>
    数量:{{ count }}
  </div>
</script>

<script>
function getCount() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3)
    }, 3000)
  })
}
// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  async setup() {
    const count = await getCount()
    return {
      count
    }
  }
}
</script>

<div id="demo">  
  <suspense
    @resolve="handleSuspenseResolve"
    @pending="handleSuspensePending"
  >
    <comp-a />
  </suspense>
  <!-- 代替后备插槽,在页面展示加载指示器 -->
  <div v-if="loadingShow">loading...</div>
</div>

<script>
const { createApp, ref } = Vue

createApp({
  components: {
    CompA
  },
  setup() {
    const loadingShow = ref(false)
    const handleSuspenseResolve = () => {
      console.log('resolve 事件')
      loadingShow.value = false
    }
    const handleSuspensePending = () => {
      console.log('pending 事件')
      loadingShow.value = true
    }
    return {
      loadingShow,
      handleSuspenseResolve,
      handleSuspensePending,
    }
  }
}).mount('#demo')
</script>

错误处理

<Suspense> 组件自身目前还不提供错误处理,不过你可以使用 errorCaptured 选项或者 onErrorCaptured() 钩子,在使用到 <Suspense> 的父组件中捕获和处理异步错误。

下面是使用 errorCaptured 选项、 onErrorCaptured() 钩子捕获错误的例子:

<script src="../../../dist/vue.global.js"></script>

<script type="text/x-template" id="comp-a">
  <div>
    数量:{{ count }}
  </div>
</script>

<script>
function getCount() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3)
    }, 3000)
  })
}
// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  async setup() {
    const count = await getCount()
    throw Error('我是故意抛出的错误')
    return {
      count
    }
  }
}
</script>

<div id="demo">  
  <suspense>
    <template #default>
      <comp-a />
    </template>
    <!-- 在 #fallback 插槽中显示 “正在加载中” -->
    <template #fallback>
        正在加载中...
    </template>
  </suspense>
</div>

<script>
const { createApp, onErrorCaptured } = Vue

createApp({
  components: {
    CompA
  },
  setup() {
    onErrorCaptured((errorInfo) => {
      console.log('onErrorCaptured errorInfo ', errorInfo)
    })
  },
  errorCaptured(errorInfo) {
    console.log('errorCaptured errorInfo ', errorInfo)
  }
}).mount('#demo')
</script>

pic39.png

和其他组件结合

<Suspense> 可以和 <Transition><KeepAlive> 等组件结合使用。

下面是 <Suspense><Transition> 结合使用的例子:

<script src="../../../dist/vue.global.js"></script>

<style>
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.5s ease;
}
.fade-enter-from, .fade-leave-to {
  opacity: 0;
}
</style>

<div id="demo">
  <transition name="fade" mode="out-in">
    <suspense>
      <template #default>
        <component :is="AsyncComponent"></component>
      </template>
      <!-- 在 #fallback 插槽中显示 “正在加载中” -->
      <template #fallback>
          <div>正在加载中...</div>
      </template>
    </suspense>
  </transition>
</div>

<script>
const { createApp, defineAsyncComponent } = Vue

const AsyncComponent = defineAsyncComponent(() =>
  new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        template: '<div>Hello from Async Component !</div>',
      });
    }, 2000);
  })
);


createApp({
  setup() {
    return {
      AsyncComponent,
    }
  }
}).mount('#demo')
</script>

gif5.gif

下面是 <Suspense><Transition><KeepAlive> 结合使用的例子:

<script src="../../../dist/vue.global.js"></script>

<script type="text/x-template" id="comp-a">
  <div>
    <button @click="handleAddNum">增加数字</button>
    数字:{{ num }}
  </div>
</script>

<style>
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.5s ease;
}
.fade-enter-from, .fade-leave-to {
  opacity: 0;
}
</style>

<div id="demo">
  <button @click="changCompA">切换 CompA</button>
  <button @click="changeAsyncComponent">切换 AsyncComponent</button>
  <transition name="fade" mode="out-in">
    <keep-alive>
      <suspense>
        <template #default>
          <component :is="activeComponent"></component>
        </template>
        <!-- 在 #fallback 插槽中显示 “正在加载中” -->
        <template #fallback>
            <div>正在加载中...</div>
        </template>
      </suspense>
    </keep-alive>
  </transition>
</div>

<script>
const {
  createApp,
  ref,
  shallowRef,
  defineAsyncComponent
} = Vue

function getCount() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3)
    }, 3000)
  })
}

// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  async setup() {
    const count = await getCount()
    const num = ref(count)
    const handleAddNum = () => {
      num.value += 1
    }
    return {
      num,
      handleAddNum
    }
  }
}

// 定义 AsyncComponent
const AsyncComponent = defineAsyncComponent(() =>
  new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        template: '<div>Hello from Async Component !</div>',
      });
    }, 2000);
  })
);


createApp({
  setup() {
    const activeComponent = shallowRef(CompA)
    const changCompA = () => {
      activeComponent.value = CompA
    }
    const changeAsyncComponent = () => {
      activeComponent.value = AsyncComponent
    }
    return {
      activeComponent,
      changCompA,
      changeAsyncComponent
    }
  }
}).mount('#demo')
</script>

gif6.gif

嵌套使用

当我们有多个类似于下方的异步组件(常见于嵌套或基于布局的路由)时:

<Suspense>
  <component :is="DynamicAsyncOuter">
    <component :is="DynamicAsyncInner" />
  </component>
</Suspense>

<Suspense> 创建了一个边界,它将如预期的那样解析树下的所有异步组件。然而,当我们更改 DynamicAsyncOuter 时,<Suspense> 会正确地等待它,但当我们更改 DynamicAsyncInner 时,嵌套的 DynamicAsyncInner 会呈现为一个空节点,直到它被解析为止 (而不是之前的节点或回退插槽)。这会引起页面闪烁。

这么讲述也许会有点抽象,我们看看具体的例子感受一下。

首先定义内部的异步依赖 CompA ,该异步依赖会在 3s 后完成解析。

// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  async setup() {
    console.log('CompA render')
    const count = await getCount()
    const num = ref(count)
    const handleAddNum = () => {
      num.value += 1
    }
    return {
      num,
      handleAddNum
    }
  }
}
function getCount() {
  return new Promise((resolve) => {
    setTimeout(() => {
      // 让内部异步依赖 3s 后完成解析
      resolve(3)
    }, 3000)
  })
}

CompA 组件的模板如下:

<script type="text/x-template" id="comp-a">
  <div>
    <button @click="handleAddNum">增加数字</button>
    内部组件CompA 数字:{{ num }}
  </div>
</script>

同理,我们定义另一个内部的异步依赖 CompBCompB 也是 3s 后完成解析:

// 定义 CompB 组件
const CompB = {
  template: '#comp-b',
  async setup() {
    console.log('CompB render')
    const n = await getCount()
    const num = ref(n)
    const handleAddNum = () => {
      num.value += 1
    }
    return {
      num,
      handleAddNum
    }
  }
}

CompB 组件的模板如下:

<script type="text/x-template" id="out-comp-b">
  <div>
    <button @click="handleAddNum">增加总值</button>
    外部组件 OutCompB 数值:{{ num }}
    <slot></slot>
  </div>
</script>

定义完内部依赖后,我们定义外部依赖。

首先定义外部异步依赖 OutCompA 组件,该组件会在 300ms 后完成解析:

// 定义 OutCompA 组件
const OutCompA = {
  template: '#out-comp-a',
  async setup() {
    const count = await getNumbers()
    const num = ref(count)
    const handleAddNum = () => {
      num.value += 1
    }
    return {
      num,
      handleAddNum
    }
  }
}
function getNumbers() {
  return new Promise((resolve) => {
    setTimeout(() => {
      // 让外部异步依赖 300ms 后完成解析
      resolve(1)
    }, 300)
  })
}

外部异步依赖 OutCompA 组件的模板如下:

<script type="text/x-template" id="out-comp-a">
  <div>
    <button @click="handleAddNum">增加总数</button>
    外部组件 OutCompA 数字:{{ num }}
    <slot></slot>
  </div>
</script>

然后定义另一个外部异步依赖 OutCompB 组件,该组件也是 300ms 后完成解析:

// 定义 OutCompB 组件
const OutCompB = {
  template: '#out-comp-b',
  async setup() {
    const n = await getNumbers()
    const num = ref(n)
    const handleAddNum = () => {
      num.value += 1
    }
    return {
      num,
      handleAddNum
    }
  }
}

外部异步依赖 OutCompB 组件的模板如下:

<script type="text/x-template" id="out-comp-b">
  <div>
    <button @click="handleAddNum">增加总值</button>
    外部组件 OutCompB 数值:{{ num }}
    <slot></slot>
  </div>
</script>

然后我们页面中引入这两个内部异步依赖和两个外部异步依赖:

<script src="../../../dist/vue.global.js"></script>

<div id="demo">
  <button @click="changeInnerComp">切换内部异步组件</button>
  <button @click="changeOuterComp">切换外部异步组件</button>
  <suspense>
    <template #default>
      <component :is="DynamicAsyncOuter">
        <component :is="DynamicAsyncInner"></component>
      </component>
    </template>
    <!-- 在 #fallback 插槽中显示 “正在加载中” -->
    <template #fallback>
      <div>正在加载中...</div>
    </template>
  </suspense>
</div>

<script>
const {
  createApp,
  ref,
  shallowRef
} = Vue

createApp({
  setup() {
    const DynamicAsyncInner = shallowRef(CompA)
    let innerNum = 0
    const changeInnerComp = () => {
      innerNum++
      console.log('changeInnerComp  ', {
        innerNum
      })      
      if (innerNum % 2) {
        DynamicAsyncInner.value = CompB
        return
      }
      DynamicAsyncInner.value = CompA
    }

    const DynamicAsyncOuter = shallowRef(OutCompA)
    let outerNum = 0
    const changeOuterComp = () => {
      outerNum++
      console.log('changeOuterComp  ', {
        outerNum
      })
      if (outerNum % 2) {
        DynamicAsyncOuter.value = OutCompB
        return
      }
      DynamicAsyncOuter.value = OutCompA
    }
    return {
      DynamicAsyncInner,
      DynamicAsyncOuter,
      changeInnerComp,
      changeOuterComp
    }
  }
}).mount('#demo')
</script>

该例子的运行结果如下:

gif1.gif

初始加载页面时,<Suspense> 如预期的那样解析树下的所有异步组件,由于受内部异步依赖的拖累,整体要 3s 后才完成解析。

然后点击 切换外部异步组件 按钮,<Suspense> 会正确地等待外部依赖完成解析,由于内部依赖的拖累,要等 3s 后才完成解析,由于 <Suspense> 的作用,在所有异步依赖完成解析时,页面一直展示之前的节点(注意,不是回退插槽的内容)。

接着,点击 切换内部异步组件 按钮,注意,组件树中深层次的异步依赖不会造成 <Suspense> 回退到挂起状态。因此在异步依赖完成解析之前,渲染的是空节点,从而造成页面闪烁。

此例子完整实现如下:

<script src="../../../dist/vue.global.js"></script>

<script type="text/x-template" id="comp-a">
  <div>
    <button @click="handleAddNum">增加数字</button>
    内部组件CompA 数字:{{ num }}
  </div>
</script>

<script type="text/x-template" id="comp-b">
  <div>
    <button @click="handleAddNum">增加数量</button>
    内部组件CompB 数量:{{ num }}
  </div>
</script>

<script type="text/x-template" id="out-comp-a">
  <div>
    <button @click="handleAddNum">增加总数</button>
    外部组件 OutCompA 数字:{{ num }}
    <slot></slot>
  </div>
</script>

<script type="text/x-template" id="out-comp-b">
  <div>
    <button @click="handleAddNum">增加总值</button>
    外部组件 OutCompB 数值:{{ num }}
    <slot></slot>
  </div>
</script>


<div id="demo">
  <button @click="changeInnerComp">切换内部异步组件</button>
  <button @click="changeOuterComp">切换外部异步组件</button>
  <suspense>
    <template #default>
      <component :is="DynamicAsyncOuter">
        <component :is="DynamicAsyncInner"></component>
      </component>
    </template>
    <!-- 在 #fallback 插槽中显示 “正在加载中” -->
    <template #fallback>
      <div>正在加载中...</div>
    </template>
  </suspense>
</div>

<script>
const {
  createApp,
  ref,
  shallowRef
} = Vue

function getCount() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3)
    }, 3000)
  })
}

// 定义 CompA 组件
const CompA = {
  template: '#comp-a',
  async setup() {
    console.log('CompA render')
    const count = await getCount()
    const num = ref(count)
    const handleAddNum = () => {
      num.value += 1
    }
    return {
      num,
      handleAddNum
    }
  }
}

function getNumbers() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(1)
    }, 300)
  })
}

// 定义 CompB 组件
const CompB = {
  template: '#comp-b',
  async setup() {
    console.log('CompB render')
    const n = await getCount()
    const num = ref(n)
    const handleAddNum = () => {
      num.value += 1
    }
    return {
      num,
      handleAddNum
    }
  }
}

// 定义 OutCompA 组件
const OutCompA = {
  template: '#out-comp-a',
  async setup() {
    const count = await getNumbers()
    const num = ref(count)
    const handleAddNum = () => {
      num.value += 1
    }
    return {
      num,
      handleAddNum
    }
  }
}

// 定义 OutCompB 组件
const OutCompB = {
  template: '#out-comp-b',
  async setup() {
    const n = await getNumbers()
    const num = ref(n)
    const handleAddNum = () => {
      num.value += 1
    }
    return {
      num,
      handleAddNum
    }
  }
}

createApp({
  setup() {
    const DynamicAsyncInner = shallowRef(CompA)
    let innerNum = 0
    const changeInnerComp = () => {
      innerNum++
      console.log('changeInnerComp  ', {
        innerNum
      })      
      if (innerNum % 2) {
        DynamicAsyncInner.value = CompB
        return
      }
      DynamicAsyncInner.value = CompA
    }

    const DynamicAsyncOuter = shallowRef(OutCompA)
    let outerNum = 0
    const changeOuterComp = () => {
      outerNum++
      console.log('changeOuterComp  ', {
        outerNum
      })
      if (outerNum % 2) {
        DynamicAsyncOuter.value = OutCompB
        return
      }
      DynamicAsyncOuter.value = OutCompA
    }
    return {
      DynamicAsyncInner,
      DynamicAsyncOuter,
      changeInnerComp,
      changeOuterComp
    }
  }
}).mount('#demo')
</script>

为了解决上面例子中页面闪烁的问题,可以在内部嵌套一个 <Suspense> 并设置 suspensible 属性为 true 解决。

通过为 <Suspense> 设置 suspensible 属性为 true ,可让组件树所有异步依赖项处理都交给父级 <Suspense> 处理。

<div id="demo">
  <button @click="changeInnerComp">切换内部异步组件</button>
  <button @click="changeOuterComp">切换外部异步组件</button>
  <suspense>
    <template #default>
      <component :is="DynamicAsyncOuter">
        <suspense :suspensible="true"> <!-- 内部的 suspense -->
          <component :is="DynamicAsyncInner"></component>
        </suspense>        
      </component>
    </template>
    <!-- 在 #fallback 插槽中显示 “正在加载中” -->
    <template #fallback>
      <div>正在加载中...</div>
    </template>
  </suspense>
</div>

这样,点击 切换内部异步组件 按钮就不会出现空节点了:

gif2.gif

如果你不设置 suspensible 属性,内部的 <Suspense> 会被父级 <Suspense> 视为同步组件。同时他也有自己的回退插槽:

<div id="demo">
  <button @click="changeInnerComp">切换内部异步组件</button>
  <button @click="changeOuterComp">切换外部异步组件</button>
  <suspense>
    <template #default>
      <component :is="DynamicAsyncOuter">
        <suspense> <!-- 内部的 suspense -->
          <template #default>
            <component :is="DynamicAsyncInner"></component>
          </template>
          <!-- 在 #fallback 插槽中显示 "loading..." -->
          <template #fallback>
            <div>loading...</div>
          </template>
        </suspense>
      </component>
    </template>
    <!-- 在 #fallback 插槽中显示 “正在加载中” -->
    <template #fallback>
      <div>正在加载中...</div>
    </template>
  </suspense>
</div>

gif3.gif

初始加载页面时,由于内部异步依赖有自己的回退插槽,所以页面会先展示外部异步依赖的回退插槽内容,300ms 后,展示内部异步依赖的回退插槽内容。

点击 切换内部异步组件 按钮,由于内部 <Suspense> 组件回退到挂载状态,在内部异步依赖完成解析之前,一直展示之前的节点,而不会展示空节点,造成页面闪烁。

上面的例子中,虽然内部的 <Suspense> 定义了自己的后备插槽渲染的内容,但是点击 切换内部异步组件 按钮时并没展示后备插槽中的内容,那如何才能在点击 切换内部异步组件 按钮时展示出自己后备插槽中的内容呢?

这时,timeout prop 就派上用场了,我们可以在内部 <Suspense> 中设置 timeout 为 0 ,这样我们点击 切换内部异步组件 按钮时,就会马上显示后备插槽中的内容了:

<div id="demo">
  <button @click="changeInnerComp">切换内部异步组件</button>
  <button @click="changeOuterComp">切换外部异步组件</button>
  <suspense>
    <template #default>
      <component :is="DynamicAsyncOuter">
        <suspense timeout="0"> <!-- 内部的 suspense -->
          <template #default>
            <component :is="DynamicAsyncInner"></component>
          </template>
          <!-- 在 #fallback 插槽中显示 "loading..." -->
          <template #fallback>
            <div>loading...</div>
          </template>
        </suspense>
      </component>
    </template>
    <!-- 在 #fallback 插槽中显示 “正在加载中” -->
    <template #fallback>
      <div>正在加载中...</div>
    </template>
  </suspense>
</div>

gif4.gif

总结

<Suspense> 是 Vue 的一个内置组件,用来协调对组件树中对异步依赖的处理。可实现在加载异步依赖时,先在页面上展示后备内容的功能,减少白屏时间,提升页面的用户体验。

pic40.png

<Suspense> 支持的 Props

  • timeout:用于配置在等待渲染新内容耗时超过多久后,<Suspense> 才会切换为展示后备内容。若 timeout 值为 0 将导致在替换默认内容时立即显示后备内容。

  • suspensible:通过将 <Suspense> 设置 suspensible 为 true ,可将所有的异步依赖交由父级 <Suspense> 处理。

<Suspense> 支持的事件:

  • @resolve
  • @pending
  • @fallback

温馨提示:<Suspense> 目前仍处于实验阶段,请谨慎使用。

参考

  1. Suspense

  2. Suspense API