Suspense:异步组件加载机制

在前面的文章中,我们学习了 Teleport 如何突破 DOM 树的限制。今天,我们将探索 Vue3 中另一个强大的特性:Suspense。它允许我们在等待异步组件时显示备用内容,极大地提升了用户体验。理解它的实现原理,将帮助我们更好地处理异步加载场景。

前言:为什么需要异步组件

从根本上来说,异步组件的实现不需要任何框架层面的支持,我们完全可以自己实现:

javascript 复制代码
const Component = () => import('Component.vue');

上述代码中,使用动态导入语句 import() 来加载组件,它会返回一个 Promise 实例,这样就实现了异步的方式来渲染页面。但是,这也带来了问题:

  • 如果 Component 组件加载失败了或者加载超时了怎么办?
  • 组件加载时,是否需要占位内容,比如 Loading 组件?
  • 加载失败后,是否需要重试?

以上这些问题,就是异步组件要真正解决的问题。

异步组件

异步组件的定义

所谓异步组件,就是指在需要时才加载的组件,而不需要在应用初始化时就全部加载。这样可以有效减少首屏加载时间,提升用户体验。

异步组件的基本使用

html 复制代码
<sctipt setup>
	import { defineAsyncComponent } from 'vue'
	
	// 最简单的用法
	const AsyncComp = defineAsyncComponent(() => 
	  import('./components/MyComponent.vue')
	)
</script>

<template>
  <AsyncComp />
</template>

defineAsyncComponent 解析

工作原理

defineAsyncComponent 的核心是一个高阶组件,它接收一个返回 Promise 的工厂函数,在组件需要渲染时才会执行这个工厂函数。

完整配置

javascript 复制代码
import { defineAsyncComponent, defineComponent } from 'vue';
import ErrorComponent from './components/Error.vue';
import LoadingComponent from './components/Loading.vue';

const AsyncComponentWithOptions = defineAsyncComponent({
  // 加载函数
  loader: () => import('./MyComponent.vue'),
  
  // 加载中组件
  loadingComponent: LoadingComponent,
  
  // 加载失败组件
  errorComponent: ErrorComponent,
  
  // 显示loading组件的延迟(默认200ms)
  delay: 200,
  
  // 超时时间(默认Infinity)
  timeout: 3000,
  
  // 是否可重试
  suspensible: true,
  
  // 错误处理
  onError(error, retry, fail, attempts) {
    if (attempts <= 3) {
      // 重试
      retry();
    } else {
      // 失败
      fail();
    }
  }
});

源码实现核心逻辑

javascript 复制代码
function defineAsyncComponent(source) {
  if (typeof source === 'function') {
    source = { loader: source }
  }

  const {
    loader,
    loadingComponent,
    errorComponent,
    delay = 200,
    timeout,
    suspensible = true,
    onError: userOnError
  } = source

  let resolvedComp = null

  return defineComponent({
    name: 'AsyncComponentWrapper',
    
    setup() {
      const loaded = ref(false)
      const error = ref(null)
      const loading = ref(false)
      
      let loadingTimer = null
      let retries = 0

      function load() {
        if (resolvedComp) {
          return Promise.resolve(resolvedComp)
        }

        return new Promise((resolve, reject) => {
          // 实际加载逻辑
          loader()
            .then(comp => {
              resolvedComp = comp
              loaded.value = true
              resolve(comp)
            })
            .catch(err => {
              error.value = err
              reject(err)
            })
        })
      }

      // 返回渲染逻辑
      return () => {
        if (loaded.value) {
          return h(resolvedComp)
        } else if (error.value && errorComponent) {
          return h(errorComponent, { error: error.value })
        } else if (loading.value && loadingComponent) {
          return h(loadingComponent)
        }
        // 返回一个注释节点或空
        return null
      }
    }
  })
}

Suspense 的设计哲学

定义与作用

Suspense 是一个内置组件,用于协调组件树中的异步依赖。它可以在等待异步组件解析时显示 fallback 内容。

基本结构

html 复制代码
<template>
  <Suspense>
    <!-- 异步组件等待完成时显示的内容 -->
    <template #default>
      <AsyncComponent />
    </template>
    
    <!-- 加载中的回退内容 -->
    <template #fallback>
      <div>Loading...</div>
    </template>
  </Suspense>
</template>

工作流程

插槽结构

javascript 复制代码
/**
 * Suspense组件定义
 */
