技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

Vue 源码(八):异步组件原理

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/46-Vue源码(八)异步组件原理
本文目录19 个章节
  1. 一、什么是异步组件
  2. 二、Vue 2 的三类历史写法
  3. 三、Vue 2.7.16 中的源码位置
  4. 四、组件注册:Vue.component 做了什么
  5. 五、createComponent 如何识别异步组件
  6. 六、resolveAsyncComponent 的整体流程
  7. 七、异步加载完成:resolve、reject、once
  8. 八、普通 callback factory 的完整流程
  9. 九、Promise 异步组件
  10. 十、高级异步组件的 loading 和 timeout
  11. 十一、Vue 2.7 的 defineAsyncComponent
  12. 十二、Vue 3.5 的 defineAsyncComponent
  13. 十三、Vue 2.7 与 Vue 3.5 对照
  14. 十四、异步组件与路由懒加载
  15. 十五、v-if、v-show 和异步组件
  16. 十六、源码流程总结
  17. 十七、总结
  18. 官方参考
  19. 原文出处

Vue 源码(八):异步组件原理

本文原文分析的是 Vue 2 异步组件的实现,主线是 resolveAsyncComponent、异步占位 VNode、owners$forceUpdate。这部分仍然适合学习 Vue 2 源码,但原文抓取后的代码有大量缺失换行和语法损坏,且没有覆盖 Vue 3 的异步组件。

本文保留 Vue 2.7.16 的源码思路,并补充 Vue 3.5.39 的 defineAsyncComponent 对照。两代实现不能混写。

一、什么是异步组件

普通组件在应用加载或当前模块加载时就会进入 JavaScript bundle;异步组件则把组件代码拆成独立 chunk,在组件真正需要渲染时再加载:

应用启动
  ↓
加载首屏需要的代码
  ↓ 用户进入某个功能
动态 import 下载组件 chunk
  ↓
创建并渲染组件

异步组件适合:

  • 页面中暂时不会显示的大型组件;
  • 弹窗、抽屉、编辑器等低频功能;
  • 路由级代码分割;
  • 将大型应用拆成多个可缓存的 JavaScript chunk。

异步组件的“异步”主要指组件代码或组件定义的加载过程,不等于组件内部接口请求完成。组件加载完成后,仍然可能需要等待自己的数据请求。

二、Vue 2 的三类历史写法

Vue 2 文档通常将异步组件 factory 分为三类:

  1. 通过 resolve/reject 回调加载的 factory;
  2. 返回 Promise 的 factory;
  3. 返回 component/loading/error/delay/timeout 配置对象的高级 factory。

这些写法在 Vue 2.7.16 中仍有历史兼容价值。

1. 普通 factory callback

Vue.component('hello-world', function (resolve, reject) {
  require(['./components/HelloWorld.vue'], resolve)
})

这里的 require([...], resolve) 通常是 webpack 等构建工具提供的异步模块语法,不是浏览器原生 require。模块加载成功后调用 resolve,失败时调用 reject

2. Promise factory

Vue.component(
  'hello-world',
  () => import('./components/HelloWorld.vue'),
)

构建工具会把 import() 拆成代码分块。组件真正渲染时,factory 返回 Promise,Vue 负责把加载结果转换成组件构造函数。

3. 高级 factory

const LoadingComp = {
  template: '<div>loading...</div>',
}

const ErrorComp = {
  template: '<div>加载失败</div>',
}

const AsyncComp = () => ({
  component: import('./components/HelloWorld.vue'),
  loading: LoadingComp,
  error: ErrorComp,
  delay: 200,
  timeout: 3000,
})

Vue.component('hello-world', AsyncComp)

Vue 2 的字段名是:

  • component:真正的组件 Promise;
  • loading:加载等待时间结束后显示的组件;
  • error:加载失败后显示的组件;
  • delay:显示 loading 前的等待时间,默认 200ms;
  • timeout:最长等待时间,不设置时不主动超时。

不要把 Vue 2 的 loadingerror 直接改名成 Vue 3 的 loadingComponenterrorComponent 后还声称是同一套 API。

三、Vue 2.7.16 中的源码位置

Vue 2.7.16 已经将相关源码迁移为 TypeScript,不能继续把它们标成旧的 .js 路径:

本文代码块有些是为了讲解流程而整理的伪代码,不是 Vue 2.7.16 的完整源码拷贝。

四、组件注册:Vue.component 做了什么

Vue 2 初始化全局 API 时,会注册 Vue.componentVue.directiveVue.filter 等资源 API。简化后的组件注册逻辑如下:

export const ASSET_TYPES = [
  'component',
  'directive',
  'filter',
]

