Skip to content
第 116 / 250 章前端⏱ 10 分钟阅读

第 116 章:组件基础

学习目标

  • 理解 Vue 3 组件化思维
  • 掌握组件的注册与使用
  • 学会 props、emits 的基础用法
  • 理解组件通信的基本模式

一、什么是组件

组件是可复用的 Vue 实例,每个组件有自己独立的模板、逻辑、样式。

1.1 一个最简单的组件

vue
<!-- components/HelloWorld.vue -->
<script setup lang="ts">
defineProps<{ msg: string }>();
</script>

<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
  </div>
</template>
vue
<!-- App.vue -->
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue';
</script>

<template>
  <HelloWorld msg="欢迎使用 Vue 3" />
</template>

二、组件注册

2.1 局部注册

vue
<script setup lang="ts">
import ComponentA from './ComponentA.vue';
import ComponentB from './ComponentB.vue';
</script>

<template>
  <ComponentA />
  <ComponentB />
</template>

2.2 全局注册(不推荐)

typescript
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import GlobalComponent from './components/GlobalComponent.vue';

const app = createApp(App);
app.component('GlobalComponent', GlobalComponent);
app.mount('#app');

提示

全局注册会让组件在所有地方可用,但不利于 tree-shaking 和类型推断。Vue 3 推荐局部注册

2.3 自动注册(推荐)

使用 unplugin-vue-components 实现按需自动注册:

typescript
// vite.config.ts
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';

export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [ElementPlusResolver()]
    })
  ]
});

三、Props 父传子

3.1 基础用法

vue
<!-- Child.vue -->
<script setup lang="ts">
// 方式 1:用 defineProps 宏
const props = defineProps<{
  title: string;
  count?: number;
  items: string[];
}>();

console.log(props.title);
</script>

<template>
  <div>{{ title }}</div>
</template>
vue
<!-- Parent.vue -->
<script setup lang="ts">
import Child from './Child.vue';
</script>

<template>
  <Child title="Hello" :count="10" :items="['a', 'b']" />
</template>

3.2 类型定义

typescript
// 写法 1:纯类型
defineProps<{
  msg: string;
  count?: number;
}>();

// 写法 2:运行时 + 类型
const props = defineProps({
  msg: String,
  count: {
    type: Number,
    default: 0
  }
});

3.3 默认值

typescript
// 用 withDefaults
interface Props {
  msg?: string;
  count?: number;
  items?: string[];
}

const props = withDefaults(defineProps<Props>(), {
  msg: '默认值',
  count: 0,
  items: () => []  // 对象/数组默认值用工厂函数
});

3.4 单向数据流

vue
<!-- ❌ 错误:不要直接修改 props -->
<script setup>
const props = defineProps<{ count: number }>();
props.count++;  // ❌ 警告
</script>

<!-- ✅ 正确:用本地 ref 包装 -->
<script setup>
const props = defineProps<{ count: number }>();
const localCount = ref(props.count);
</script>

四、Emits 子传父

4.1 基础用法

vue
<!-- Child.vue -->
<script setup lang="ts">
const emit = defineEmits<{
  increment: [count: number];
  change: [value: string];
}>();

const handleClick = () => {
  emit('increment', 1);
  emit('change', 'new value');
};
</script>

<template>
  <button @click="handleClick">+</button>
</template>
vue
<!-- Parent.vue -->
<script setup lang="ts">
import Child from './Child.vue';

const handleIncrement = (count: number) => {
  console.log('子组件 +1', count);
};
</script>

<template>
  <Child @increment="handleIncrement" />
</template>

4.2 带校验

typescript
const emit = defineEmits({
  submit: (payload: { id: number }) => {
    return typeof payload.id === 'number';
  },
  cancel: null
});

五、组件命名

5.1 PascalCase(推荐)

typescript
import TodoItem from './TodoItem.vue';
vue
<TodoItem />

5.2 kebab-case(模板中)

vue
<todo-item />

PascalCase 在 JS 中使用,kebab-case 在模板中使用。

5.3 文件命名

风格示例推荐
PascalCaseTodoItem.vue
kebab-casetodo-item.vue

六、组件复用:插槽

6.1 默认插槽

vue
<!-- Card.vue -->
<template>
  <div class="card">
    <slot>默认内容</slot>
  </div>
</template>
vue
<Card>
  <p>这是卡片内容</p>
</Card>

6.2 具名插槽

vue
<!-- Layout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header">默认头部</slot>
    </header>
    <main>
      <slot />
    </main>
    <footer>
      <slot name="footer" />
    </footer>
  </div>
</template>
vue
<Layout>
  <template #header>
    <h1>标题</h1>
  </template>

  <p>主体内容(默认插槽)</p>

  <template #footer>
    <p>版权信息</p>
  </template>
</Layout>

6.3 作用域插槽

vue
<!-- List.vue -->
<script setup lang="ts">
interface User { id: number; name: string }
defineProps<{ users: User[] }>();
</script>

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <slot :user="user" :index="user.id">
        {{ user.name }}
      </slot>
    </li>
  </ul>
</template>
vue
<List :users="userList">
  <template #default="{ user, index }">
    <span>{{ index }}. {{ user.name }}</span>
  </template>
</List>

七、动态组件

7.1 component 元素

vue
<script setup lang="ts">
import { ref } from 'vue';
import Home from './Home.vue';
import About from './About.vue';

const current = ref('Home');
const components = { Home, About };
</script>

<template>
  <button @click="current = 'Home'">Home</button>
  <button @click="current = 'About'">About</button>

  <component :is="components[current]" />
</template>

7.2 KeepAlive 缓存

vue
<template>
  <KeepAlive>
    <component :is="currentComponent" />
  </KeepAlive>
</template>

八、组件 v-model

8.1 单个 v-model

vue
<!-- CustomInput.vue -->
<script setup lang="ts">
defineProps<{ modelValue: string }>();
defineEmits<{ 'update:modelValue': [value: string] }>();
</script>

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
  />
</template>
vue
<CustomInput v-model="searchText" />

8.2 多个 v-model

vue
<!-- UserForm.vue -->
<script setup lang="ts">
defineProps<{ name: string; age: number }>();
defineEmits<{
  'update:name': [value: string];
  'update:age': [value: number];
}>();
</script>

<template>
  <input :value="name" @input="$emit('update:name', $event.target.value)" />
  <input :value="age" type="number" @input="$emit('update:age', +$event.target.value)" />
</template>
vue
<UserForm v-model:name="userName" v-model:age="userAge" />

九、实战案例:Todo 应用

9.1 TodoInput.vue

vue
<script setup lang="ts">
import { ref } from 'vue';

const emit = defineEmits<{ add: [text: string] }>();

const text = ref('');

const submit = () => {
  if (!text.value.trim()) return;
  emit('add', text.value);
  text.value = '';
};
</script>

<template>
  <input v-model="text" @keyup.enter="submit" placeholder="添加待办" />
  <button @click="submit">添加</button>
</template>

9.2 TodoItem.vue

vue
<script setup lang="ts">
interface Todo {
  id: number;
  text: string;
  done: boolean;
}

const props = defineProps<{ todo: Todo }>();
const emit = defineEmits<{
  toggle: [id: number];
  remove: [id: number];
}>();
</script>

<template>
  <li :class="{ done: todo.done }">
    <input
      type="checkbox"
      :checked="todo.done"
      @change="emit('toggle', todo.id)"
    />
    <span>{{ todo.text }}</span>
    <button @click="emit('remove', todo.id)">×</button>
  </li>
</template>

9.3 TodoApp.vue(根组件)

vue
<script setup lang="ts">
import { ref } from 'vue';
import TodoInput from './TodoInput.vue';
import TodoItem from './TodoItem.vue';

interface Todo {
  id: number;
  text: string;
  done: boolean;
}

const todos = ref<Todo[]>([]);

const add = (text: string) => {
  todos.value.push({
    id: Date.now(),
    text,
    done: false
  });
};

const toggle = (id: number) => {
  const todo = todos.value.find((t) => t.id === id);
  if (todo) todo.done = !todo.done;
};

const remove = (id: number) => {
  todos.value = todos.value.filter((t) => t.id !== id);
};
</script>

<template>
  <div class="todo-app">
    <h1>Todo App</h1>
    <TodoInput @add="add" />
    <ul>
      <TodoItem
        v-for="todo in todos"
        :key="todo.id"
        :todo="todo"
        @toggle="toggle"
        @remove="remove"
      />
    </ul>
  </div>
</template>

十、常见错误

10.1 组件名错误

typescript
// ❌ 引入文件名,不是组件名
import todoItem from './TodoItem.vue';  // 文件名是 TodoItem

// ✅ 文件名和组件名一致即可
import TodoItem from './TodoItem.vue';

10.2 props 整体赋值

vue
<!-- ❌ 不能直接赋值 props -->
<script setup>
const props = defineProps<{ obj: { count: number } }>();
props.obj = { count: 1 };  // ❌
</script>

10.3 组件未导入

vue
<template>
  <UserCard />  <!-- ❌ 没 import -->
</template>

十一、本章小结

要点关键
组件可复用的 Vue 实例,单文件 .vue
注册局部(推荐) / 全局 / 自动
Props父传子,defineProps
Emits子传父,defineEmits
插槽默认 / 具名 / 作用域
动态组件<component :is="...">
v-modelmodelValue + update:modelValue

动手练习

  1. 拆分组件:把"用户卡片"拆分为 Avatar、Info、Actions 三个组件
  2. Props/Emits:实现一个 Modal 组件,父组件控制显示/隐藏
  3. 插槽:写一个 Layout 组件,支持 header/main/footer 插槽
  4. 动态组件:实现一个 Tab 切换,使用 <component :is>

推荐阅读


下一章第 117 章:Props 与 Emits 深入

本站基于 VitePress 构建 · 由 Codebook 团队维护