组件基础: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 行考虑拆分子组件"是个不错的经验值。
8. 原理深挖:组件其实是一个 VNode 类型
模板里写的 <NoteCard /> 编译后是什么?答案是一个特殊类型的 VNode:
// <NoteCard :title="x" @change="y" /> 编译后
createVNode(NoteCard, { title: _ctx.x, onChange: _ctx.y })
// 对比普通 div
createVNode('div', { class: 'foo' })
关键差异:VNode 的 type 字段——字符串('div')就是普通 DOM,是对象/函数就是组件。Vue 在 patch 阶段碰到组件 VNode 时走另一套流程:
patch(oldVNode, newVNode)
│
├─ type 是字符串 → 直接操作 DOM(创建/更新/删除元素)
│
└─ type 是组件对象
├─ 创建组件实例 instance(一个普通对象,存 props/setup state/render fn 等)
├─ 跑 instance.setup(),拿到 setupState
├─ 创建 ReactiveEffect 包住 render 函数(响应式渲染)
├─ 跑一次 render() → 拿到组件内部的 VNode 子树
└─ 递归 patch 子树
Java 对照:组件就像 Spring 的 @Component——容器看到 @Autowired NoteCard 时不直接 new,而是先实例化 + 初始化字段 + AOP 增强;Vue 看到组件 VNode 时也是先建实例、跑 setup、再渲染。
9. 原理深挖:组件实例 = ComponentInternalInstance
每个组件挂载时 Vue 创建一个实例对象(不是 ES 类的实例,是个普通对象)记录所有运行时状态:
// 简化的组件实例结构
{
uid: 1, // 自增 id(patch 排序用)
type: NoteCard, // 组件定义对象
parent: <父组件实例>,
vnode: <当前组件的 VNode>,
subTree: <render 出来的子 VNode 树>,
// setup 相关
setupState: { count: ref(0) }, // <script setup> 暴露的所有变量
props: { title: 'x' }, // 处理后的 props(响应式)
attrs: { ... }, // 没声明在 props 里的多余属性
slots: { default: ..., footer: ... },
emit: fn,
expose: { ... }, // defineExpose 暴露的 API
// 生命周期钩子(数组)
m: [onMounted hooks],
bm: [onBeforeMount hooks],
u: [onUpdated hooks],
bum: [onBeforeUnmount hooks],
um: [onUnmounted hooks],
// 渲染相关
render: fn, // 编译产出的 render 函数
effect: ReactiveEffect, // 包住 render 的响应式 effect
update: fn, // 触发重渲染
// 上下文
appContext: { ... }, // 应用级(app.use 注册的全局)
provides: { ... }, // 沿父链传下来的 provide 值
}
getCurrentInstance() 返回的就是这个对象。<script setup> 里没法直接访问——不是不存在,是 Vue 故意不暴露(鼓励用 ref/computed/inject 等高层 API)。
这就是为什么 onMounted 必须写在 setup 同步代码里——onMounted 内部其实是 getCurrentInstance().m.push(hook),必须有"当前实例"才行:
function onMounted(hook) {
const instance = currentInstance
if (!instance) {
warn('onMounted is called when there is no active component instance')
return
}
instance.m = instance.m || []
instance.m.push(hook)
}
currentInstance 是个全局变量,在 setup 跑之前 setCurrentInstance(instance)、跑完 unsetCurrentInstance()。所以 setup 里的同步代码能拿到 instance,异步代码(await 之后)就拿不到——除非手动用 effectScope。
10. 原理深挖:props 不是普通对象,是个 Proxy
<script setup>
const props = defineProps<{ title: string }>()
console.log(props.title) // 能读
props.title = 'x' // ❌ 警告:Set operation on key "title" failed: target is readonly
const { title } = props // ❌ 解构丢响应式
const titleRef = toRef(props, 'title') // ✅ 保持响应式
</script>
为什么能 detect 这些?因为 props 对象是 Vue 包过的 shallowReactive Proxy + readonly 拦截:
// 简化版
const propsProxy = new Proxy(rawProps, {
get(target, key) {
track(target, key) // 收集依赖(这就是为什么父改了 props 子组件会重渲染)
return target[key]
},
set() {
warn('Set operation on key xxx failed: target is readonly')
return false // 拒绝写入
}
})
几个关键点:
- props 是响应式的——父组件改了 prop 值,子组件读 props.xxx 的依赖会被触发,子组件 render 重跑
- props 是只读的——子组件想改值会被静默拒绝(开发环境警告)
- 解构丢响应式——Proxy 只能拦截属性访问,
const { title } = props把当前值赋给一个普通变量,再读这个变量不经过 Proxy toRef(props, 'title')创建一个特殊 ref,每次访问.value都重新读props.title,等于"延迟读取"
Java 对照:类似 Spring 的 @Value 字段——容器注入后 final 的,运行时变更走 @RefreshScope 重新创建 Bean。Vue 是"props 引用稳定,但里面的值每次重新读"。
11. 原理深挖:emit 的本质 = 调父组件传下来的函数
<!-- 父 -->
<Counter @change="handler" />
编译后:
createVNode(Counter, { onChange: _ctx.handler })
子组件的 emit 实现:
function emit(instance, event, ...args) {
// event = 'change' → 'onChange'
const handlerName = 'on' + capitalize(event)
const handler = instance.vnode.props[handlerName]
if (handler) handler(...args)
}
就这么简单——没有事件总线、没有 PubSub,只是把"父传给子的回调函数"反过来调一下。所以:
- emit 是同步的(除非 handler 内部用 await)
- emit 不会冒泡(不像 DOM 事件能冒到祖先)——它只调当前组件 VNode 上挂的回调
- 想跨层级通信只能用 provide/inject 或 Pinia
v-model 实际上就是 emit 的特例——子组件 emit('update:modelValue', v),父组件的 v-model 自动给子组件传了个 onUpdate:modelValue handler。
Java 对照:emit 不像 Spring 的 ApplicationEventPublisher(那是真广播),更像传一个 Consumer<T> 给方法用——单一接收者、同步、显式。
12. 原理深挖:style scoped 不是真隔离
<style scoped>
.note-card { color: red; }
</style>
编译后:
.note-card[data-v-7c8a3f] { color: red; }
template 里所有元素的 attribute 上加一个相同的 hash:
<div class="note-card" data-v-7c8a3f>...</div>
这是基于 attribute selector 的"假隔离"——本质还是全局 CSS,只是选择器变长了,不会和别处冲突。
几个推论:
- 不是真的不能影响子组件——子组件根元素也带父的 hash,所以父能改子的根元素样式
- 深度选择器
:deep()—— 想穿透到子组件内部时用:.parent :deep(.child-class) { ... } /* 编译后:.parent[data-v-xxx] .child-class { ... } —— hash 只挂在 parent 上 */ - 全局样式
:global()—— 跳出 scoped::global(.dark-theme button) { ... } - CSS 模块化的另一种方案
<style module>—— 真的把类名 hash 化:<template><div :class="$style.card">...</div></template> <style module> .card { color: red; } /* 实际类名变成 _card_x9z2a */ </style>
Java 对照:scoped 像 Spring 的 @Qualifier——同名 Bean 通过额外标识区分,不是真物理隔离;module 像 OSGi 的 bundle——真隔离命名空间。
13. 原理深挖:defineProps / defineEmits 是编译期宏,不是运行时函数
注意这俩没有 import:
<script setup>
const props = defineProps<{ title: string }>() // 没 import
</script>
它们是 <script setup> 编译器识别的关键字——编译时被替换成真正的运行时调用:
// 编译前
const props = defineProps<{ title: string }>()
// 编译后(简化)
const props = __props
// 而组件定义里:
{
props: { title: { type: String, required: true } },
setup(__props) { ... }
}
所以:
- 不能动态构造类型:
defineProps<DynamicType>()必须 DynamicType 在编译期可知 - 不能在条件里调:
if (x) defineProps(...)编译器会报错 - 不能 import 别名:
import { defineProps as dp } from 'vue'没用,宏是关键字级识别
Java 对照:类似 Lombok 的 @Data——编译期处理 AST,运行时根本不存在 defineProps 这个调用。
Vue 3.3+ 还有 defineModel(v-model 简写)和 defineExpose(控制 ref 拿到的实例 API),都是同一类编译器宏。
14. 原理深挖:组件渲染的依赖追踪边界
一个常被忽略的细节:props 变化只触发用到该 prop 的子组件重渲染,不会牵连兄弟。
<template>
<Child1 :a="x" />
<Child2 :b="y" />
</template>
1. 父组件 render effect 跑:
├─ 访问 _ctx.x → x.dep.add(父 render effect)
└─ 访问 _ctx.y → y.dep.add(父 render effect)
2. Child1 自己也是个 render effect:
└─ 跑时访问 props.a → a.dep.add(Child1 render effect)
(a 是 props 的内部 ref,由父的 x 派生)
3. 改 x.value:
├─ 触发父 render effect → 父重新生成 vnode 树
├─ patch 时给 Child1 传新 props.a
├─ 触发 Child1 内部的 a.dep → Child1 重渲染
└─ Child2 完全不受影响
这就是为什么 Vue 的更新粒度是"组件级"——每个组件都有自己的 render effect 作为依赖单元。Pinia store 的某个字段改了,只有真正读它的组件会重渲染,没读的组件即使被 Pinia store 引用也不动。
Java 对照:类似 Spring 的 @RefreshScope —— 配置变化只重新创建用到该配置的 Bean。但 Vue 这一切都是自动的,不用任何注解。
小练习:把首页的卡片抽成 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 上。