function initAssetRegisters(Vue) {
  ASSET_TYPES.forEach(type => {
    Vue[type] = function registerAsset(id, definition) {
      if (!definition) {
        return this.options[type + 's'][id]
      }

      if (type === 'component' && isPlainObject(definition)) {
        definition.name = definition.name || id
        definition = this.options._base.extend(definition)
      }

      this.options[type + 's'][id] = definition
      return definition
    }
  })
}

注册普通对象组件时,Vue 2 会通过 Vue.extend 创建组件构造函数;注册异步 factory 时,传入的是函数,Vue 不会立刻把它扩展成普通组件构造函数,而是先保存这个 factory,等渲染阶段解析。

五、createComponent 如何识别异步组件

渲染组件 VNode 时,Vue 2 的 createComponent 会区分普通组件构造函数、组件配置对象和异步 factory。流程可以简化为:

const baseCtor = context.$options._base

if (isObject(Ctor)) {
  Ctor = baseCtor.extend(Ctor)
}

let asyncFactory

if (isUndef(Ctor.cid)) {
  // 没有 cid 的函数可能是异步 factory
  asyncFactory = Ctor
  Ctor = resolveAsyncComponent(asyncFactory, baseCtor)

  if (Ctor === undefined) {
    return createAsyncPlaceholder(
      asyncFactory,
      data,
      context,
      children,
      tag,
    )
  }
}

普通 Vue 组件构造函数通常有 cid。如果传入的函数没有 cid,Vue 2 会把它当成可能的异步 factory,交给 resolveAsyncComponent

createAsyncPlaceholder

异步组件还没有加载完成时,Vue 2 不会凭空丢掉这个位置,而是创建一个注释 VNode 作为占位符:

function createAsyncPlaceholder(
  factory,
  data,
  context,
  children,
  tag,
) {
  const node = createEmptyVNode()

  node.asyncFactory = factory
  node.asyncMeta = {
    data,
    context,
    children,
    tag,
  }

  return node
}

这个占位符的作用是:

  • 保留异步组件应该出现的位置;
  • 记录 factory 和创建时的上下文;
  • 让后续异步解析完成后可以重新进入 patch 流程;
  • 在未配置 loading 组件时,页面通常只看到一个注释节点,而不是一个真正的 loading DOM。

注释 VNode 不是异步组件本身,也不是组件实例;它只是等待期间的占位信息。

六、resolveAsyncComponent 的整体流程

Vue 2.7.16 的 resolveAsyncComponent 负责:

  1. 读取已经解析成功、失败或 loading 的缓存状态;
  2. 收集正在使用该 factory 的 owner;
  3. 调用 factory;
  4. 处理 Promise、回调和高级配置对象;
  5. 在加载完成后让 owner 重新渲染。

可以先用状态流程理解:

factory.resolved 存在? ── 是 ──> 返回已解析组件
        │ 否
factory.error + errorComp? ── 是 ──> 返回错误组件
        │ 否
factory.loading + loadingComp? ── 是 ──> 返回 loading 组件
        │ 否
记录当前 owner
        ↓
调用 factory(resolve, reject)
        ↓
未完成:返回 undefined,创建注释占位 VNode
        ↓
完成:缓存 resolved/error,forceRender owners

1. 缓存状态优先级

简化后的入口判断:

if (factory.error && factory.errorComp) {
  return factory.errorComp
}

if (factory.resolved) {
  return factory.resolved
}

if (factory.loading && factory.loadingComp) {
  return factory.loadingComp
}

这些状态挂在 factory 函数上,因此同一个异步 factory 可以复用加载结果。这里缓存的是解析后的组件构造函数,不是每个页面使用的组件实例;实例是否保留还要由 KeepAlive 决定。

2. ownerowners

owner 是当前正在渲染、使用这个异步组件的 Vue 实例。第一次解析时,Vue 会创建 factory.owners

const owner = currentRenderingInstance

if (owner && factory.owners) {
  if (factory.owners.indexOf(owner) === -1) {
    factory.owners.push(owner)
  }
}

if (owner && !factory.owners) {
  const owners = factory.owners = [owner]
  // 开始真正调用 factory
}

为什么需要数组?因为同一个异步组件可能同时被多个父组件使用:

ParentA ─┐
         ├─ 同一个 async factory
ParentB ─┘

加载完成后,所有正在等待的 owner 都需要重新渲染。

Vue 2 还会监听 owner 的 hook:destroyed,在 owner 销毁时从数组中删除它:

owner.$on('hook:destroyed', () => {
  remove(owners, owner)
})

