Vue 基础体系 · 第 7/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue Slots 与动态组件:内容分发、KeepAlive、Teleport 和异步组件

Vue 组件之间有两条容易混淆的数据通道:

  • Props、Emits 和 v-model:父组件向子组件传值,子组件向父组件发送事件。
  • Slots(插槽):父组件把一段模板内容交给子组件,由子组件决定这段内容渲染在什么位置、什么结构中。

在此基础上,Vue 还提供了几种控制组件生命周期和 DOM 位置的能力:

  • 动态组件:运行时决定渲染哪个组件。
  • KeepAlive:在动态切换组件时缓存组件实例和状态。
  • Teleport:保持组件逻辑关系不变,但把 DOM 渲染到当前组件树之外的位置。
  • 异步组件:延迟加载组件代码,并处理加载中、加载失败和超时状态。

这些能力解决的是不同问题。Slots 解决“内容由谁提供、结构由谁控制”,动态组件解决“当前渲染哪个组件”,KeepAlive 解决“切换后是否保留实例状态”,Teleport 解决“逻辑归属和 DOM 位置不一致”,异步组件解决“组件代码何时加载”。


一、先区分组件的逻辑树、渲染树和 DOM 树

理解后续机制前,需要区分三个概念。

1. 组件树

组件树描述组件实例之间的父子关系。例如:

<App>
  <UserPage>
    <UserCard />
  </UserPage>
</App>

UserCard 的逻辑父组件是 UserPage。Props、Emits、provide/inject 和生命周期关系都基于这棵树。

2. VNode 渲染树

Vue 模板会被编译为渲染函数,渲染函数返回 VNode。VNode 是 Vue 对“应该如何渲染”的描述,而不是最终 DOM 本身。

动态组件、条件渲染和插槽,首先影响的是 VNode 结构。

3. DOM 树

浏览器最终看到的是 DOM 树。通常 DOM 树与组件树的层级大致对应,但 Teleport 会有意打破这种对应关系:

组件逻辑树:App
           └── Modal

DOM 树:body
        ├── #app
        └── .modal-container
            └── Modal 生成的 DOM

Modal 仍然是 App 的后代组件,但它的 DOM 可以被放到 body 下的其他容器中。

这个区分非常重要:组件逻辑关系不一定等于 DOM 放置位置


二、Slots:父组件提供内容,子组件定义插入位置

2.1 Slot 的基本模型

先看一个卡片组件:

<!-- BaseCard.vue -->
<script setup lang="ts">
defineProps<{
  title: string
}>()
</script>

<template>
  <article class="card">
    <header class="card__header">
      <h2>{{ title }}</h2>
    </header>

    <section class="card__body">
      <slot />
    </section>
  </article>
</template>

父组件这样使用:

<BaseCard title="账户信息">
  <p>用户名:alice</p>
  <p>状态:已启用</p>
</BaseCard>

最终结构近似为:

<article class="card">
  <header class="card__header">
    <h2>账户信息</h2>
  </header>

  <section class="card__body">
    <p>用户名:alice</p>
    <p>状态:已启用</p>
  </section>
</article>

这里有两个不同的责任:

  • BaseCard 决定卡片外壳、标题区域和内容区域的位置。
  • 父组件决定插入到默认插槽中的内容。

因此,Slot 不是“把 HTML 字符串传给子组件”,而是父组件提供的一段模板内容,由子组件在自己的模板中调用

从运行机制看,插槽内容会以函数形式传递给子组件。概念上可以近似理解为:

const slots = {
  default: () => [
    h('p', '用户名:alice'),
    h('p', '状态:已启用')
  ]
}

子组件调用默认插槽:

slots.default?.()

这也是为什么插槽内容仍然使用父组件的作用域,而不是子组件的作用域。


2.2 插槽内容的作用域:在哪里声明,就读取哪里的变量

<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import BaseCard from './BaseCard.vue'

const username = ref('alice')
</script>

<template>
  <BaseCard title="用户">
    <p>{{ username }}</p>
  </BaseCard>
</template>

username 属于 Parent.vue。即使这段 <p> 最终出现在 BaseCard 的模板位置,它仍然读取父组件的 username

反过来,子组件内部的变量不能直接被默认插槽访问:

<!-- BaseCard.vue -->
<script setup lang="ts">
const internalId = 'card-001'
</script>

<template>
  <slot />
</template>

父组件不能直接写:

<BaseCard>
  {{ internalId }}
</BaseCard>

因为 internalId 不在父组件作用域内。要让子组件向插槽内容提供数据,必须使用作用域插槽


三、默认插槽、具名插槽和作用域插槽

3.1 默认插槽

没有名称的 <slot /> 是默认插槽:

<slot />

父组件直接在组件标签内部提供内容:

<BaseCard>
  <p>这是默认内容</p>
</BaseCard>

如果父组件没有提供内容,默认插槽也可以提供回退内容:

<template>
  <section>
    <slot>
      <p>暂无内容</p>
    </slot>
  </section>
</template>

这里的回退内容只有在调用方没有提供默认插槽时渲染。


3.2 具名插槽

当组件有多个内容区域时,应使用具名插槽:

<!-- Panel.vue -->
<template>
  <section class="panel">
    <header class="panel__header">
      <slot name="header" />
    </header>

    <main class="panel__body">
      <slot />
    </main>

    <footer class="panel__footer">
      <slot name="footer" />
    </footer>
  </section>
</template>

调用方可以使用 #name 语法:

<Panel>
  <template #header>
    <h2>订单详情</h2>
  </template>

  <p>订单号:A-1001</p>

  <template #footer>
    <button type="button">关闭</button>
  </template>
</Panel>

#default 是默认插槽的具名写法:

<Panel>
  <template #default>
    <p>正文</p>
  </template>
</Panel>

通常不需要显式写 #default,但当同时需要多个 template 插槽块时,它能让结构更清楚。

具名插槽的关键不是“给 HTML 起名字”,而是:子组件定义多个稳定的内容契约,调用方按名称填充不同区域


3.3 作用域插槽:子组件提供数据,父组件决定展示方式

假设列表组件负责获取和遍历数据,但不希望写死每一行的展示结构:

<!-- UserList.vue -->
<script setup lang="ts">
type User = {
  id: number
  name: string
  online: boolean
}

defineProps<{
  users: User[]
}>()
</script>

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <slot
        name="user"
        :user="user"
        :online="user.online"
      >
        {{ user.name }}
      </slot>
    </li>
  </ul>
</template>

父组件使用:

<UserList :users="users">
  <template #user="{ user, online }">
    <strong>{{ user.name }}</strong>
    <span>{{ online ? '在线' : '离线' }}</span>
  </template>
</UserList>

UserList 提供 useronline,父组件决定如何渲染它们。数据流方向是:

UserList ──slot props──> 父组件插槽模板

这与普通 Props 的方向不同:

父组件 ──props──> UserList
UserList ──slot props──> 父组件提供的模板

作用域插槽可以用 TypeScript 类型约束。使用 defineSlots 时,Vue 3.3+ 支持在 <script setup> 中声明插槽类型:

<script setup lang="ts">
type User = {
  id: number
  name: string
  online: boolean
}

defineSlots<{
  user(props: { user: User; online: boolean }): any
  footer(props: { total: number }): any
}>()
</script>

defineSlots 主要用于编辑器和类型检查;它不会在运行时验证插槽参数。


3.4 条件渲染具名插槽

组件可以通过 $slots 判断调用方是否提供了某个插槽:

<!-- DataTable.vue -->
<script setup lang="ts">
defineProps<{
  rows: Array<{ id: number; name: string }>
}>()
</script>

<template>
  <table>
    <thead>
      <tr>
        <th>名称</th>
        <th v-if="$slots.actions">操作</th>
      </tr>
    </thead>

    <tbody>
      <tr v-for="row in rows" :key="row.id">
        <td>{{ row.name }}</td>
        <td v-if="$slots.actions">
          <slot name="actions" :row="row" />
        </td>
      </tr>
    </tbody>
  </table>
</template>

调用方不提供 actions 时,操作列本身不会渲染。这样比始终渲染空的 <td> 更准确,也避免组件内部猜测调用方是否需要该区域。

$slots.actions 只表示插槽函数是否存在,不代表它一定会产生可见 DOM。例如调用方提供了一个返回空内容的插槽,$slots.actions 仍可能为真。因此,如果业务要求判断“最终是否有可见内容”,不能仅依赖这个判断。


四、Slots 与 Props、Emits、v-model 的边界

组件契约可以按责任区分:

需求 适合的机制
传递普通数据 Props
通知父组件发生了动作 Emits
双向绑定一个值 v-model
让调用方控制局部模板 Slots
让子组件提供插槽渲染所需数据 Scoped Slots

例如,一个对话框组件可以这样设计:

<script setup lang="ts">
const props = defineProps<{
  open: boolean
}>()

const emit = defineEmits<{
  'update:open': [value: boolean]
  confirm: []
}>()
</script>

<template>
  <Teleport to="body">
    <div v-if="props.open" class="dialog">
      <header>
        <slot name="title">确认操作</slot>
      </header>

      <main>
        <slot />
      </main>

      <footer>
        <slot name="actions">
          <button type="button" @click="emit('update:open', false)">
            取消
          </button>
          <button type="button" @click="emit('confirm')">
            确认
          </button>
        </slot>
      </footer>
    </div>
  </Teleport>
</template>

这里:

  • open 是状态输入,用 Props 接收。
  • 关闭行为通过 update:open 支持 v-model:open
  • 确认行为通过 confirm 事件通知。
  • 标题、正文和操作区通过 Slots 定制。
  • DOM 位置通过 Teleport 放到 body 下。