const Suspense = {
  name: 'Suspense',
  __isSuspense: true,
  
  props: {
    timeout: {
      type: Number,
      default: 0
    }
  },
  
  setup(props, { slots }) {
    // 获取default和fallback插槽
    const defaultSlot = slots.default;
    const fallbackSlot = slots.fallback;
    
    // 状态管理
    const state = ref('pending'); // 'pending' | 'resolving' | 'resolved'
    const activeBranch = ref(null);
    const pendingBranch = ref(null);
    
    // 异步依赖收集
    const asyncDeps = new Set();
    const depPromises = [];
    
    // 挂起计数
    let pendingCount = 0;
    
    return {
      state,
      activeBranch,
      pendingBranch,
      asyncDeps,
      depPromises,
      pendingCount
    };
  },
  
  render() {
    const { state, activeBranch, pendingBranch } = this;
    
    if (state === 'pending') {
      // 显示fallback
      return this.fallbackSlot ? this.fallbackSlot() : null;
    }
    
    // 显示default内容
    return activeBranch || (this.defaultSlot ? this.defaultSlot() : null);
  }
};

Suspense 的挂起与恢复机制

异步依赖的类型

Suspense 可以处理两种类型的异步依赖:

  • 异步组件:使用 defineAsyncComponent 定义的组件
  • 异步 setupsetup 函数返回 Promise 的组件

上面的例子展示了如何使用 defineAsyncComponent 定义的组件,下面这个例子用来展示异步 setup

javascript 复制代码
import { ref } from 'vue'

export default {
  async setup() {
    // setup 返回 Promise,Suspense 会等待这个 Promise
    const data = ref(null)
    // 模拟异步数据获取
    data.value = await fetch('/api/data').then(r => r.json())
    
    return {
      data
    }
  }
}

挂起与恢复的源码分析

javascript 复制代码
// Suspense 组件的简化实现
function processSuspense(n1, n2, container) {
  const suspense = {
    // 挂起的 Promise 列表
    deps: [],
    
    // 正在挂起中的依赖数量
    pending: 0,
    
    // 挂起状态
    isResolved: false,
    
    // 异步依赖注册
    registerDep(instance, setupRender) {
      suspense.deps.push(instance)
      
      instance.asyncDep.catch(err => {
        // 错误处理
        suspense.handleError(err)
      }).then(setupRender => {
        // 依赖完成
        suspense.pending--
        if (suspense.pending === 0) {
          // 所有依赖都已完成,切换回正常状态
          suspense.resolve()
        }
      })
      
      if (suspense.pending++ === 0) {
        // 如果有 pending 状态,显示 fallback
        suspense.showFallback = true
      }
    },
    
    // 解析完成
    resolve() {
      suspense.isResolved = true
      suspense.showFallback = false
      // 重新渲染默认内容
    }
  }
}

手写实现 Suspense 组件

基础实现

javascript 复制代码
import { defineComponent, h, Fragment } from 'vue'

export default defineComponent({
  name: 'Suspense',
  
  props: {
    timeout: Number
  },
  
  setup(props, { slots }) {
    // 异步依赖列表
    const asyncDeps = new Set()
    let isResolved = false
    
    // 注册异步依赖的方法,暴露给子组件使用
    const registerAsyncDep = (asyncDep) => {
      if (isResolved) return
      
      asyncDeps.add(asyncDep)
      
      asyncDep
        .then(() => {
          asyncDeps.delete(asyncDep)
          if (asyncDeps.size === 0 && !isResolved) {
            resolve()
          }
        })
        .catch((err) => {
          console.error('Suspense: 异步依赖执行失败', err)
        })
    }
    
    // 解析完成
    const resolve = () => {
      isResolved = true
      // 触发重新渲染
    }
    
    // 如果设置了超时
    if (props.timeout) {
      setTimeout(() => {
        if (!isResolved) {
          console.warn(`Suspense: 超过 ${props.timeout}ms 未完成解析`)
        }
      }, props.timeout)
    }
    
    // 提供 registerAsyncDep 给后代组件使用
    provide('registerAsyncDep', registerAsyncDep)
    
    return () => {
      // 如果没有异步依赖或已解析,显示 default 内容
      if (isResolved || asyncDeps.size === 0) {
        return slots.default ? slots.default() : null
      }
      
      // 否则显示 fallback
      return slots.fallback ? slots.fallback() : null
    }
  }
})

结合 defineAsyncComponent