这也是 Vue 2 hook:* 实例事件机制在内部的一处使用。

3. forceRender

const forceRender = renderCompleted => {
  for (let i = 0; i < owners.length; i++) {
    owners[i].$forceUpdate()
  }

  if (renderCompleted) {
    owners.length = 0
    clearLoadingTimer()
    clearTimeoutTimer()
  }
}

forceRender 的准确含义是:

  • 遍历等待当前异步 factory 的 owner;
  • 对这些 owner 调用 $forceUpdate()
  • owner 重新执行 render;
  • 新一轮 createComponent 发现 factory.resolved 后,创建真正的组件 VNode;
  • 完成后清除 owner 和定时器。

它不是“强制更新所有子孙组件”,也不是对全局应用执行刷新。

七、异步加载完成:resolverejectonce

1. once

Vue 2 使用 once 保证同一个回调不会重复处理同类结果:

function once(fn) {
  let called = false

  return function onceWrapper(...args) {
    if (!called) {
      called = true
      return fn.apply(this, args)
    }
  }
}

2. ensureCtor

webpack 的动态导入可能返回 ESM module 对象,而 Vue 2 需要组件对象或构造函数。ensureCtor 会解包 default

function ensureCtor(comp, base) {
  if (
    comp.__esModule ||
    (hasSymbol && comp[Symbol.toStringTag] === 'Module')
  ) {
    comp = comp.default
  }

  return isObject(comp)
    ? base.extend(comp)
    : comp
}

3. resolve

const resolve = once(res => {
  factory.resolved = ensureCtor(res, baseCtor)

  if (!sync) {
    forceRender(true)
  } else {
    owners.length = 0
  }
})

如果 factory 在当前同步调用阶段就返回结果,sync 仍为 true,Vue 可以直接使用结果;如果 Promise 在之后完成,sync 已经变为 false,就需要通过 forceRender(true) 让 owner 重新渲染。

4. reject

const reject = once(reason => {
  if (process.env.NODE_ENV !== 'production') {
    warn(`Failed to resolve async component: ${String(factory)}`)
    if (reason) warn(`Reason: ${reason}`)
  }

  if (factory.errorComp) {
    factory.error = true
    forceRender(true)
  }
})

只有配置了 error component 时,Vue 2 才能在页面中显示错误组件;否则主要是开发环境警告和占位状态。

超时后的一个常见误区: resolvereject 是分别用 once 包装的函数。调用 reject 并不等于从 JavaScript 层面取消了底层 Promise,之后网络请求仍可能完成并调用 resolve。如果 error 状态已经被设置,后续解析时可能仍优先显示 error component。因此不能简单写成“超时后绝对不会执行 resolve”;真正的请求取消需要配合 AbortController 或请求库的取消机制。

八、普通 callback factory 的完整流程

以这段代码为例:

Vue.component('hello-world', function (resolve, reject) {
  require(['./components/HelloWorld.vue'], resolve)
})

第一次渲染

createComponent
  ↓
发现 Ctor 没有 cid
  ↓
resolveAsyncComponent
  ↓
记录 owner
  ↓
调用 factory(resolve, reject)
  ↓
require 尚未完成,factory 返回 undefined
  ↓
resolveAsyncComponent 返回 undefined
  ↓
createAsyncPlaceholder
  ↓
页面保留注释占位 VNode

加载成功

require 完成
  ↓
调用 resolve(module)
  ↓
ensureCtor 解包 module.default
  ↓
factory.resolved = 组件构造函数
  ↓
forceRender(true)
  ↓
owner.$forceUpdate()
  ↓
重新 render
  ↓
返回 factory.resolved
  ↓
patch 真正的组件 VNode

多个 owner 同时使用

如果 ParentA 和 ParentB 同时使用同一个 factory:

第一次解析:owners = [ParentA]
第二次解析:owners = [ParentA, ParentB]
加载完成:分别调用 ParentA.$forceUpdate() 和 ParentB.$forceUpdate()

如果其中一个父组件在加载完成前销毁,hook:destroyed 监听器会把它从 owners 中移除,避免对已经销毁的实例继续更新。

九、Promise 异步组件

Vue.component(
  'hello-world',
  () => import('./components/HelloWorld.vue'),
)

这类 factory 被调用后返回一个 Promise:

const res = factory(resolve, reject)

if (isPromise(res)) {
  if (isUndef(factory.resolved)) {
    res.then(resolve, reject)
  }
}

后续过程与 callback factory 类似:

  1. 第一次渲染时 Promise 尚未完成;
  2. resolveAsyncComponent 返回 undefined
  3. createComponent 创建注释占位 VNode;
  4. Promise 成功后调用 resolve
  5. 解析结果缓存到 factory.resolved
  6. owner 重新渲染并创建真正组件。

