组件基础: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 行考虑拆分子组件"是个不错的经验值。


小练习:把首页的卡片抽成 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 上。


延伸阅读