javascript 复制代码
// 增强的异步组件工厂函数
function createAsyncComponent(options) {
  const component = defineAsyncComponent(options)
  
  // 包装组件,使其与 Suspense 协作
  return defineComponent({
    name: 'SuspenseAsyncComponent',
    
    setup(props, { attrs, slots }) {
      const registerAsyncDep = inject('registerAsyncDep', null)
      const asyncComp = ref(null)
      
      // 异步加载组件
      const asyncDep = component().then(comp => {
        asyncComp.value = comp
      })
      
      // 向 Suspense 注册异步依赖
      if (registerAsyncDep) {
        registerAsyncDep(asyncDep)
      }
      
      return () => {
        if (asyncComp.value) {
          return h(asyncComp.value, attrs, slots)
        }
        return null
      }
    }
  })
}

异步依赖的等待与完成

嵌套 Suspense 的处理

html 复制代码
<template>
  <Suspense>
    <template #default>
      <!-- 父级异步组件 -->
      <ParentAsyncComponent>
        <!-- 子级 Suspense -->
        <Suspense>
          <template #default>
            <ChildAsyncComponent />
          </template>
          <template #fallback>
            <div>子组件加载中...</div>
          </template>
        </Suspense>
      </ParentAsyncComponent>
    </template>
    
    <template #fallback>
      <div>父组件加载中...</div>
    </template>
  </Suspense>
</template>

完成顺序控制

javascript 复制代码
// 等待多个异步依赖完成
const useSuspense = () => {
  const deps = ref([])
  const status = ref('pending') // pending | resolved | rejected
  
  const addDep = (promise) => {
    deps.value.push(promise)
    return promise
  }
  
  const waitForAll = async () => {
    try {
      await Promise.all(deps.value)
      status.value = 'resolved'
    } catch (err) {
      status.value = 'rejected'
      throw err
    }
  }
  
  return {
    status,
    addDep,
    waitForAll
  }
}

异步组件错误处理

完整的错误处理策略

javascript 复制代码
const AsyncComponentWithErrorHandling = defineAsyncComponent({
  loader: () => import('./MyComponent.vue'),
  
  errorComponent: defineComponent({
    props: ['error', 'retry'],
    setup(props) {
      return () => h('div', [
        h('p', `加载失败: ${props.error.message}`),
        h('button', { onClick: props.retry }, '重试')
      ])
    }
  }),
  
  onError(error, retry, fail, attempts) {
    // 错误分类处理
    if (error.name === 'ChunkLoadError') {
      // 路由懒加载错误,可能是网络问题
      if (attempts <= 3) {
        console.log(`重试第 ${attempts} 次...`)
        retry()
      } else {
        console.error('网络异常,请检查网络连接')
        fail()
      }
    } else if (error.name === 'TimeoutError') {
      // 超时错误
      console.error('组件加载超时')
      fail()
    } else {
      // 其他错误
      console.error('未知错误:', error)
      fail()
    }
  }
})

Suspense 的错误边界

javascript 复制代码
const SuspenseWithErrorBoundary = defineComponent({
  setup(props, { slots }) {
    const error = ref(null)
    
    const handleError = (err) => {
      error.value = err
    }
    
    provide('suspenseErrorHandler', handleError)
    
    return () => {
      if (error.value) {
        return h('div', { class: 'error-boundary' }, [
          h('h3', '出错了'),
          h('p', error.value.message),
          h('button', {
            onClick: () => {
              error.value = null
              // 触发重新加载
            }
          }, '重试')
        ])
      }
      
      return h(Suspense, null, {
        default: slots.default,
        fallback: slots.fallback
      })
    }
  }
})

Loading 状态的实现

优雅的 Loading 组件

html 复制代码
<template>
  <div class="loading-container">
    <div class="loading-spinner" :style="{ width: size + 'px', height: size + 'px' }">
      <div class="spinner"></div>
    </div>
    <p v-if="text" class="loading-text">{{ text }}</p>
    <p v-if="progress !== undefined" class="loading-progress">
      {{ Math.round(progress * 100) }}%
    </p>
  </div>
</template>

<script setup>
defineProps({
  size: {
    type: Number,
    default: 40
  },
  text: {
    type: String,
    default: ''
  },
  progress: {
    type: Number,
    default: undefined
  }
})
</script>

<style scoped>
.loading-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

.loading-spinner {
  position: relative;
  display: inline-block;
}