这几种机制可以同时存在,但职责不应混用。例如,不应把一个组件的全部结构都暴露成插槽,也不应把本来只需要显示的内容设计成复杂的双向绑定。


五、动态组件:运行时决定组件类型

5.1 <component :is> 的基本用法

Vue 的动态组件语法是:

<component :is="currentComponent" />

currentComponent 可以是:

  • 已注册组件的字符串名称;
  • 一个组件对象;
  • 一个异步组件;
  • 某些内置元素或组件标识。

Composition API 中通常直接保存组件对象:

<script setup lang="ts">
import { ref, computed } from 'vue'
import ProfileTab from './ProfileTab.vue'
import SecurityTab from './SecurityTab.vue'

const currentTab = ref<'profile' | 'security'>('profile')

const currentComponent = computed(() => {
  return currentTab.value === 'profile'
    ? ProfileTab
    : SecurityTab
})
</script>

<template>
  <nav>
    <button
      type="button"
      :aria-selected="currentTab === 'profile'"
      @click="currentTab = 'profile'"
    >
      资料
    </button>

    <button
      type="button"
      :aria-selected="currentTab === 'security'"
      @click="currentTab = 'security'"
    >
      安全
    </button>
  </nav>

  <component :is="currentComponent" />
</template>

切换过程可以拆成以下步骤:

  1. currentTabprofile 变为 security
  2. currentComponent 重新计算为 SecurityTab
  3. 动态组件 VNode 的类型发生变化。
  4. Vue 卸载旧的 ProfileTab 实例。
  5. Vue 创建并挂载 SecurityTab 实例。
  6. 如果没有 KeepAlive,旧组件的本地状态和 DOM 都不再保留。

动态组件并不等同于路由。路由还包含 URL、历史记录、嵌套路由、导航守卫等概念;动态组件只是模板层面的组件选择。


5.2 动态组件中的 Props、事件和 Slots

动态组件仍然可以接收普通属性、事件和插槽:

<component
  :is="currentComponent"
  :title="pageTitle"
  @submit="handleSubmit"
>
  <template #default="{ item }">
    <span>{{ item.name }}</span>
  </template>
</component>

但这里有一个契约问题:ProfileTabSecurityTab 必须兼容这些 Props、事件和插槽,否则切换到某个组件时,属性可能无效或插槽没有被消费。

可以用联合类型和显式映射避免把不兼容的组件塞进同一个动态区域:

const tabs = {
  profile: ProfileTab,
  security: SecurityTab
} as const

type TabName = keyof typeof tabs
const activeTab = ref<TabName>('profile')

如果不同页面需要完全不同的 Props 契约,通常应在动态组件外层做分支,或者为每种组件建立统一的适配接口,而不是假设所有组件都接受同样的参数。


5.3 key 决定实例是否应被视为同一个渲染对象

key 用于标识同一位置上的 VNode 身份。例如:

<component
  :is="currentComponent"
  :key="currentTab"
/>

currentTab 改变时,即使某些组件类型相同,只要 key 不同,Vue 也会把它们视为不同实例。

这在“同一组件展示不同记录”时很有用:

<UserEditor
  :key="userId"
  :user-id="userId"
/>

userId 变化时,新的 key 会强制创建新的编辑器实例,避免旧记录的本地表单状态错误地沿用。

反例是无意中使用不稳定的 key:

<component :is="currentComponent" :key="Math.random()" />

每次渲染都会生成新 key,导致组件不断卸载和重建,输入状态、焦点和缓存都可能失效。


六、KeepAlive:缓存组件实例,而不是只缓存 HTML

6.1 没有缓存时发生什么

<component :is="activeTab" />

假设当前是 Editor,切换到 Preview

Editor:mounted
切换
Editor:unmounted
Preview:mounted

如果 Editor 中有输入框,用户切换回来时通常会得到一个新实例,输入内容若只存于组件本地状态,就会丢失。


6.2 使用 KeepAlive

<script setup lang="ts">
import { ref } from 'vue'
import Editor from './Editor.vue'
import Preview from './Preview.vue'

const current = ref(Editor)
</script>

<template>
  <KeepAlive>
    <component :is="current" />
  </KeepAlive>

  <button type="button" @click="current = Editor">
    编辑
  </button>

  <button type="button" @click="current = Preview">
    预览
  </button>
</template>

切换过程变为:

Editor:mounted
切换到 Preview
Editor:deactivated
Preview:mounted
切换回 Editor
Preview:deactivated
Editor:activated

旧实例没有被卸载,而是从 DOM 中移出并进入缓存。切回来时,Vue 重新激活原实例。

因此,KeepAlive 缓存的是:

  • 组件实例;
  • 组件本地响应式状态;
  • 组件关联的 VNode 和 DOM 状态的一部分。

它不是通用的数据缓存,也不会替代 Pinia、服务端缓存或浏览器存储。


