组件、props、插槽
本教程共 50 篇 · 第 12 篇 · 更新于 2026-08-08 · 约 7 分钟阅读
本节目标:掌握 Vue 组件之间怎么通信——用 props 从父传数据给子、用 emits 从子发事件给父,再用插槽让父组件往子组件里塞内容。
把界面拆成一个个组件后,它们必须能「对话」才有用。Vue 的通信规则很清晰:数据向下用 props,事件向上用 emits,内容嵌套用插槽。这章把这三件套讲透。
12-1
Vue 的组件形成一棵树。父组件里用子组件,就像搭积木。通信方向约定俗成:
- 父 → 子:传数据,用
props。 - 子 → 父:通知事情发生(常附带数据),用
emits。 - 父 → 子(内容):决定子组件内部某块显示什么,用
插槽 slot。
记住「props 向下、事件向上」这八个字,后面就不会乱。
12-2
defineProps() 是编译期宏(不是普通函数),用来声明「这个组件能接收哪些外部传入的属性」。
推荐用基于类型的写法(配合 TypeScript 最直观):
<!-- Child.vue -->
<script setup lang="ts">
const props = defineProps<{
title: string
count?: number
}>()
</script>
<template>
<h2>{{ title }}</h2>
<p>数量:{{ count }}</p>
</template>
父组件这样传值:
<!-- Parent.vue -->
<template>
<Child title="欢迎" :count="3" />
</template>
要点:
- 普通文本直接写
title="欢迎";其他类型(数字、对象、表达式)要加:(即v-bind简写),如:count="3"。 - 在
<script>里通过props.title访问;在<template>里直接写title即可,编译器知道它是 props。 ?表示可选,count?不传也不会报错。
Warning把 props 当成只读。子组件里不要去修改 props 的值——这会破坏「父组件拥有数据」的约定,导致父子紧耦合、难以维护。需要基于 props 做本地状态,用 props 的值去初始化一个本地变量。
12-3
props 负责「给数据」,emits 负责「说事情发生了」。子组件用 defineEmits() 声明自己会发出哪些事件,需要时调用 emit。
<!-- Child.vue -->
<script setup lang="ts">
const emit = defineEmits<{
(e: 'submit', value: string): void
}>()
function onClick() {
emit('submit', '来自子组件的数据')
}
</script>
<template>
<button @click="onClick">提交</button>
</template>
父组件监听这个事件:
<!-- Parent.vue -->
<script setup lang="ts">
function handleSubmit(payload: string) {
console.log('收到子组件消息:', payload)
}
</script>
<template>
<Child @submit="handleSubmit" />
</template>
这样,子组件点按钮 → 发出 submit 事件并带上数据 → 父组件的 handleSubmit 被调用。数据沿着「事件向上」的路线回到了父级。
Tip模板里也能直接用
$emit:<button @click="$emit('submit', val)">。但代码稍多时,还是在<script>里用const emit = defineEmits(...)更清楚。
12-4
常见场景:一个输入框组件,父级传初始值、用户改了再通知父级。这种「又传又收」用 v-model 最简洁。Vue 3.4+ 提供 defineModel() 宏:
<!-- Child.vue -->
<script setup lang="ts">
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>
父组件直接:
<!-- Parent.vue -->
<script setup lang="ts">
const text = ref('')
</script>
<template>
<Child v-model="text" />
</template>
Vue 自动保证:用户在子组件输入,text 同步更新,无需手写 emit + props 来回接。这是处理表单类组件的首选。
12-5
有时你想让子组件留出一块「空位」,由父组件填内容。这就是插槽(slot)。最基础的默认插槽:
<!-- Card.vue -->
<template>
<div class="card">
<slot>这里放默认内容(父没传时显示)</slot>
</div>
</template>
父组件往里塞:
<template>
<Card>
<p>这段内容会填进 Card 的插槽里</p>
</Card>
</template>
需要多个插槽时,用具名插槽:
<!-- Layout.vue -->
<template>
<div>
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</div>
</template>
父组件指定名字:
<template>
<Layout>
<template #header>顶部标题</template>
<template #default>正文内容</template>
<template #footer>页脚信息</template>
</Layout>
</template>
Note插槽传的是「内容/模板」,props 传的是「数据」。简单区分:要让子组件显示父给的值用 props;要让子组件显示父给的一段界面用插槽。
12-6
当组件嵌套很深,中间层只是为了把 props 往下传,会出现「透传地狱」(prop drilling)。Vue 提供 provide / inject 跨层直接通信:
// 祖先组件
provide('theme', 'dark')
// 任意后代组件
const theme = inject('theme')
不过日常小项目用 props + emits 就够了;跨层共享、全局状态更推荐用 Nuxt 的 useState 或 Pinia(后续章节讲)。provide/inject 知道有这个选项即可。
12-7
把前面三件套串起来,看一个真实场景:父组件显示文章列表,子组件是一个「点赞按钮」,点一下把次数回传给父。
<!-- LikeButton.vue(子) -->
<script setup lang="ts">
const props = defineProps<{ initial: number }>()
const emit = defineEmits<{ (e: 'liked', count: number): void }>()
// 本地状态基于 props 初始化,不直接改 props
const count = ref(props.initial)
function onClick() {
count.value++
emit('liked', count.value) // 把最新值抛给父
}
</script>
<template>
<button @click="onClick">赞 {{ count }}</button>
</template>
<!-- Parent.vue(父) -->
<script setup lang="ts">
const total = ref(0)
function onLiked(n: number) {
total.value = n // 接收子组件抛来的值
}
</script>
<template>
<LikeButton :initial="0" @liked="onLiked" />
<p>总点赞:{{ total }}</p>
</template>
逐行看流向:父用 :initial 把初始值向下传进子 → 子用 props.initial 接住并初始化本地 count → 用户点按钮,子改本地状态并 emit('liked', ...) 向上通知父 → 父的 @liked 处理函数拿到值更新 total。一条数据,先下后上,各管各的,互不越界。
Warning注意子组件里我们没有直接改
props.initial,而是用ref(props.initial)建了个本地副本。props 是父的,子只该读;要改就改自己的本地副本,这是 Vue 通信里最容易写错的一点——直接给 props 赋值在开发模式下会报警告。
12-8
回头看三件套,落地时照这个思路挑:
- 父要把「一个值」给子 →
defineProps。 - 子要把「一件事发生了」告诉父 →
defineEmits。 - 父要往子里塞「一整段界面」→ 插槽
slot。 - 表单类既要传初始值又要收改动 →
defineModel(v-model)。 - 跨很多层传同一份数据 → 先用
props/emits试试,真乱了再上provide/inject或useState、Pinia。
Tip一个实用心法:数据永远「谁创建、谁拥有」。父创建的数据用 props 借给子用,子想改就通过事件把意图交还给父去改。守住这条,组件之间就不会缠成一团。
12-9
组件通信三件套:用 defineProps 让父把数据传给子(只读、别改);用 defineEmits 让子把事件发给父;用 slot 让父往子里塞内容。v-model 则是表单类双向绑定的省事写法。掌握这几点,你就能把 Nuxt 页面拆成干净、可复用的组件了。
第一部分到此结束。你已经从「Nuxt 是什么」一路走到「组件怎么通信」,打好了 Nuxt 开发的地基。后续章节会在此基础上展开路由、数据获取、服务端能力等更进阶的主题。
12-9 组件设计的基本原则
好的组件设计是前端项目可维护的基石。在 Nuxt 里,由于组件自动导入,你不需要担心 import 语句的繁琐,可以把更多精力放在组件本身的质量上。
一个组件应该只做一件事。如果你发现一个 .vue 文件超过两百行,或者同时处理数据获取、表单验证、UI 渲染,那它可能承担了太多职责。把它拆成更小的组件,每个组件各司其职,通过 props 和 emits 协作。
另外,组件的 props 接口就是它的”合同”。定义清楚每个 prop 的类型、是否必填、默认值,调用方就能正确使用,不需要翻看组件源码。在 TypeScript 支持下,这种合同会被编译器强制执行,漏传或传错类型会立刻报错。
12-10 调试组件通信的实用技巧
组件通信出问题时,最快的排查方式是借助 Vue Devtools 浏览器插件。它能让你实时查看组件树、每个组件的 props 值、发出的事件,以及响应式数据的变化。当 props 传值不符合预期时,在 Devtools 里一眼就能看出来。
另一个有用的调试手段是在子组件的 watch 里打印 props 的变化。当父组件更新 props 时,子组件的 watcher 会触发,打印出旧值和新值。这能帮你确认数据传递的时机和内容是否正确。
对于复杂的组件通信场景(如多层嵌套的 provide/inject),建议在关键节点加日志,追踪数据从源头到目的地的完整路径。这样当数据不符合预期时,你能快速定位是哪一层出了问题。