.spinner {
  width: 100%;
  height: 100%;
  border: 3px solid #f3f3f3;
  border-top: 3px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.loading-text {
  margin-top: 10px;
  color: #666;
  font-size: 14px;
}

.loading-progress {
  margin-top: 8px;
  color: #3498db;
  font-size: 16px;
  font-weight: bold;
}
</style>

进度跟踪的实现

javascript 复制代码
// 可跟踪进度的异步组件加载器
function createProgressAsyncComponent(options) {
  const { loader, onProgress } = options
  
  return defineAsyncComponent({
    loader: () => {
      return new Promise((resolve, reject) => {
        // 模拟进度更新
        let progress = 0
        const timer = setInterval(() => {
          progress += 0.1
          if (progress <= 0.9) {
            onProgress?.(progress)
          }
        }, 100)
        
        loader()
          .then(comp => {
            clearInterval(timer)
            onProgress?.(1)
            resolve(comp)
          })
          .catch(err => {
            clearInterval(timer)
            reject(err)
          })
      })
    },
    
    loadingComponent: defineComponent({
      setup(_, { attrs }) {
        return () => h(LoadingComponent, {
          progress: attrs.progress
        })
      }
    })
  })
}

性能优化与最佳实践

预加载策略

javascript 复制代码
// 预加载组件
const preloadComponent = (componentFactory) => {
  const comp = componentFactory()
  // 触发加载但不等待
  return comp
}

// 路由级别预加载
const router = createRouter({
  routes: [
    {
      path: '/dashboard',
      component: () => import('./views/Dashboard.vue'),
      // 预加载相关组件
      meta: {
        preload: () => {
          import('./components/Chart.vue')
          import('./components/Table.vue')
        }
      }
    }
  ]
})

// 在路由守卫中执行预加载
router.beforeEach((to, from, next) => {
  if (to.meta.preload) {
    to.meta.preload()
  }
  next()
})

Suspense 的最佳实践

html 复制代码
<template>
  <div class="app">
    <!-- 为关键路径添加 Suspense -->
    <Suspense :timeout="3000" @resolve="handleResolve" @fallback="handleFallback">
      <template #default>
        <RouterView v-slot="{ Component }">
          <Suspense>
            <component :is="Component" />
            <template #fallback>
              <PageLoading />
            </template>
          </Suspense>
        </RouterView>
      </template>
      
      <template #fallback>
        <AppLoading />
      </template>
    </Suspense>
  </div>
</template>

<script setup>
const handleResolve = () => {
  console.log('路由解析完成')
}

const handleFallback = () => {
  console.log('显示加载状态')
}
</script>

缓存策略

javascript 复制代码
// 实现组件缓存
const componentCache = new Map()

function createCachedAsyncComponent(loader, key) {
  if (componentCache.has(key)) {
    return componentCache.get(key)
  }
  
  const asyncComp = defineAsyncComponent({
    loader,
    loadingComponent: LoadingComponent,
    delay: 200
  })
  
  componentCache.set(key, asyncComp)
  return asyncComp
}

// 在路由中使用
{
  path: '/user/:id',
  component: (route) => {
    const userId = route.params.id
    return createCachedAsyncComponent(
      () => import('./views/UserProfile.vue'),
      `user-profile-${userId}`
    )
  }
}

结语

Suspense 和异步组件加载机制是 Vue3 中非常重要的特性,它们不仅解决了异步组件加载的显示问题,更重要的是提供了一个统一的异步依赖处理模型。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
青青家的小灰灰2 小时前
告别 Prop Drilling:Context API 的陷阱、Reducer 模式与原子化状态库原理
前端·javascript·react.js
进击的尘埃2 小时前
CSS 变量 + 主题切换:从 CSS-in-JS 回归原生方案的实践之路
javascript
进击的尘埃2 小时前
组合式函数的设计模式:如何写出真正可复用的 Vue3 Composables
javascript
Moment2 小时前
想要长期陪伴你的助理?先从部署一个 OpenClaw 开始 😍😍😍
前端·后端·github
前端Hardy2 小时前
别再用 $emit 满天飞了!Vue 3 组件通信的 4 种正确姿势,第 3 种 90% 的人不知道
前端·vue.js·面试
古时的风筝2 小时前
花10 分钟时间,把终端改造成“生产力武器”:Ghostty + Yazi + Lazygit 配置全流程
前端·后端·程序员
Cache技术分享2 小时前
340. Java Stream API - 理解并行流的额外开销
前端·后端
我叫黑大帅2 小时前
前端如何利用 GitHub Actions 自动构建并发布到 GitHub Pages?
前端·面试·github
smallLabel2 小时前
记一次 OpenClaw 飞书插件接入填坑指南: Error: spawn EINVAL
前端