6.3 onActivatedonDeactivated

被缓存的组件需要区分“首次挂载”和“重新显示”:

<script setup lang="ts">
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'

onMounted(() => {
  console.log('首次挂载,或实例首次创建')
})

onActivated(() => {
  console.log('组件进入活动状态')
})

onDeactivated(() => {
  console.log('组件离开活动状态')
})

onUnmounted(() => {
  console.log('组件实例最终销毁')
})
</script>

如果组件在 KeepAlive 内:

  • onMounted 通常只在实例首次挂载时触发;
  • onActivated 在首次挂载后也会触发,并在之后每次重新激活时触发;
  • onDeactivated 在被缓存隐藏时触发;
  • onUnmounted 只有缓存实例最终被销毁时才触发。

因此,轮询、订阅和浏览器事件监听的处理不能只写在 onMountedonUnmounted 中。一个只在激活期间运行的轮询应这样写:

<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'

let timer: number | undefined

onActivated(() => {
  timer = window.setInterval(() => {
    console.log('刷新当前页面数据')
  }, 30_000)
})

onDeactivated(() => {
  if (timer !== undefined) {
    window.clearInterval(timer)
    timer = undefined
  }
})
</script>

6.4 includeexcludemax

可以限制哪些组件进入缓存:

<KeepAlive :include="['ProfileTab', 'SettingsTab']">
  <component :is="currentComponent" />
</KeepAlive>

也可以使用正则或组件名列表。匹配依据是组件的名称,因此组件名必须稳定、可识别。

<script setup> 单文件组件中,Vue 3.2+ 会根据文件名推断组件名。需要显式指定名称时,可使用 Vue 3.3+ 支持的:

<script setup lang="ts">
defineOptions({
  name: 'SettingsTab'
})
</script>

max 用于限制缓存实例数量:

<KeepAlive :max="5">
  <component :is="currentComponent" />
</KeepAlive>

当缓存达到上限,最久未使用的缓存实例会被移除。被移除的实例会经过销毁流程,因此不能把 max 理解成“无限保留最近五个页面的数据”。

实际占用的资源取决于组件内部状态、DOM 规模、订阅和第三方实例。缓存过多会增加内存压力,尤其是编辑器、图表和大型表格组件。


6.5 KeepAlive 的常见失败表现

误解一:KeepAlive 能保留任何状态

下面的状态不一定由组件实例本身持有:

  • Pinia 中的全局状态;
  • 服务端数据;
  • 浏览器地址栏;
  • 未提交到组件状态的外部编辑器内容。

KeepAlive 只保证被缓存组件实例不因普通切换立即销毁。

误解二:组件仍然可见,所以没有清理必要

KeepAlive 缓存的组件通常已不在活动 DOM 中,但实例仍然存在。若组件在 onMounted 中注册了全局监听,却没有在 onDeactivated 中暂停,可能继续响应不可见页面的事件。

误解三:include 使用的是文件路径

<KeepAlive :include="['src/views/Settings.vue']">

这通常不会匹配组件名。include 对比的是组件名称,不是导入路径。


七、Teleport:移动 DOM,不移动组件逻辑

7.1 基本用法

<Teleport to="body">
  <div class="modal">
    <h2>删除项目</h2>
  </div>
</Teleport>

Vue 仍然从当前组件逻辑树管理这个 div,但浏览器 DOM 会把它放到 body 中。

一个可运行的模态框示例:

<!-- BaseDialog.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'

const props = withDefaults(
  defineProps<{
    open: boolean
    title?: string
  }>(),
  {
    title: '对话框'
  }
)

const emit = defineEmits<{
  'update:open': [value: boolean]
}>()

function close() {
  emit('update:open', false)
}

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape' && props.open) {
    close()
  }
}

onMounted(() => {
  window.addEventListener('keydown', onKeydown)
})

onUnmounted(() => {
  window.removeEventListener('keydown', onKeydown)
})

watch(
  () => props.open,
  (open) => {
    document.body.classList.toggle('dialog-open', open)
  },
  { immediate: true }
)
</script>

<template>
  <Teleport to="body">
    <div v-if="open" class="dialog-layer">
      <div
        class="dialog-backdrop"
        aria-hidden="true"
        @click="close"
      />

      <section
        class="dialog"
        role="dialog"
        aria-modal="true"
        :aria-label="title"
      >
        <header class="dialog__header">
          <h2>{{ title }}</h2>
          <button type="button" aria-label="关闭" @click="close">
            ×
          </button>
        </header>

        <div class="dialog__body">
          <slot />
        </div>

        <footer class="dialog__footer">
          <slot name="actions">
            <button type="button" @click="close">关闭</button>
          </slot>
        </footer>
      </section>
    </div>
  </Teleport>
</template>

父组件可以继续用普通 v-model 语法:

<script setup lang="ts">
import { ref } from 'vue'
import BaseDialog from './BaseDialog.vue'