Vue 2 的异步组件 factory 会复用同一个 pending 解析过程,避免同一 factory 被重复启动多个加载任务。

十、高级异步组件的 loading 和 timeout

当 factory 返回下面的对象时:

const AsyncComp = () => ({
  component: import('./HelloWorld.vue'),
  loading: LoadingComp,
  error: ErrorComp,
  delay: 200,
  timeout: 3000,
})

Vue 2 会额外处理 res.component、loading 和 timeout。

1. component Promise

if (isPromise(res.component)) {
  res.component.then(resolve, reject)
}

2. loading delay

if (isDef(res.loading)) {
  factory.loadingComp = ensureCtor(res.loading, baseCtor)

  if (res.delay === 0) {
    factory.loading = true
  } else {
    timerLoading = setTimeout(() => {
      timerLoading = null

      if (
        isUndef(factory.resolved) &&
        isUndef(factory.error)
      ) {
        factory.loading = true
        forceRender(false)
      }
    }, res.delay || 200)
  }
}

delay 的意义是避免短暂的网络请求让 loading 组件闪烁。delay: 0 表示立即进入 loading 状态。

3. timeout

if (isDef(res.timeout)) {
  timerTimeout = setTimeout(() => {
    timerTimeout = null

    if (isUndef(factory.resolved)) {
      reject(`timeout (${res.timeout}ms)`)
    }
  }, res.timeout)
}

如果配置了 error component,reject 会将 factory.error 设为 true,并让 owner 重新渲染 error component;如果没有 error component,开发环境会输出警告,页面不一定得到可见的错误 UI。

4. loading、error、resolved 的优先级

正在加载且 delay 已到:显示 loading
加载失败且有 error component:显示 error
加载成功:显示 resolved component

实际判断由 resolveAsyncComponent 的状态字段决定。定时器在完成或失败后会被清理。

十一、Vue 2.7 的 defineAsyncComponent

Vue 2.7.4 起增加了兼容 API defineAsyncComponent,因此不能再说“Vue 2.7 完全没有这个 API”:

import { defineAsyncComponent } from 'vue'

const AsyncPanel = defineAsyncComponent(() => import('./Panel.vue'))

但 Vue 2.7 的实现是兼容桥,底层仍然使用 Vue 2 factory、placeholder、owners 和 resolveAsyncComponent

  • 函数形式会包装成带 loader 的配置;
  • 最终返回旧式 Vue 2 async factory;
  • loadingerrordelaytimeout 仍会交给 Vue 2 解析流程;
  • suspensible 不是真正的 Vue 3 Suspense 能力,Vue 2 没有 Vue 3 的 Suspense runtime;
  • 不能把它描述成 Vue 3 的 AsyncComponentWrapper

十二、Vue 3.5 的 defineAsyncComponent

Vue 3 使用新的异步组件 wrapper:

import { defineAsyncComponent } from 'vue'

const AsyncPanel = defineAsyncComponent(() => import('./Panel.vue'))

完整配置:

const AsyncPanel = defineAsyncComponent({
  loader: () => import('./Panel.vue'),
  loadingComponent: LoadingPanel,
  errorComponent: ErrorPanel,
  delay: 200,
  timeout: 3000,
  suspensible: true,
  onError(error, retry, fail, attempts) {
    if (attempts <= 3) {
      retry()
    } else {
      fail()
    }
  },
})

Vue 3.5 的实现重点:

  • defineAsyncComponent 返回一个 wrapper component;
  • loader 结果会缓存到 resolvedComp
  • 同一 wrapper 的并发加载会复用 pending Promise;
  • loader reject 后可以通过 onError 控制重试;
  • 默认 delay 为 200ms,timeout 默认 Infinity
  • 有父级 Suspense 且 suspensible 为 true 时,wrapper 会把 loader 注册为 Suspense 依赖,Suspense 的 fallback/pending 控制未完成状态;wrapper 自己的 loading、delay 和 timeout 分支不会按普通方式驱动 UI,loader 错误仍通过 onErrorerrorCaptured 处理;
  • Vue 3.5 还支持与服务端懒 hydration 相关的 hydrate 选项。

Vue 3 不再创建 Vue 2 那种 createAsyncPlaceholder 注释 VNode,也不使用 factory.owners + owner.$forceUpdate() 作为主要实现。

十三、Vue 2.7 与 Vue 3.5 对照

