Learn
Vue/03-components-and-props

组件与 props

React 用「函数接收 props 返回 JSX」来定义组件。Vue 的组件是一个 单文件组件(Single File Component,.vue 文件),把模板、逻辑、样式收在一个文件里:

<script setup lang="ts">
import { ref } from 'vue';
 
const count = ref(0);
</script>
 
<template>
  <button @click="count++">点了 {{ count }} 次</button>
</template>
 
<style scoped>
button { color: teal; }
</style>

lang="ts" 开启 TypeScript;<style scoped> 让样式只作用于当前组件。这种「三块合一」的结构是 Vue 的标志性写法。

1. <script setup> 的编译结果

<script setup> 是语法糖,编译器会把它编译成一个 setup() 函数,顶层声明的变量和函数自动暴露给模板:

<script setup>
const msg = "hi";
function greet() { return msg; }
</script>
 
<template>
  <p>{{ msg }}</p>
  <p>{{ greet() }}</p>
</template>

你不需要手写 return { msg, greet }。编译后大致是:

setup(__props, { expose }) {
  const msg = "hi";
  const greet = () => msg;
  return { msg, greet, ... }; // 编译器自动帮你 return
}

这就是为什么 <script setup> 里写的东西能直接在模板里用——省掉了手动导出这一步。

2. props:父传子的数据

2.1 类型化声明

<script setup lang="ts">
interface Props {
  title: string;
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
}
 
const props = defineProps<Props>();
</script>
 
<template>
  <button :class="size" :disabled="disabled">{{ title }}</button>
</template>

defineProps<Props>() 是编译期宏,直接吃一个类型,模板里就能用 props.title。注意:它是类型层面的,运行时没有这个对象(类型被擦除),是纯类型约束。

2.2 默认值与响应式解构

给 props 设默认值,3.5+ 推荐用响应式 props 解构:

<script setup lang="ts">
interface Props {
  title: string;
  size?: "sm" | "md" | "lg";
}
 
// 解构的同时给默认值,且 size 仍然是响应式的
const { title, size = "md" } = defineProps<Props>();
</script>
 
<template>
  <button :class="size">{{ title }}</button>
</template>

旧版本(3.4 及以前)要用 withDefaults:

const props = withDefaults(defineProps<Props>(), {
  size: "md",
  disabled: false,
});
ℹ️props 是只读的,单向数据流

父组件传给子组件的 props,子组件不能修改。这是 Vue 和 React 一样的「单向数据流」原则:数据从父流向子,子想改,应该通过事件通知父组件去改(见第 4 章的 defineEmits 和第 10 章的 v-model)。

如果你在子组件里写 props.size = "lg",开发模式下 Vue 会警告。

确实需要在子组件里「持有」一个基于 props 的本地状态时,正确的做法是复制一份到 ref:

const props = defineProps<{ initialCount: number }>();
const count = ref(props.initialCount); // 之后改 count,不影响父的 initialCount

3. 子传父:emits

<script setup lang="ts">
interface Emits {
  (e: "change", id: number): void;
  (e: "submit", payload: { name: string }): void;
}
 
const emit = defineEmits<Emits>();
 
function onClick() {
  emit("change", 42);
}
</script>
 
<template>
  <button @click="onClick">通知父组件</button>
</template>

父组件监听:

<template>
  <Child @change="handleChange" />
</template>

defineEmits 用函数签名式声明事件名和载荷类型,类型推导非常干净。

4. 插槽:组件的内容分发

当需要让父组件往子组件里「塞一段自己的模板」时,用插槽。

4.1 默认插槽与具名插槽

<!-- Card.vue -->
<template>
  <div class="card">
    <header><slot name="header">默认标题</slot></header>
    <main><slot>默认内容</slot></main>
    <footer><slot name="footer" /></footer>
  </div>
</template>
 
<!-- 父组件 -->
<Card>
  <template #header>我的标题</template>
  主体内容(进默认插槽)
  <template #footer>页脚</template>
