组件基础:Props 向下,Emit 向上
组件是什么
一句话:
组件 = HTML + CSS + JS 打包成一个可复用的小模块。
Java 对照:
- 像 Spring MVC 的
@Component+ JSP 片段(<%@ include %>)的合体 - 或者 Swing 里自定义的
JPanel子类——有自己的界面、状态、事件
一个 .vue 文件就是一个组件:
<script setup lang="ts">
// 逻辑(class 的字段和方法)
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<!-- 视图(HTML) -->
<button @click="count++">点了 {{ count }} 次</button>
</template>
<style scoped>
/* 样式(CSS,scoped 表示只影响本组件) */
button { color: red; }
</style>
三段式结构 = "类的字段方法 + Swing 的 paintComponent + 样式表"。
1. 定义与使用
<!-- 父组件 ParentPage.vue -->
<script setup lang="ts">
import NoteCard from '@/components/NoteCard.vue'
</script>
<template>
<NoteCard />
<NoteCard />
</template>
关键点:
<script setup>里import进来的组件自动注册,直接在模板里用- 组件名在模板里用 PascalCase(
<NoteCard />),SFC 和 DOM 模板都兼容 - 自闭合
<NoteCard />和<NoteCard></NoteCard>等价
Java 对照:
// 父 Controller 调用子 Service
@Autowired
private NoteService noteService;
// 使用:noteService.getList()
区别:Vue 组件的"调用"是在模板里像 HTML 标签一样写,不是函数调用。
项目里约定
src/components/放通用组件(多个页面复用,如MarkdownRenderer)- 页面私有组件放在对应页面旁边(如
src/pages/HomePage/NoteCard.vue) - 组件文件用 PascalCase(
NoteCard.vue,不是note-card.vue)
2. Props:父传子的数据通道
Props 是组件的"入参",类比 Java 方法的参数。
基础用法
<!-- 子组件 NoteCard.vue -->
<script setup lang="ts">
// defineProps 是编译器宏,不用 import
const props = defineProps<{
title: string
category: string
tags?: string[] // 可选
order?: number
}>()
console.log(props.title) // 父组件传过来的值
</script>
<template>
<div class="note-card">
<span>{{ category }}</span>
<h3>{{ title }}</h3>
<span v-for="t in tags" :key="t">{{ t }}</span>
</div>
</template>
父组件传参:
<NoteCard
title="ES6 核心语法"
category="前端基础"
:tags="['JS', 'ES6']"
:order="1"
/>
语法区别:
- 字符串常量:直接写
title="..."(像写 HTML 属性) - 任何表达式(数组、对象、变量):加
:前缀,值是 JS 表达式
Java 对照:
// 构造注入,参数名即字段名
NoteCard card = new NoteCard(
"ES6 核心语法", // title
"前端基础", // category
List.of("JS", "ES6"), // tags
1 // order
);
带默认值
const props = withDefaults(
defineProps<{
title: string
order?: number
tags?: string[]
}>(),
{
order: 999,
tags: () => [], // 引用类型的默认值必须用函数返回
}
)
注意:引用类型(数组、对象)的默认值必须包一层函数——否则所有实例共享同一个默认对象,改一个等于全改(和 Java 的 @Value 默认值不同,这是 JS 的坑)。
Props 是单向的 + 只读
// ❌ 不要改 props
props.title = '新标题' // TypeScript 报错 + 运行时警告
原因是 Vue 强制单向数据流:父改子看得见,子不能反过来改父。这样数据流清晰可追踪,不会出现"谁改了我的字段"的疑案。
想改的话:子组件里把 props 复制一份到 ref,或者通过 emit 通知父组件改。
3. Emit:子通知父的事件通道
Emit 是组件的"回调",类比 Java 的 ActionListener 或 Spring 的 ApplicationEventPublisher。
基础用法
<!-- 子组件 Counter.vue -->
<script setup lang="ts">
const emit = defineEmits<{
(e: 'change', value: number): void
(e: 'reset'): void
}>()
let count = 0
function handleClick() {
count++
emit('change', count) // 触发事件,附带值
}
function handleReset() {
count = 0
emit('reset')
}
</script>
<template>
<button @click="handleClick">+1</button>
<button @click="handleReset">重置</button>
</template>
父组件监听:
<Counter
@change="onCountChange"
@reset="onReset"
/>
<script setup lang="ts">
function onCountChange(value: number) {
console.log('子组件通知:', value)
}
function onReset() {
console.log('被重置了')
}
</script>
Java 对照:
// 观察者模式
class Counter {
private List<Consumer<Integer>> changeListeners = new ArrayList<>();
public void addChangeListener(Consumer<Integer> l) {
changeListeners.add(l);
}
private void fireChange(int v) {
changeListeners.forEach(l -> l.accept(v));
}
}
// 使用
counter.addChangeListener(v -> System.out.println("值:" + v));
Vue 把这套"注册监听器 + 触发"的流程简化成 @event="handler" 一行。
事件命名约定
- 定义用 camelCase(
changeCount) - 模板绑定时 kebab-case / camelCase 都行(
@change-count/@changeCount),推荐 kebab-case 和 HTML 属性风格保持一致
4. v-model:Props + Emit 的语法糖
表单输入最常见的需求:子组件的值变了,父组件跟着更新。手写要 props + emit 配合:
<!-- 子 -->
<script setup>
defineProps<{ modelValue: string }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
</script>
<template>
<input :value="modelValue" @input="emit('update:modelValue', $event.target.value)" />
</template>
<!-- 父 -->
<CustomInput v-model="text" />
父组件的 v-model="text" 自动展开成:
<CustomInput
:modelValue="text"
@update:modelValue="text = $event"
/>
这就是为什么 v-model 能用在自定义组件上——它本质就是 Props 下行 + Emit 上行的组合。
多个 v-model
<UserForm v-model:name="user.name" v-model:age="user.age" />
<!-- 子组件 UserForm.vue -->
<script setup>
defineProps<{ name: string; age: number }>()
defineEmits<{
(e: 'update:name', v: string): void
(e: 'update:age', v: number): void
}>()
</script>
5. 数据流原则
一张图:
父组件
│ Props(数据向下)
↓
子组件
│ Emit(事件向上)
↑
父组件(决定要不要改自己的状态)
核心原则:
- 数据向下:父用 Props 把数据传给子
- 事件向上:子用 Emit 通知父"发生了什么"
- 状态归父:共享状态放在最近的公共祖先,子组件只管自己的 UI
Java 对照:这和 Spring 的 依赖注入 + 事件驱动 模式几乎一样:
@Autowired把依赖注入下去(Props)ApplicationEventPublisher把事件发布出去(Emit)
6. 项目里的实例
src/pages/NoteDetailPage.vue 使用了 MarkdownRenderer 组件:
<MarkdownRenderer :html="note.html" />
- 父:传入
html字符串(Props 向下) - 子:不改这个字符串,只负责渲染,无事件往外抛(只读展示型组件)
这是最简单的"纯展示组件"。如果 MarkdownRenderer 还要提供"复制代码"功能并通知父组件,就会是:
<MarkdownRenderer
:html="note.html"
@copy="onCopyCode"
/>
7. 新手常见误区
误区 1:在子组件里改 props
<script setup>
const props = defineProps<{ count: number }>()
// ❌ 错:直接改 props
function add() { props.count++ }
// ✅ 对:emit 通知父改
const emit = defineEmits<{ (e: 'update:count', v: number): void }>()
function add() { emit('update:count', props.count + 1) }
</script>
误区 2:Props 传递过深(Props Drilling)
一层一层往下传同一个数据,中间组件明明用不到却要转发:
App → Layout → Sidebar → Menu → MenuItem → UserAvatar
每层都要写 :user="user",累且脆弱。解法:
- 跨层级共享:
provide / inject(后面笔记讲) - 全局状态:Pinia(第三阶段)
误区 3:把所有东西都做成组件
不是所有 UI 都要抽成组件。一个 20 行的 HTML 片段,留在页面里反而清楚。原则:
- 复用 ≥ 2 次 → 抽组件
- 单独测试更方便 → 抽组件
- 职责独立(如卡片、表单项) → 抽组件
- 否则留在页面里
CLAUDE.md 里的约定:"超过 200 行考虑拆分子组件"是个不错的经验值。
小练习:把首页的卡片抽成 NoteCard 组件
当前 src/pages/HomePage.vue 的卡片逻辑直接内联在模板里。练习如下:
- 新建
src/components/NoteCard.vue - Props 接收
note: NoteListItem和viewMode: 'grid' | 'list' - 把
HomePage.vue里<RouterLink v-for=...>的内容搬进去 - 父组件传入:
<NoteCard v-for="note in filteredNotes" :key="note.slug" :note="note" :view-mode="viewMode" />
做完对照:卡片的"变尖角颜色"现在要怎么改?在 NoteCard.vue 里改一处就行,而不用翻 HomePage.vue 那 100 多行模板。这就是组件化的价值。
这个练习本身就是"第二阶段的产品落地"——把学到的组件知识用在 in4vue 上。