const open = ref(false)
</script>

<template>
  <button type="button" @click="open = true">
    打开对话框
  </button>

  <BaseDialog v-model:open="open" title="删除文件">
    <p>删除后无法恢复,是否继续?</p>

    <template #actions>
      <button type="button" @click="open = false">
        取消
      </button>
      <button type="button" @click="open = false">
        删除
      </button>
    </template>
  </BaseDialog>
</template>

这里的 v-model:open="open" 会展开为:

<BaseDialog
  :open="open"
  @update:open="open = $event"
/>

Teleport 只是改变 DOM 的落点,不会改变 Props、Emits、Slot 作用域或 provide/inject 的逻辑归属。


7.2 为什么模态框常用 Teleport

如果模态框直接嵌套在页面内容中,可能受到祖先元素影响:

.page {
  transform: translateZ(0);
  overflow: hidden;
  position: relative;
}

这类样式会改变定位上下文、裁剪区域或层叠上下文,使 position: fixed 的模态框表现异常。

把模态框 Teleport 到 body 下,通常可以减少这些 DOM 层级影响:

.dialog-layer {
  position: fixed;
  inset: 0;
  z-index: 1000;
}

Teleport 不是自动解决所有层叠问题的工具。目标容器的层叠上下文、z-index、浏览器原生 <dialog> 行为和其他浮层系统仍然需要统一设计。


7.3 目标元素必须存在

<Teleport to="#modal-root">
  <div>内容</div>
</Teleport>

必须确保应用启动前 DOM 中存在:

<body>
  <div id="app"></div>
  <div id="modal-root"></div>
</body>

如果目标不存在,Teleport 内容无法正确插入。Vite 的 index.html 可以这样配置:

<div id="app"></div>
<div id="modal-root"></div>

在 SSR 场景中,服务端输出和客户端首次渲染必须对目标结构保持一致,否则可能出现 hydration 不匹配。

如果目标是动态生成的,不能简单假设组件挂载时它已经存在。应先创建目标节点,或在 Vue 3.5+ 使用 defer 能力等待同一渲染周期内的目标;defer 属于版本敏感能力,使用前应确认项目所用 Vue 版本。


7.4 多个 Teleport 指向同一目标

多个组件可以 Teleport 到相同目标:

<Teleport to="#overlays">
  <Toast />
</Teleport>

<Teleport to="#overlays">
  <ConfirmDialog />
</Teleport>

它们会共同渲染到 #overlays。插入顺序与 Teleport 的声明和更新顺序有关,不应把 DOM 顺序当成可靠的全局优先级系统。

如果浮层存在明确的层级管理需求,建议由统一的 Overlay 管理器分配层级,而不是依赖组件在页面中的偶然顺序。

可以使用 disabled 暂时关闭传送:

<Teleport to="body" :disabled="isMobileInline">
  <div class="popover">内容</div>
</Teleport>

禁用后,内容会回到当前组件位置渲染。


7.5 Teleport 不自动解决无障碍问题

role="dialog"aria-modal="true" 只是语义的一部分。生产对话框还通常需要:

  • 打开后把焦点移动到对话框内部;
  • 关闭后把焦点还给触发按钮;
  • 限制 Tab 键在对话框内部循环;
  • 支持 Escape 关闭,并区分是否允许关闭;
  • 提供唯一的标题和描述关联;
  • 确保背景内容不会被键盘和读屏器错误访问。

Teleport 解决的是 DOM 位置,不是焦点管理和可访问性状态。


八、异步组件:延迟加载组件定义

8.1 defineAsyncComponent

异步组件的最小形式:

import { defineAsyncComponent } from 'vue'

const ReportsPage = defineAsyncComponent(
  () => import('./ReportsPage.vue')
)

使用方式与普通组件一致:

<component :is="ReportsPage" />

import('./ReportsPage.vue') 返回一个 Promise。组件第一次需要渲染时,Vue 执行加载器;加载完成后,Vue 得到真正的组件定义并继续挂载。

在 Vite 中,动态导入通常会产生独立代码分块。浏览器初始加载主包时不必立即下载该页面组件。


8.2 完整的加载态、错误态和超时处理

// components/AsyncReportsPage.ts
import { defineAsyncComponent, h } from 'vue'
import LoadingView from './LoadingView.vue'
import ErrorView from './ErrorView.vue'

export const AsyncReportsPage = defineAsyncComponent({
  loader: () => import('./ReportsPage.vue'),

  loadingComponent: LoadingView,
  errorComponent: ErrorView,

  // 默认超时与具体网络环境有关,显式设置后契约更清楚
  timeout: 10_000,

  // 进入 Suspense 时是否由 Suspense 接管异步等待
  suspensible: true,

  onError(error, retry, fail, attempts) {
    const message = String(error)

    // 网络抖动或临时加载失败时最多重试两次
    if (
      attempts <= 2 &&
      (message.includes('Failed to fetch dynamically imported module') ||
        message.includes('Importing a module script failed'))
    ) {
      retry()
      return
    }

    fail()
  }
})