</Card>

#header 是 v-slot:header 的简写。

4.2 作用域插槽

子组件可以把自己的数据「反向传回」给插槽内容:

<!-- List.vue -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot name="item" :row="item">{{ item.name }}</slot>
    </li>
  </ul>
</template>
 
<!-- 父组件可完全自定义每一行的渲染 -->
<List :items="users">
  <template #item="{ row }">
    <span :class="{ off: !row.active }">{{ row.name }}({{ row.age }} 岁)</span>
  </template>
</List>

#item="{ row }" 解构出子组件通过 :row="item" 传出来的数据。这是 Vue 做「通用列表/表格组件」的核心机制。

5. 透传属性与多根节点

子组件没有显式声明 props / emits 的属性(如 class、id、自定义 data-*),会**自动透传(fallthrough)**到根元素上:

<!-- 父 -->
<MyButton class="big" data-testid="ok" />
 
<!-- MyButton 根元素会自动带上 class="big" data-testid="ok" -->

如果组件有多个根节点,Vue 不知道透传属性该放哪,必须手动指定:

<template>
  <header v-bind="$attrs">...</header>
  <main>...</main>
</template>

inheritAttrs: false + 手动 v-bind="$attrs" 是控制透传行为的标准做法。

6. 组件注册

<script setup> 里导入即注册——只要 import 了一个组件,就能在模板里直接用,不用额外声明:

<script setup>
import Child from './Child.vue';
</script>
 
<template>
  <Child />
</template>

全局注册(在 main.ts 里 app.component('Child', Child))则用在不方便局部导入的场景,配合 unplugin-vue-components 还能做到「用到才自动导入、自动注册」。

🎯练习 1:设计组件契约

为一个通用的 <DataTable> 组件设计 props / emits / slots 的类型声明。需求:传入行数据数组 rows(元素类型泛型 T)、列定义 columns(含 key、标题、是否可排序)、当前排序列 sortKey 与方向 sortDir;暴露 sort-change 事件(载荷为新的 key 与方向);提供默认插槽放表格内容、具名插槽 cell 让父组件自定义每个单元格(能拿到当前行 row 与列 col)。

🎯练习 2:找出直接改 props 的写法

下面这段代码有 4 处「在子组件里直接改 props」的写法,逐一指出,并分别给出用 emit / defineModel(第 10 章)/ 本地副本 的修复方式。

<script setup>
const props = defineProps<{ count: number; user: { name: string }; list: string[]; visible: boolean }>();
function add() { props.count++; }                       // ①
function rename() { props.user.name = "new"; }           // ②
function pushItem() { props.list.push("x"); }            // ③
function hide() { props.visible = false; }               // ④
</script>
🎯练习 3:用作用域插槽改造死板列表

有一个写死的列表组件:

<template>
  <ul>
    <li v-for="u in users" :key="u.id">{{ u.name }} - {{ u.email }}</li>
  </ul>
</template>

请把它改造成「可完全自定义每一行渲染」的通用列表:父组件能拿到每行 user 决定怎么渲染。

小结

  • .vue 单文件组件 = 模板 + <script setup> + 可选的 scoped 样式,是 Vue 的标志性结构
  • <script setup> 顶层绑定自动暴露给模板,省去手写 return
  • defineProps<Props>() 吃类型做约束;默认值用响应式 props 解构(const { x = d } = defineProps())或旧版 withDefaults
  • props 只读、单向数据流;子改父数据要靠 emit 或 defineModel
  • defineEmits<Emits>() 用函数签名声明事件与载荷类型
  • 插槽分默认/具名/作用域三种;作用域插槽把子数据反向传回,用于通用列表/表格
  • 多根节点时透传属性要手动 v-bind="$attrs";inheritAttrs: false 控制行为
  • 下一章我们讲响应式状态 ref/reactive 与事件,并亲手写一个迷你 ref →