组件基础:Props 向下,Emit 向上

组件是什么

一句话:

组件 = HTML + CSS + JS 打包成一个可复用的小模块。

Java 对照

一个 .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>

关键点

Java 对照

// 父 Controller 调用子 Service
@Autowired
private NoteService noteService;
// 使用:noteService.getList()

区别:Vue 组件的"调用"是在模板里像 HTML 标签一样写,不是函数调用。

项目里约定


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"
/>

语法区别

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" 一行。

事件命名约定


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(事件向上)
  ↑
父组件(决定要不要改自己的状态)

核心原则

  1. 数据向下:父用 Props 把数据传给子
  2. 事件向上:子用 Emit 通知父"发生了什么"
  3. 状态归父:共享状态放在最近的公共祖先,子组件只管自己的 UI

Java 对照:这和 Spring 的 依赖注入 + 事件驱动 模式几乎一样:


6. 项目里的实例

src/pages/NoteDetailPage.vue 使用了 MarkdownRenderer 组件:

<MarkdownRenderer :html="note.html" />

这是最简单的"纯展示组件"。如果 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",累且脆弱。解法

误区 3:把所有东西都做成组件

不是所有 UI 都要抽成组件。一个 20 行的 HTML 片段,留在页面里反而清楚。原则

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                 // 拒绝写入
  }
})

几个关键点

  1. props 是响应式的——父组件改了 prop 值,子组件读 props.xxx 的依赖会被触发,子组件 render 重跑
  2. props 是只读的——子组件想改值会被静默拒绝(开发环境警告)
  3. 解构丢响应式——Proxy 只能拦截属性访问,const { title } = props 把当前值赋给一个普通变量,再读这个变量不经过 Proxy
  4. 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,只是把"父传给子的回调函数"反过来调一下。所以:

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,只是选择器变长了,不会和别处冲突。

几个推论

  1. 不是真的不能影响子组件——子组件根元素也带父的 hash,所以父能改子的根元素样式
  2. 深度选择器 :deep() —— 想穿透到子组件内部时用:
    .parent :deep(.child-class) { ... }
    /* 编译后:.parent[data-v-xxx] .child-class { ... } —— hash 只挂在 parent 上 */
    
  3. 全局样式 :global() —— 跳出 scoped:
    :global(.dark-theme button) { ... }
    
  4. 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) { ... }
}

所以

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 的卡片逻辑直接内联在模板里。练习如下:

  1. 新建 src/components/NoteCard.vue
  2. Props 接收 note: NoteListItemviewMode: 'grid' | 'list'
  3. HomePage.vue<RouterLink v-for=...> 的内容搬进去
  4. 父组件传入:<NoteCard v-for="note in filteredNotes" :key="note.slug" :note="note" :view-mode="viewMode" />

做完对照:卡片的"变尖角颜色"现在要怎么改?在 NoteCard.vue 里改一处就行,而不用翻 HomePage.vue 那 100 多行模板。这就是组件化的价值。

这个练习本身就是"第二阶段的产品落地"——把学到的组件知识用在 in4vue 上。


延伸阅读