各部分的因果关系如下:

  1. loader 返回 Promise,表示组件定义尚未准备好。
  2. Promise 未完成时,若未被 Suspense 接管,Vue 可以渲染 loadingComponent
  3. Promise 在超时时间内失败或超时后,Vue 可以渲染 errorComponent
  4. onError 决定是 retry() 重新加载,还是 fail() 进入错误态。
  5. attempts 表示当前加载尝试次数,可避免无限重试。

错误组件可以接收错误对象:

<!-- ErrorView.vue -->
<script setup lang="ts">
defineProps<{
  error?: unknown
}>()
</script>

<template>
  <div role="alert">
    页面加载失败,请刷新后重试。
  </div>
</template>

不同 Vue 版本对错误组件收到的内部参数、默认加载行为和 Suspense 细节可能存在差异。公共组件库应以当前项目锁定的 Vue 版本 API 为准,并通过实际测试确认类型。


8.3 重试必须区分“临时失败”和“确定性失败”

适合重试的情况包括:

  • 网络瞬时中断;
  • 动态分块请求偶发失败;
  • 移动网络切换导致的连接重置。

不适合盲目重试的情况包括:

  • 组件代码本身语法错误;
  • 服务端持续返回 404;
  • 用户权限导致的业务拒绝;
  • 版本发布后旧 HTML 引用了已经删除的 chunk。

无限重试会造成请求风暴,也会让用户一直看不到明确错误。更稳妥的策略是:

首次加载失败
    ├── 可判断为临时网络错误 → 有限次数重试
    └── 代码错误、404 或达到上限 → 展示错误页

如果部署采用带哈希的 chunk 文件,发布新版本后旧页面可能仍引用旧 chunk。此时除了重试,还应在错误页提供刷新入口;刷新是否安全取决于应用是否有未保存表单数据。


九、异步组件与 Suspense

Suspense 可以协调异步依赖的等待状态:

<Suspense>
  <template #default>
    <AsyncReportsPage />
  </template>

  <template #fallback>
    <LoadingView />
  </template>
</Suspense>

在这个结构中,Suspense 可能接管异步组件的等待过程,直接显示 fallback。如果希望异步组件自己的 loadingComponent 生效,需要理解 suspensible 的关系:

  • suspensible: true 时,父级 Suspense 可以接管等待;
  • suspensible: false 时,异步组件更倾向于自行管理 loading 和 error 状态。

Suspense 的核心是“等待异步依赖完成再切换边界状态”,它不是请求库,也不会自动重试请求。错误处理仍应由异步组件、onErrorCaptured 或应用级错误处理机制负责。

如果异步组件内部还有 async setup(),它也可能成为 Suspense 的异步依赖:

<script setup lang="ts">
const response = await fetch('/api/reports')

if (!response.ok) {
  throw new Error(`HTTP ${response.status}`)
}

const reports = await response.json()
</script>

<template>
  <ul>
    <li v-for="report in reports" :key="report.id">
      {{ report.name }}
    </li>
  </ul>
</template>

此时应明确区分两类失败:

  • 代码分块加载失败import() 没有得到组件定义;
  • 组件内部数据请求失败:组件已经开始执行,但 API 返回错误。

二者的重试策略和用户提示通常不同。


十、把动态组件、KeepAlive、Teleport 和异步组件组合起来

下面构造一个“设置中心”:

  • Tab 使用动态组件;
  • Tab 切换时保留编辑状态;
  • 帮助弹窗使用 Teleport;
  • 设置页面按需异步加载。
<!-- SettingsShell.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import { defineAsyncComponent } from 'vue'
import BaseDialog from './BaseDialog.vue'
import ProfileSettings from './ProfileSettings.vue'
import SecuritySettings from './SecuritySettings.vue'
import LoadingView from './LoadingView.vue'
import ErrorView from './ErrorView.vue'

type Tab = 'profile' | 'security' | 'advanced'

const AsyncAdvancedSettings = defineAsyncComponent({
  loader: () => import('./AdvancedSettings.vue'),
  loadingComponent: LoadingView,
  errorComponent: ErrorView,
  timeout: 10_000
})

const activeTab = ref<Tab>('profile')
const helpOpen = ref(false)

const components = {
  profile: ProfileSettings,
  security: SecuritySettings,
  advanced: AsyncAdvancedSettings
} as const

const activeComponent = computed(() => components[activeTab.value])
</script>

<template>
  <section>
    <nav aria-label="设置分类">
      <button
        v-for="tab in (Object.keys(components) as Tab[])"
        :key="tab"
        type="button"
        :aria-current="activeTab === tab ? 'page' : undefined"
        @click="activeTab = tab"
      >
        {{ tab }}
      </button>
    </nav>

    <KeepAlive :max="3">
      <component
        :is="activeComponent"
        :key="activeTab"
      >
        <template #header>
          <h2>设置</h2>
        </template>
      </component>
    </KeepAlive>

    <button type="button" @click="helpOpen = true">
      打开帮助
    </button>

    <BaseDialog v-model:open="helpOpen" title="设置帮助">
      <p>不同设置页面的内容由当前动态组件负责。</p>
    </BaseDialog>
  </section>
</template>

这段代码的运行过程是:

  1. 初始 activeTabprofile,同步组件立即挂载。
  2. 切换到 securityProfileSettingsdeactivatedSecuritySettings 挂载。
  3. 切换回 profile,Vue 从 KeepAlive 缓存中激活原实例。
  4. 切换到 advanced,异步组件开始执行动态导入。
  5. 加载期间由 LoadingView 或外部 Suspense 显示等待状态。
  6. 加载成功后,AdvancedSettings 挂载,并可以被 KeepAlive 缓存。
  7. 打开帮助时,BaseDialog 的逻辑实例仍在 SettingsShell 的组件树中,但对话框 DOM 被 Teleport 到 body
  8. 关闭帮助时,v-model:open 通过 update:open 把状态写回父组件。

需要注意,KeepAlive 与异步组件组合时,缓存的是异步组件解析完成后的实例。在首次加载完成前,缓存并不存在一个可供复用的完整页面实例。


十一、组件切换、缓存和异步加载的状态机

可以把一个动态异步组件的状态简化为:

stateDiagram-v2
    [*] --> 未请求
    未请求 --> 加载中: 首次渲染
    加载中 --> 已解析: import 成功
    加载中 --> 加载失败: 超时或 loader 失败
    加载失败 --> 加载中: retry()
    加载失败 --> 错误展示: fail() 或超过重试上限
    已解析 --> 活跃: 挂载或激活
    活跃 --> 已缓存: KeepAlive 缓存
    已缓存 --> 活跃: 再次切换回来
    已缓存 --> 已销毁: 被 max 淘汰或父树卸载
    活跃 --> 已销毁: 未使用 KeepAlive 时切换

这个状态机说明了三个经常被混淆的时间点:

  • 代码解析完成不代表组件已经挂载;
  • 组件被缓存不代表组件仍处于活动状态;
  • 组件被隐藏不代表组件实例已销毁。

因此排查问题时,应分别检查:

  1. 动态导入 Promise 是否完成;
  2. 组件是否触发 mounted
  3. 组件是在 activated 还是重新 mounted
  4. 是否因 max、父级卸载或 key 变化而销毁;
  5. Teleport 目标是否存在且包含预期 DOM。

十二、真实边界与常见误解

12.1 Slot 不是任意模板注入机制

插槽内容仍受 Vue 编译和响应式系统管理,但不能把它当成字符串拼接或安全 HTML 渲染。需要渲染来自服务端的 HTML 时,应明确使用 v-html 的安全边界,并对不可信内容进行净化;Slot 本身不会自动把不可信字符串变成可执行模板。

12.2 不能在动态组件上假设所有插槽都存在

<component :is="currentComponent">
  <template #actions>
    <button>保存</button>
  </template>
</component>

只有当前组件消费了 actions 插槽时,它才会出现在页面中。若不同动态组件的插槽契约不同,应在组件接口层统一名称,或按组件类型分别提供模板。

12.3 KeepAlive 不适合所有页面

以下场景往往不应默认缓存:

  • 页面数据必须每次进入都重新初始化;
  • 页面持有大型图表或编辑器实例;
  • 页面离开后必须立即释放订阅;
  • 页面状态可能包含敏感信息;
  • 缓存数量不可控。

若只是希望切换时保留一个简单输入值,使用父组件状态或表单状态管理也可能比缓存整个组件更明确。

12.4 Teleport 后 CSS 选择器可能改变

.page .dialog {
  color: red;
}

如果 .dialog 被 Teleport 到 body 下,它不再是 .page 的后代,因此该选择器可能失效。需要使用全局样式、稳定的类名,或把样式作用域设计为适合 Teleport 后的 DOM 位置。

使用 <style scoped> 时,Vue 会为组件 DOM 添加作用域属性;Teleport 渲染的节点仍由该组件生成,通常可以匹配组件自己的 scoped 样式,但依赖祖先 DOM 结构的选择器仍会受影响。

12.5 异步组件不是异步数据组件

const AsyncUserPage = defineAsyncComponent(
  () => import('./UserPage.vue')
)

这里只延迟加载 JavaScript 模块,不会自动请求用户数据,也不会自动缓存 API 结果。数据请求、取消请求、缓存和错误重试仍需由组件或数据层负责。

12.6 动态导入路径不能完全任意

Vite 能处理静态可分析的动态导入:

const pages = {
  home: () => import('./pages/Home.vue'),
  about: () => import('./pages/About.vue')
}

这种写法明确列出了可能的模块。

而完全任意的路径:

const page = import(`./pages/${userInput}.vue`)

通常会受到构建工具分析能力和打包范围限制,也可能引入不应被加载的模块。应使用显式映射或 import.meta.glob,并对外部输入做白名单控制。


十三、调试这些机制的具体方法

1. 插槽没有显示

按以下顺序检查:

  1. 子组件是否真的写了对应的 <slot name="...">
  2. 调用方的名称是否一致,例如 #footername="footer"
  3. 作用域插槽的解构名称是否正确;
  4. 子组件是否因 v-if、动态组件切换或 Teleport 条件而没有渲染;
  5. 是否把插槽误写成了普通 Props。

可以临时输出:

<pre>{{ Object.keys($slots) }}</pre>

这只能帮助判断插槽函数是否存在,不能证明插槽最终有可见内容。

2. 切换组件后状态丢失

检查:

  • 是否包在 KeepAlive 内;
  • key 是否被动态改变;
  • 动态组件类型是否每次都重新创建;
  • 状态是否实际存储在组件实例之外;
  • 是否因为 include 名称不匹配而没有进入缓存。

不要这样在模板更新过程中反复创建匿名组件:

const current = computed(() => ({
  template: '<div>...</div>'
}))

每次得到的新组件定义都可能被视为新的类型。应使用稳定导入的组件对象。

3. Teleport 内容不见了

检查:

  • to 选择器是否能找到元素;
  • 目标是否在首次渲染时已经存在;
  • v-if 是否使 Teleport 内容没有生成;
  • 内容是否被其他全局样式隐藏;
  • 是否因为 z-indexoverflow 或定位上下文导致不可见;
  • SSR 的服务端和客户端结构是否一致。

浏览器开发者工具中应直接搜索目标容器,而不是只查看 #app 内部。

4. 异步组件一直加载

检查:

  • Network 面板中动态 chunk 是否返回 200;
  • 是否发生跨域、缓存或部署路径错误;
  • Vite 的 base 是否与生产部署路径一致;
  • loader Promise 是否真的返回组件模块;
  • 是否把运行时业务请求误认为代码分块请求;
  • 是否存在无限 retry。

对于 chunk 加载失败,记录错误、当前构建版本和 chunk URL,通常比只记录“页面加载失败”更容易定位发布或缓存问题。


十四、设计组件契约时的取舍

Slots 适合暴露稳定的结构扩展点,例如:

Card:header、default、footer
Dialog:title、default、actions
Table:cell、empty、loading、actions

如果组件暴露几十个细粒度插槽,调用方虽然获得了高度自由,但组件的 DOM 结构也被外部强耦合。之后调整 HTML 层级、无障碍属性或主题样式时,兼容成本会显著增加。

一种较稳定的分层方式是:

  • Props 控制数据和少量行为参数;
  • Emits 控制动作通知;
  • Slots 控制有限的结构扩展;
  • CSS Variables 或设计 Token 控制颜色、间距、圆角等视觉变化;
  • 对复杂渲染逻辑使用作用域插槽,但明确插槽参数类型。

例如表格组件可以提供:

defineSlots<{
  cell(props: {
    row: Record<string, unknown>
    column: { key: string; label: string }
    value: unknown
  }): any

  empty(): any
  loading(): any
}>()

这样调用方可以重绘单元格,但表格仍然保留行键、表头、键盘导航和基础语义的控制权。

动态组件也应有明确的统一契约。若多个页面需要被同一个 Tab 容器切换,它们至少应约定:

  • 可接受的公共 Props;
  • 事件名称;
  • 默认插槽和具名插槽;
  • 页面激活和停用时的资源行为;
  • 是否允许被 KeepAlive 缓存。

否则,动态组件容器会变成一个运行时才暴露错误的“万能容器”。


十五、最终组合关系

可以用下面的关系概括四种能力:

Slots
  └── 决定组件内部哪些区域由调用方提供内容

动态组件
  └── 决定当前区域使用哪个组件定义

KeepAlive
  └── 决定动态切换后旧组件实例是销毁还是缓存

Teleport
  └── 决定组件生成的 DOM 被放到哪个 DOM 容器

异步组件
  └── 决定组件定义何时通过代码分块加载

它们可以组合,但每一层只解决自己的问题:

父组件提供插槽内容
        ↓
动态选择当前页面组件
        ↓
KeepAlive 决定页面实例是否保留
        ↓
异步组件决定页面代码是否延迟下载
        ↓
Teleport 决定浮层 DOM 的最终位置

当出现“内容不见了、状态丢失、页面重复请求、弹窗层级异常或异步页面打不开”时,应先判断问题属于哪一层,而不是把所有现象归因于 Vue 的响应式系统。Slot 看插槽契约,动态组件看类型和 keyKeepAlive 看激活与销毁,Teleport 看目标 DOM 和样式,异步组件看 chunk、Promise 和错误恢复路径。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。