全局组件与跨层级通信

组件通信是前端架构的核心问题——父子之间用 props/emit,但跨越多层嵌套时怎么办?全局共享的 Toast、Modal、确认弹窗怎么从任意组件触发?Nuxt4 提供了 global/ 全局注册、provide/inject 类型安全注入、useNuxtApp() 属性挂载和 Teleport 传送门等机制。本章逐一拆解。

1. global/ 目录自动注册

1.1 普通组件 vs 全局组件

app/components/
├── VideoCard.vue           ← 普通组件:懒加载,按需导入
├── ui/
│   └── Button.vue          ← 普通组件:懒加载
└── global/
    ├── AppHeader.vue       ← 全局组件:预加载,所有页面可用
    ├── AppFooter.vue       ← 全局组件
    └── Toast.vue           ← 全局组件

区别

  • 普通组件:代码分割后按需加载,首次使用时才下载
  • 全局组件:打包进主 bundle,应用启动时立即可用

1.2 配置全局组件目录

// nuxt.config.ts
export default defineNuxtConfig({
  components: {
    dirs: [
      { path: '~/components', pathPrefix: true },
      { path: '~/components/global', global: true },  // 全局注册
    ],
  },
})

1.3 何时使用全局组件

场景是否全局原因
AppHeader / AppFooter✅ 全局每个页面都用到
Toast / Modal / Dialog✅ 全局任意位置触发
LoadingSpinner✅ 全局到处都用
VideoCard❌ 普通只在视频相关页面用
StudioTimeline❌ 普通只在工作台用

原则:只有真正在绝大多数页面使用的组件才设为全局,否则增加首屏 JS 体积。

2. provide/inject 类型安全用法

2.1 基本用法

provide/inject 是 Vue 的依赖注入机制——祖先组件提供数据,任意后代组件注入使用,无需逐层传递 props。

<!-- 祖先组件 -->
<script setup lang="ts">
const theme = ref<'light' | 'dark'>('light')
provide('theme', theme)
</script>
 
<!-- 任意后代组件 -->
<script setup lang="ts">
const theme = inject<Ref<'light' | 'dark'>>('theme')
</script>

2.2 类型安全的 InjectionKey

直接用字符串 key 容易出错——用 InjectionKey 保证类型安全:

// app/types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
 
export interface VideoPlayerContext {
  isPlaying: Ref<boolean>
  currentTime: Ref<number>
  duration: Ref<number>
  play: () => void
  pause: () => void
  seek: (time: number) => void
}
 
export const VideoPlayerKey: InjectionKey<VideoPlayerContext> =
  Symbol('VideoPlayer')
 
export interface ToastContext {
  show: (message: string, type?: 'success' | 'error' | 'info') => void
  hide: () => void
}
 
export const ToastKey: InjectionKey<ToastContext> = Symbol('Toast')

2.3 提供者组件

<!-- app/components/video/PlayerProvider.vue -->
<script setup lang="ts">
import { VideoPlayerKey } from '~/types/injection-keys'
 
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
 
function play() { isPlaying.value = true }
function pause() { isPlaying.value = false }
function seek(time: number) { currentTime.value = time }
 
provide(VideoPlayerKey, {
  isPlaying: readonly(isPlaying),
  currentTime: readonly(currentTime),
  duration: readonly(duration),
  play,
  pause,
  seek,
})
</script>
 
<template>
  <slot />
</template>

2.4 消费者组件

<!-- app/components/video/Controls.vue -->
<script setup lang="ts">
import { VideoPlayerKey } from '~/types/injection-keys'
 
const player = inject(VideoPlayerKey)
// TypeScript 自动推断 player 的完整类型
 
if (!player) {
  throw new Error('VideoControls must be used inside VideoPlayerProvider')
}
</script>
 
<template>
  <div class="flex items-center gap-2">
    <button @click="player.isPlaying.value ? player.pause() : player.play()">
      {{ player.isPlaying.value ? '暂停' : '播放' }}
    </button>
    <span>{{ player.currentTime.value }}s / {{ player.duration.value }}s</span>
  </div>
</template>

2.5 封装为 Composable

// app/composables/useVideoPlayer.ts
import { VideoPlayerKey } from '~/types/injection-keys'
 
export function useVideoPlayer() {
  const context = inject(VideoPlayerKey)
  if (!context) {
    throw new Error('useVideoPlayer() must be used inside <VideoPlayerProvider>')
  }
  return context
}
<!-- 使用更简洁 -->
<script setup lang="ts">
const { isPlaying, play, pause } = useVideoPlayer()
</script>

3. useNuxtApp $ 属性注入

3.1 通过插件注入全局属性

Nuxt 插件可以向 useNuxtApp() 注入自定义属性,供整个应用使用:

// app/plugins/toast.ts
export default defineNuxtPlugin(() => {
  const toasts = ref<Array<{ id: number; message: string; type: string }>>([])
  let nextId = 0
 
  function show(message: string, type = 'info') {
    const id = nextId++
    toasts.value.push({ id, message, type })
    setTimeout(() => hide(id), 3000)
  }
 
  function hide(id: number) {
    toasts.value = toasts.value.filter(t => t.id !== id)
  }
 
  return {
    provide: {
      toast: { show, hide, toasts },
    },
  }
})

3.2 类型声明

// app/types/nuxt.d.ts
declare module '#app' {
  interface NuxtApp {
    $toast: {
      show: (message: string, type?: string) => void
      hide: (id: number) => void
      toasts: Ref<Array<{ id: number; message: string; type: string }>>
    }
  }
}

3.3 在组件中使用

<script setup lang="ts">
const { $toast } = useNuxtApp()
 
async function handleDelete() {
  try {
    await $fetch('/api/videos/123', { method: 'DELETE' })
    $toast.show('删除成功', 'success')
  } catch {
    $toast.show('删除失败', 'error')
  }
}
</script>

3.4 provide vs useNuxtApp 选择

方案适用场景作用范围
provide/inject组件树局部共享祖先 → 后代
useNuxtApp().$xxx全局共享整个应用
useState()全局响应式状态整个应用(SSR 安全)

4. Teleport 与 NuxtTeleport

4.1 Vue Teleport 基础

&lt;Teleport&gt; 将组件的 DOM 渲染到指定的目标元素中,而非组件所在位置:

<template>
  <!-- 逻辑上属于当前组件 -->
  <!-- DOM 上渲染到 body 末尾 -->
  <Teleport to="body">
    <div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center">
      <div class="absolute inset-0 bg-black/50" @click="showModal = false" />
      <div class="relative bg-white rounded-xl p-6 max-w-lg w-full">
        <h2>确认删除?</h2>
        <p>此操作不可撤销。</p>
        <div class="flex gap-2 mt-4">
          <button @click="showModal = false">取消</button>
          <button @click="confirmDelete" class="bg-red-500 text-white">删除</button>
        </div>
      </div>
    </div>
  </Teleport>
</template>

4.2 为什么需要 Teleport

不用 Teleport:
  <div class="overflow-hidden">       ← 父元素 overflow: hidden
    <Modal class="fixed z-50" />      ← Modal 被裁剪!
  </div>

用 Teleport:
  <div class="overflow-hidden">
    <!-- Modal 的 DOM 不在这里 -->
  </div>
  ...
  <body>
    <Modal class="fixed z-50" />      ← 在 body 末尾,不受父元素影响
  </body>

4.3 SSR 中的 Teleport

SSR 时 &lt;Teleport&gt; 需要注意——服务端渲染时目标元素可能不存在:

<!-- 方案 1:禁用服务端 Teleport -->
<ClientOnly>
  <Teleport to="body">
    <Modal />
  </Teleport>
</ClientOnly>
 
<!-- 方案 2:使用 disabled 属性 -->
<Teleport to="body" :disabled="isServer">
  <Modal />
</Teleport>
 
<script setup lang="ts">
const isServer = import.meta.server
</script>

4.4 全局 Modal/Toast 容器模式

<!-- app/app.vue -->
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
 
  <!-- 全局 Teleport 目标容器 -->
  <div id="modals" />
  <div id="toasts" />
</template>
<!-- 任意组件中使用 -->
<template>
  <Teleport to="#modals">
    <ConfirmDialog v-if="showConfirm" @confirm="handleConfirm" @cancel="showConfirm = false" />
  </Teleport>
 
  <Teleport to="#toasts">
    <ToastContainer />
  </Teleport>
</template>

4.5 AI 视频项目的全局组件架构

<!-- app/app.vue -->
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
 
  <!-- 全局组件(通过 Teleport 渲染到固定位置) -->
  <ClientOnly>
    <Teleport to="body">
      <!-- Toast 通知 -->
      <ToastContainer />
 
      <!-- 全局确认弹窗 -->
      <GlobalConfirmDialog />
 
      <!-- 全局视频预览浮窗 -->
      <VideoPreviewPopup />
    </Teleport>
  </ClientOnly>
</template>
// app/composables/useConfirm.ts
const isOpen = ref(false)
const message = ref('')
let resolvePromise: ((value: boolean) => void) | null = null
 
export function useConfirm() {
  function confirm(msg: string): Promise<boolean> {
    message.value = msg
    isOpen.value = true
    return new Promise((resolve) => {
      resolvePromise = resolve
    })
  }
 
  function handleConfirm() {
    isOpen.value = false
    resolvePromise?.(true)
  }
 
  function handleCancel() {
    isOpen.value = false
    resolvePromise?.(false)
  }
 
  return { isOpen, message, confirm, handleConfirm, handleCancel }
}
<!-- 在任意组件中使用 -->
<script setup lang="ts">
const { confirm } = useConfirm()
 
async function handleDelete(videoId: string) {
  const ok = await confirm('确认删除这个视频吗?此操作不可撤销。')
  if (ok) {
    await $fetch(`/api/videos/${videoId}`, { method: 'DELETE' })
  }
}
</script>

本章小结

  • global/ 全局组件:打包进主 bundle,应用启动即可用;只放高频使用的组件
  • provide/inject:跨层级数据传递,用 InjectionKey 保证类型安全
  • useNuxtApp().$xxx:插件注入全局属性,适合应用级服务(toast、analytics)
  • Teleport:将 DOM 渲染到指定位置,解决 overflow: hiddenz-index 等层叠问题
  • SSR 注意:Teleport 在 SSR 时需要 &lt;ClientOnly&gt;disabled 处理
  • 实战模式:全局 Toast + 确认弹窗 + 预览浮窗的 Composable + Teleport 架构

至此,第四篇"组件系统精讲"全部 5 章已完成。下一篇将深入状态管理