Vue 之动态组件和异步组件
动态组件解决“当前要显示哪个组件”的问题;异步组件解决“组件代码什么时候下载和解析”的问题。两者可以组合,但不是同一个概念。
本文保留原文的动态组件、
keep-alive、异步加载和懒加载示例,并按 Vue 2.7.16、Vue 3.5.39 的 API 重新整理。
一、动态组件是什么
动态组件是根据状态切换组件,例如在“文章列表”和“文章归档”之间切换:
<script setup lang="ts">
import { ref } from 'vue'
import Posts from './Posts.vue'
import Archive from './Archive.vue'
const current = ref<'posts' | 'archive'>('posts')
const views = {
posts: Posts,
archive: Archive,
}
</script>
<template>
<button @click="current = 'posts'">文章</button>
<button @click="current = 'archive'">归档</button>
<component :is="views[current]" />
</template>
<component> 是 Vue 的动态组件占位符,:is 可以接收:
- 已注册组件的字符串名称,例如
:is="currentComponentName"; - 导入的组件对象,例如上例的
views[current]; - 原生元素名称,例如
:is="'div'",但实际项目中要谨慎使用。
Vue 2 的对应写法是:
<component :is="currentTabComponent"></component>
参考:Vue 2 动态组件、Vue 3 动态组件。
Vue 2 的完整切换示例
原文使用了运行时模板和全局注册组件的方式,思路可以保留。下面是格式化后的 Vue 2 示例:
Vue.component('input-item', {
template: '<input />',
})
Vue.component('common-item', {
template: '<div>hello world</div>',
})
new Vue({
el: '#app',
data() {
return {
currentItem: 'input-item',
}
},
methods: {
handleClick() {
this.currentItem = this.currentItem === 'input-item'
? 'common-item'
: 'input-item'
},
},
template: `
<div>
<component :is="currentItem"></component>
<button @click="handleClick">切换</button>
</div>
`,
})
template 字符串需要 Vue 的 runtime compiler。使用 Vue CLI、Vite 或 Nuxt 的 SFC 项目通常不写这种运行时模板,而是直接创建 .vue 文件。
二、动态组件切换时会发生什么
默认情况下,动态组件切换会卸载旧组件并创建新组件:
当前是 Posts
↓ current 改为 Archive
卸载 Posts,创建 Archive
因此,如果 Posts 中有输入框、分页位置或临时状态,切换回来时可能重新初始化。这里要区分两件事:
- 动态组件决定显示哪一个组件;
- KeepAlive决定不显示的组件实例是否进入缓存而不是被卸载。
原文中“切换组件时值会被清掉,所以需要使用 keep-alive”的意图是正确的,但原句容易读成“keep-alive 会清掉值”。实际正好相反:<KeepAlive> 会缓存符合条件的组件实例。
三、使用 KeepAlive 保留组件状态
<template>
<KeepAlive>
<component :is="views[current]" />
</KeepAlive>
</template>
切换到其他组件时,当前组件会被停用并放入缓存;再次切换回来时,原实例会重新激活。输入框内容、组件本地状态和已创建的子组件实例可以保留。
KeepAlive 生命周期
import {
onActivated,
onDeactivated,
} from 'vue'
onActivated(() => {
// 组件首次挂载后,以及之后每次从缓存中恢复时执行
})
onDeactivated(() => {
// 组件被放入 KeepAlive 缓存时执行
})
deactivated 不等于 unmounted。只有缓存项被 max 淘汰、父组件卸载或 KeepAlive 本身卸载时,组件才会真正卸载。Vue 2 中对应的是 activated / deactivated 选项钩子。
include、exclude 和 max
<KeepAlive
:include="['Posts', 'Archive']"
:exclude="/^Settings/"
:max="10"
>
<component :is="views[current]" />
</KeepAlive>
include:只有名称匹配的组件会被缓存;exclude:名称匹配的组件不会被缓存;max:最多缓存多少个实例,超过后会按 LRU(最近最少使用)策略淘汰;include/exclude支持逗号分隔字符串、正则表达式或数组。
名称匹配依赖组件的 name。在 Vue 3.2.34 及之后,<script setup> SFC 通常会根据文件名推断组件名,但需要过滤时仍建议使用稳定、清晰的组件名称。Vue 2 项目则应显式设置 name。
<script setup lang="ts">
defineOptions({ name: 'Posts' })
</script>
KeepAlive 只缓存组件实例,不会阻止异步组件的 loader 下载;异步加载和实例缓存是两个阶段。
参考:Vue 3 KeepAlive、Vue 2 keep-alive API。
四、异步组件是什么
异步组件把组件的加载推迟到真正需要渲染时:
应用启动
↓
只加载首屏需要的代码
↓ 用户进入某功能
下载该功能的代码分块
↓
创建并渲染组件
它适合:
- 页面下方或弹窗中暂时不会展示的大型组件;
- 管理后台中不常访问的功能模块;
- 路由级代码分割;
- 需要把大型 JavaScript 拆成多个 chunk 的应用。
异步组件不是“4 秒后用 setTimeout 显示文本”。setTimeout 只是原文为了模拟网络延迟而写的演示。真实项目通常通过 import() 让构建工具完成代码分割。
五、Vue 3.5 的 defineAsyncComponent
1. 最简单的写法
import { defineAsyncComponent } from 'vue'
const AsyncPanel = defineAsyncComponent(() => import('./Panel.vue'))
然后像普通组件一样使用:
<script setup lang="ts">
import AsyncPanel from './AsyncPanel'
</script>
<template>
<AsyncPanel />
</template>
也可以在组件注册表中切换:
<script setup lang="ts">
import { ref } from 'vue'
import { defineAsyncComponent } from 'vue'
import LocalPanel from './LocalPanel.vue'
const current = ref('local')
const panels = {
local: LocalPanel,
remote: defineAsyncComponent(() => import('./RemotePanel.vue')),
}
</script>
<template>
<component :is="panels[current]" />
</template>
loader 返回的 Promise 会在异步组件真正渲染时执行。组件加载结果会被 wrapper 缓存,但这不等于 KeepAlive 的组件实例缓存。
2. loading、error、delay 和 timeout
import { defineAsyncComponent } from 'vue'
import LoadingPanel from './LoadingPanel.vue'
import ErrorPanel from './ErrorPanel.vue'
const AsyncPanel = defineAsyncComponent({
loader: () => import('./Panel.vue'),
loadingComponent: LoadingPanel,
errorComponent: ErrorPanel,
delay: 200,
timeout: 3000,
})
Vue 3 的选项含义:
loadingComponent:加载中显示的组件;errorComponent:加载失败后显示的组件;delay:显示 loading 前等待多久,默认 200ms,避免快速请求产生闪烁;设置为0可立即显示;timeout:超过指定毫秒数后视为失败,默认Infinity,也就是不主动超时;suspensible:是否让父级Suspense接管异步状态,默认true;onError:自定义重试和失败处理。
errorComponent 可以接收名为 error 的 prop:
<script setup lang="ts">
defineProps<{
error: unknown
}>()
</script>
<template>
<div role="alert">组件加载失败:{{ error }}</div>
</template>
3. 失败重试
const AsyncPanel = defineAsyncComponent({
loader: () => import('./Panel.vue'),
onError(error, retry, fail, attempts) {
// 网络暂时失败时最多重试三次
if (attempts <= 3) {
retry()
} else {
fail()
}
},
})
onError 中必须明确调用 retry() 或 fail()。不要无条件重试,否则网络断开时可能形成无限请求。
4. Suspense 与异步组件
<script setup lang="ts">
import AsyncPanel from './AsyncPanel'
</script>
<template>
<Suspense>
<template #default>
<AsyncPanel />
</template>
<template #fallback>
<div class="loading">Loading...</div>
</template>
</Suspense>
</template>
Suspense 可以等待异步组件和带有顶层 await 的异步 setup()。当异步组件处于默认的 suspensible: true 状态并位于 Suspense 内部时,wrapper 会把 loader 注册为 Suspense 依赖,未完成期间由 Suspense 的 fallback/pending 状态控制展示;异步 wrapper 自己的 loading、delay 和 timeout 分支不会按普通方式驱动 UI。loader reject 仍应通过 onError、errorCaptured/onErrorCaptured 处理,Suspense 本身没有 error slot;不要把 fallback 当成错误组件。
如果希望由异步组件 wrapper 自己管理 loading/delay/timeout 状态,可以设置:
const AsyncPanel = defineAsyncComponent({
loader: () => import('./Panel.vue'),
loadingComponent: LoadingPanel,
errorComponent: ErrorPanel,
suspensible: false,
})
Vue 3.5.39 的 <Suspense> 仍标注为 Experimental。它提供 pending、resolve、fallback 等事件,但没有一个可以替代 errorCaptured 的 error slot。异步错误可以通过 onError、父组件的 onErrorCaptured 或应用级 app.config.errorHandler 处理。
import { onErrorCaptured } from 'vue'
onErrorCaptured(error => {
console.error('异步组件错误', error)
return false
})
不要使用原文中的 lazy():lazy 不是 Vue 的通用内置函数;代码分割应使用 import(),组件加载状态应使用 defineAsyncComponent、Suspense 或业务组件。
参考:Vue 3 Async Components、Vue 3 Suspense。
六、Vue 2.7 的异步组件写法
Vue 2 的异步组件主要有三类历史写法:factory callback、返回 Promise 的 factory,以及带 loading/error 配置的高级 factory。
1. factory callback
Vue.component('async-panel', function (resolve, reject) {
require(['./components/Panel.vue'], resolve)
})
这种 require 是 webpack 等构建工具提供的异步模块语法,不是浏览器原生 API。它的核心是:组件加载成功后调用 resolve,失败时调用 reject。
2. Promise factory
Vue.component('async-panel', () => import('./components/Panel.vue'))
这也是 Vue 2 中常见的代码分割写法。Vue 2.7 仍支持,但新 Vue 3 项目应优先写成 defineAsyncComponent,因为 Vue 3 的异步组件状态和 Suspense 集成方式不同。
3. loading/error 高级 factory
const LoadingPanel = {
template: '<div>loading...</div>',
}
const ErrorPanel = {
template: '<div>加载失败</div>',
}
const AsyncPanel = () => ({
component: import('./components/Panel.vue'),
loading: LoadingPanel,
error: ErrorPanel,
delay: 200,
timeout: 3000,
})
Vue.component('async-panel', AsyncPanel)
Vue 2 的字段名是 component、loading、error、delay 和 timeout;Vue 3 对应的对象选项是 loader、loadingComponent 和 errorComponent,不能只改名字而不说明版本。
Vue 2 文档中这类高级 factory 的 loading 默认延时为 200ms,timeout 不设置时不主动超时。参考:Vue 2 异步组件 Loading 状态。
七、Vue 2.7 与 Vue 3.5 的异步组件版本对照
| 项目 | Vue 2.7.16 | Vue 3.5.39 |
|---|---|---|
| 基础写法 | factory callback 或 () => import() | defineAsyncComponent(() => import()) |
| loading 选项 | loading | loadingComponent |
| error 选项 | error | errorComponent |
| 未解析状态 | 注释 VNode 占位符 | 由异步 wrapper、renderer 或 Suspense 管理 |
| 加载完成更新 | 记录 owners,调用 $forceUpdate() | 响应式状态、pending Promise 和 wrapper 更新 |
| Suspense | 不支持 Vue 3 Suspense | 内置但仍为 Experimental |
| 组件 API | Options API 为主,2.7 提供部分组合式 API | Composition API、defineAsyncComponent |
Vue 2.7.4 起还提供了一个兼容 API defineAsyncComponent,但它底层仍走 Vue 2 的 async factory/placeholder 机制,不能说它返回 Vue 3 的 AsyncComponentWrapper。本文第八节会单独说明 Vue 2 源码。
八、路由级懒加载
路由组件的懒加载与普通异步组件相关,但不建议混用两层 wrapper。Vue Router 4 推荐直接写:
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/reports',
component: () => import('./views/Reports.vue'),
},
],
})
不要写成:
// 不推荐:不要把 defineAsyncComponent 当作 route component wrapper
{
path: '/reports',
component: defineAsyncComponent(() => import('./views/Reports.vue')),
}
Vue Router 官方说明路由懒加载和 async component 是不同功能。Router 会在首次进入路由时加载并缓存组件;路由懒加载本身也不会因为包在 Suspense 中就自动触发 Suspense fallback。路由组件内部的异步组件仍可以单独使用 Suspense。
参考:Vue Router Lazy Loading Routes。
九、v-if、v-show 与异步组件
原文使用 v-if 让异步组件在首次需要显示时才加载:
<template>
<button @click="showPanel = true">显示组件</button>
<AsyncPanel v-if="showPanel" />
</template>
这通常可以延迟异步 wrapper 的实际渲染和 loader 调用。v-show 则会创建并挂载组件,只是切换 CSS display,因此不能用它来表达“组件完全不需要加载”:
<AsyncPanel v-show="showPanel" />
选择建议:
- 组件很少显示、初始化成本高:使用
v-if; - 组件频繁显示/隐藏,且希望保留 DOM:考虑
v-show; - 组件切换需要保留实例状态:使用
KeepAlive; - 组件代码体积较大:使用
import()做异步组件或路由级代码分割。
如果父组件通过 $refs 调用异步子组件的方法,不应写死 setTimeout(..., 400) 等待。应在 v-if 条件改变后使用 nextTick,或者让子组件通过 ready 事件通知;异步组件本身还要考虑 loader 尚未完成的情况。
import { nextTick } from 'vue'
async function showAndFocus() {
showPanel.value = true
await nextTick()
// 此时只代表同步 DOM 更新完成;若 AsyncPanel 仍在加载,还要等待它自己的 ready 事件
}
十、总结
- 动态组件通过
<component :is="...">决定显示哪个组件。 - 默认切换动态组件可能卸载旧实例;
KeepAlive才负责缓存实例状态。 - Vue 3.5 使用
defineAsyncComponent创建组件级异步 wrapper,可配置 loading、error、delay、timeout、suspensible 和 onError。 Suspense可以提供异步依赖的 fallback,但 Vue 3.5.39 仍将其标为 Experimental,并不自动处理所有错误。- Vue 2.7 的
loading/error高级 factory、Vue 3 的loadingComponent/errorComponent不能混写。 - 路由懒加载使用
component: () => import(...),不要额外包defineAsyncComponent。 v-if可以延迟组件渲染,v-show只是隐藏已经渲染的组件;KeepAlive 的缓存、异步组件的代码加载和路由 chunk 是三个不同概念。
官方参考
- Vue 2 动态与异步组件
- Vue 2 KeepAlive API
- Vue 3 动态组件
- Vue 3 异步组件
- Vue 3
defineAsyncComponentAPI - Vue 3 Suspense
- Vue Router 4 路由懒加载
- Vue 3.5.39
apiAsyncComponent.ts - Vue 2.7.16 异步组件源码
原文出处
作者:职场007
来源:稀土掘金
本文保留原文的动态组件、KeepAlive 和异步组件示例,并补充 Vue 2.7.16、Vue 3.5.39 与 Vue Router 4 的版本边界。