维度Vue 2.7.16Vue 3.5.39
基础 APIcallback factory、Promise factorydefineAsyncComponent
高级配置component/loading/error/delay/timeoutloader/loadingComponent/errorComponent/delay/timeout
未完成状态comment VNode + asyncFactory/asyncMetawrapper、renderer 和可选 Suspense fallback 处理
等待者factory 上的 ownerspending Promise 和响应式 wrapper 状态
完成更新owner $forceUpdate()wrapper 状态更新并重新渲染
加载结果factory.resolvedresolvedComp
Suspense不支持 Vue 3 Suspense内置但仍标为 Experimental
KeepAliveVue 2 patch/缓存机制Vue 3 KeepAlive 缓存和 LRU
3.5 懒 hydration支持 hydrate 配置

十四、异步组件与路由懒加载

Vue Router 4 中,路由级懒加载应直接写返回 Promise 的 route component:

const routes = [
  {
    path: '/reports',
    component: () => import('./views/Reports.vue'),
  },
]

不要再额外包一层:

// 不推荐
{
  path: '/reports',
  component: defineAsyncComponent(() => import('./views/Reports.vue')),
}

Router 官方把路由懒加载和普通 async component 视为不同功能:Router 在首次进入路由时加载并缓存路由组件;组件 wrapper 则负责组件自身的 loading/error/retry。路由懒加载本身也不会因为放在 Suspense 旁边就自动触发 Suspense fallback。

参考:Vue Router Lazy Loading Routes

十五、v-ifv-show 和异步组件

原文最后使用 v-if 配合异步组件:

<template>
  <button @click="showPanel = true">显示组件</button>
  <AsyncPanel v-if="showPanel" />
</template>

v-if 为 false 时,异步组件不会进入渲染流程,通常也不会触发 loader;变为 true 后才真正渲染 wrapper。v-show 则会创建组件,只是改变 CSS 显示状态:

<!-- 组件会被创建,不能用于完全延迟加载 -->
<AsyncPanel v-show="showPanel" />

选择建议:

  • 首次很少显示、初始化成本高:使用 v-if
  • 高频显示/隐藏且需要保留 DOM:考虑 v-show
  • 需要保留组件实例状态:使用 KeepAlive
  • 需要延迟下载代码:使用 import() 和异步组件/路由懒加载。

如果父组件需要调用异步子组件方法,不要使用固定的 setTimeout(..., 400) 猜测加载时间。应使用 nextTick 处理同步 DOM 更新,并让子组件发出 ready 事件或等待明确的加载 Promise。

十六、源码流程总结

Vue 2.7.16 的异步组件可以浓缩为:

注册 async factory
      ↓
createComponent 发现没有 cid
      ↓
resolveAsyncComponent
      ↓
记录使用该 factory 的 owners
      ↓
调用 factory(resolve, reject)
      ↓
未完成:创建 async placeholder comment VNode
      ↓
加载完成:ensureCtor + factory.resolved
      ↓
forceRender 所有 owners
      ↓
重新 render 并创建真实组件

Vue 3.5.39 则是:

defineAsyncComponent 创建 wrapper
      ↓
wrapper 首次渲染时调用 loader
      ↓
pending Promise 复用请求
      ↓
响应式 loaded/error/delayed 状态更新
      ↓
Suspense 或 wrapper 决定 fallback/loading/error
      ↓
渲染 resolved component

十七、总结

  • Vue 2 异步组件有 callback factory、Promise factory 和高级配置对象三种历史写法;
  • Vue 2.7.16 通过 resolveAsyncComponent 解析 factory,并使用注释 VNode、owners$forceUpdate 完成异步更新;
  • ensureCtor 负责处理 ESM default 和组件 options 对象;
  • Vue 2 的 loading/error、Vue 3 的 loadingComponent/errorComponent 是不同版本命名;
  • Vue 2.7.4 起存在兼容版 defineAsyncComponent,但底层仍是 Vue 2 factory,不是 Vue 3 wrapper;
  • Vue 3.5 使用 defineAsyncComponent、Suspense 和响应式异步状态,不再使用 Vue 2 的 placeholder/owners 主流程;
  • v-if 可以延迟组件进入渲染流程,v-show 不能替代代码分割;
  • 路由级懒加载使用 component: () => import(...),不要把 route component 包成 defineAsyncComponent
  • 异步组件的代码加载、接口请求、KeepAlive 实例缓存和路由 chunk 是不同概念,排查问题时要分别定位。

官方参考

Vue 2

Vue 3

原文出处

作者:zygg不含糖

来源:稀土掘金

本文保留原文 Vue 2 异步组件源码分析,并补充 Vue 2.7.16 和 Vue 3.5.39 的实现差异。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS