全局组件与跨层级通信
组件通信是前端架构的核心问题——父子之间用 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 基础
<Teleport> 将组件的 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 时 <Teleport> 需要注意——服务端渲染时目标元素可能不存在:
<!-- 方案 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: hidden、z-index等层叠问题 - SSR 注意:Teleport 在 SSR 时需要
<ClientOnly>或disabled处理 - 实战模式:全局 Toast + 确认弹窗 + 预览浮窗的 Composable + Teleport 架构
至此,第四篇"组件系统精讲"全部 5 章已完成。下一篇将深入状态管理。