Vue.js从零到精通系列(三):组件化基础——Props、Emits、插槽与生命周期

摘要: 组件化是Vue的灵魂,也是现代前端开发的基石。前两篇我们掌握了响应式数据和模板语法,本篇将正式踏入组件化的世界。我们将从"为什么需要组件"讲起,逐步拆解父子组件通信的两大支柱------Props(父传子)和Emits(子传父),并学习如何使用TypeScript为它们添加编译时的安全保障。随后深入Vue强大的插槽系统(默认插槽、具名插槽、作用域插槽),理解内容分发与作用域穿透的精妙设计。最后,我们将全面掌握组件生命周期的各个阶段,知道"什么时候该做什么事"。通过升级版的待办事项应用,你将亲手把这些知识整合成一个结构清晰、职责分明的组件树。


一、组件:前端开发的"乐高积木"

1.1 什么是组件?

如果把网页比作一栋房子,组件就是砖、门窗和家具。组件是一个独立、可复用、可组合的UI单元,它封装了自己的模板、逻辑和样式。当你需要一扇窗户时,不是重新画一遍,而是直接拿一个"窗户组件"装上去,并告诉它尺寸、颜色(Props),它也能告诉你何时被打开(Emits)。

1.2 为什么需要组件化?

你已经写过一个Todo应用,所有代码挤在一个App.vue里。当应用扩大到几十个页面、数百个功能时,单文件将变得不可维护。组件化带来三大好处:

  • 复用性:一个按钮组件可以在多处使用,一处修改全局更新。

  • 隔离性:每个组件的样式和逻辑互不污染,出了问题容易定位。

  • 可测试性:独立的小组件单元测试更容易编写。

1.3 Vue单文件组件的三件套

上一篇我们已经见过.vue文件,它由三个块组成:

复制代码
<template>   ← 结构(HTML)
<script>     ← 逻辑(JS/TS)
<style>      ← 样式(CSS)

这种高内聚的设计让你在修改一个组件时,所有相关内容都在一个文件里,不再在HTML/JS/CSS三个文件间来回跳转。


二、组件注册:全局 vs 局部

在Vue 3中,组件有两种注册方式。我们先从项目结构入手,创建一个components文件夹。

2.1 局部注册:按需引入

局部注册是推荐做法,配合<script setup>可以极其简洁地使用:

javascript 复制代码
<script setup lang="ts">
import MyButton from './components/MyButton.vue'
import MyInput from './components/MyInput.vue'
</script>
​
<template>
  <MyButton />
  <MyInput />
</template>

导入后,直接在模板中使用,无需任何额外注册步骤。<script setup>的编译魔法会在背后处理一切。如果未使用setup语法糖,则需要在components选项中显式注册。

组件命名规范

  • 在模板中,我们使用PascalCase(大驼峰)<MyButton>或kebab-case(短横线)<my-button>都可以。

  • 在JavaScript/TypeScript中导入时,通常用PascalCase与构造函数区分,也符合ES模块的命名习惯。

2.2 全局注册:全局可用

某些高频使用的组件(如通用图标、模态框)可以全局注册。在main.ts中:

javascript 复制代码
import { createApp } from 'vue'
import App from './App.vue'
import GlobalIcon from './components/GlobalIcon.vue'
​
const app = createApp(App)
app.component('GlobalIcon', GlobalIcon)
app.mount('#app')

现在所有组件中都可以直接使用<GlobalIcon />,无需导入。但全局注册有副作用:即使不用该组件,它也会被打包进来,影响Tree Shaking。所以非必要不全局注册


三、父传子 Props:让组件"可配置"

Props是组件的输入。它定义了从父组件传递数据给子组件的接口。使用Props,同一个组件可以根据不同输入展现出不同形态。

3.1 Props的基础定义

在子组件中,使用defineProps定义props:

javascript 复制代码
<!-- UserCard.vue -->
<script setup lang="ts">
const props = defineProps({
  name: String,
  age: Number,
  isActive: Boolean
})
</script>
​
<template>
  <div class="user-card" :class="{ active: isActive }">
    <h3>{{ name }}</h3>
    <p>年龄:{{ age }}</p>
  </div>
</template>

父组件传入:

javascript 复制代码
<UserCard name="张三" :age="25" :is-active="true" />

注意:非字符串类型的prop需要用v-bind(即冒号)传递,否则会当作字符串。

3.2 使用TypeScript纯类型声明

有了TypeScript,我们可以使用更简洁的纯类型语法:

javascript 复制代码
<script setup lang="ts">
interface Props {
  name: string
  age?: number          // 可选
  isActive?: boolean
  tags?: string[]       // 数组类型
  config?: {            // 对象类型
    showAvatar: boolean
    size: 'small' | 'medium' | 'large'
  }
}
​
const props = withDefaults(defineProps<Props>(), {
  age: 0,
  isActive: false,
  tags: () => []        // 数组/对象默认值必须用工厂函数
})
</script>

defineProps加上泛型<Props>,TypeScript会在编译时检查父组件传入的prop类型是否匹配。withDefaults为可选prop设置默认值,对于数组和对象,必须使用箭头函数返回,避免共享引用。

这就是静态类型的威力:当你传入错误的类型,开发环境会立刻报错,减少运行时bug。

3.3 Props的单向数据流

Vue强调单向数据流:数据从父组件流向子组件,子组件不应直接修改props。这确保了数据流的可预测性。

javascript 复制代码
<script setup lang="ts">
const props = defineProps<{ count: number }>()
​
// ❌ 不要这样做!
function badIncrement() {
  props.count++  // 报错!props是只读的
}
</script>

如果子组件需要基于prop进行计算或转换,有两种正确做法:

使用计算属性做派生

javascript 复制代码
import { computed } from 'vue'
const doubleCount = computed(() => props.count * 2)

将prop作为本地数据的初始值

javascript 复制代码
import { ref } from 'vue'
const localCount = ref(props.count)
// 此后操作localCount,不影响父组件

3.4 运行时类型校验

除了TypeScript的编译时检查,Vue还支持运行时校验,提供更详细的警告:

javascript 复制代码
defineProps({
  status: {
    type: String as PropType<'active' | 'inactive' | 'pending'>,
    required: true,
    validator: (value: string) => ['active', 'inactive', 'pending'].includes(value)
  }
})

如果父组件传入非法值,控制台会输出明确的警告信息,帮助调试。


四、子传父 Emits:让组件"会说话"

Props解决父→子通信,Emits解决子→父通信。子组件通过触发事件向父组件发送消息,就像孩子喊"爸爸,按钮被点了!"

4.1 基本用法

在子组件中,使用defineEmits声明事件,然后调用emit函数:

javascript 复制代码
<!-- CounterButton.vue -->
<script setup lang="ts">
const emit = defineEmits(['increase', 'decrease'])
​
function handleIncrease() {
  emit('increase')
}
function handleDecrease() {
  emit('decrease')
}
</script>
​
<template>
  <div>
    <button @click="handleDecrease">-</button>
    <button @click="handleIncrease">+</button>
  </div>
</template>

父组件监听事件,就像处理原生DOM事件:

javascript 复制代码
<CounterButton @increase="total++" @decrease="total--" />
<p>总计:{{ total }}</p>

4.2 带参数的事件

事件可以携带数据:

javascript 复制代码
<!-- 子组件 -->
<script setup lang="ts">
const emit = defineEmits<{
  (e: 'submit', value: string, id: number): void
}>()
​
function onSubmit() {
  emit('submit', 'hello', 123)
}
</script>
<!-- 父组件 -->
<ChildComponent @submit="handleSubmit" />
​
<script setup lang="ts">
function handleSubmit(value: string, id: number) {
  console.log(value, id)
}
</script>

使用TypeScript泛型声明事件,父组件的回调参数类型会自动推断,获得完整的智能提示。

4.3 v-model在组件上的双向绑定

v-model本质是props + emits的语法糖。默认情况下,组件上的v-model使用modelValue作为prop,update:modelValue作为事件。

子组件:

javascript 复制代码
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
​
function updateValue(event: Event) {
  const target = event.target as HTMLInputElement
  emit('update:modelValue', target.value)
}
</script>
​
<template>
  <input :value="modelValue" @input="updateValue" />
</template>

父组件:

javascript 复制代码
<MyInput v-model="username" />
<p>用户名:{{ username }}</p>

Vue 3支持多个v-model绑定,只需指定名字:

javascript 复制代码
<!-- 子组件 -->
<script setup lang="ts">
defineProps<{ firstName: string; lastName: string }>()
defineEmits<{
  (e: 'update:firstName', val: string): void
  (e: 'update:lastName', val: string): void
}>()
</script>
​
<!-- 父组件 -->
<UserForm v-model:first-name="first" v-model:last-name="last" />

这种设计让自定义组件可以像原生表单元素一样使用v-model,极大简化了表单开发。


五、插槽 Slots:内容分发魔法

有时,一个组件不仅需要数据,还需要"内容"------父组件想往子组件里塞一段HTML。插槽就是为此而生。

5.1 默认插槽

最简单的插槽,在子组件中使用<slot>标签作为占位符:

javascript 复制代码
<!-- Panel.vue -->
<template>
  <div class="panel">
    <div class="panel-header">标题栏</div>
    <div class="panel-body">
      <slot />
    </div>
  </div>
</template>

使用:

javascript 复制代码
<Panel>
  <p>这是放入面板的内容</p>
  <button>确认</button>
</Panel>

<Panel>标签之间的所有内容会被投射到<slot />所在位置。

5.2 默认内容

当父组件没有提供内容时,插槽可以显示默认内容:

javascript 复制代码
<slot>
  <span class="placeholder">暂无内容</span>
</slot>

5.3 具名插槽

一个组件可以有多个插槽,用name属性区分:

javascript 复制代码
<!-- Card.vue -->
<template>
  <div class="card">
    <header>
      <slot name="header" />
    </header>
    <main>
      <slot />
    </main>
    <footer>
      <slot name="footer" />
    </footer>
  </div>
</template>

使用v-slot指令(简写#)指定内容插入哪个插槽:

javascript 复制代码
<Card>
  <template #header>
    <h2>卡片标题</h2>
  </template>
​
  <p>这是主体内容,默认插槽</p>
​
  <template #footer>
    <button>操作按钮</button>
  </template>
</Card>

注意v-slot只能用在<template>标签或组件标签上。默认插槽的隐式名称是default

5.4 作用域插槽:插槽中的数据回传

当子组件有一些数据需要暴露给父组件的插槽内容时,可以使用作用域插槽。这解决了"内容由父组件定义,但数据在子组件"的矛盾。

子组件:

javascript 复制代码
<!-- List.vue -->
<script setup lang="ts">
import { ref } from 'vue'
​
interface Item {
  id: number
  name: string
  score: number
}
​
const items = ref<Item[]>([
  { id: 1, name: '张三', score: 95 },
  { id: 2, name: '李四', score: 82 }
])
</script>
​
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot name="item" :item="item" :index="item.id" />
    </li>
  </ul>
</template>

父组件使用:

javascript 复制代码
<List>
  <template #item="{ item, index }">
    <span class="rank">{{ index }}</span>
    <span>{{ item.name }}</span>
    <span :class="{ excellent: item.score >= 90 }">
      {{ item.score }}分
    </span>
  </template>
</List>

子组件通过slot的属性绑定向父组件传递数据,父组件用解构语法接收。这种模式是无渲染组件 的基础------只提供逻辑,不提供UI,渲染完全由父组件决定。像Vue Router的<router-link>和Element Plus的表格自定义列都大量使用了作用域插槽。


六、生命周期:组件的生老病死

每个Vue组件实例从创建到销毁,会经历一系列预定义的步骤,称为生命周期 。在这些步骤的间隙,Vue暴露了生命周期钩子函数,让你可以在特定时刻注入自己的代码。

6.1 生命周期的几个阶段

用人的一生类比:

创建期(setup):组件实例初始化,响应式数据建立。

挂载期(onBeforeMount → onMounted):组件被插入到DOM树。

更新期(onBeforeUpdate → onUpdated):响应式数据变化导致重新渲染。

销毁期(onBeforeUnmount → onUnmounted):组件从DOM移除并被销毁。

6.2 常用钩子详解

setup()

组合式API的入口,在组件创建之前执行。此时组件实例尚未完全创建,无法访问this(也没有this)。这是初始化响应式数据、计算属性、侦听器的地方。由于<script setup>就是整个脚本块运行在此阶段,你通常不需要显式写setup()

onMounted

组件挂载到DOM后调用。此时可以:

  • 访问DOM元素(通过ref模板引用)

  • 发起API请求

  • 初始化第三方库(如echarts图表需要DOM容器)

javascript 复制代码
<script setup lang="ts">
import { ref, onMounted } from 'vue'
​
const canvasRef = ref<HTMLCanvasElement | null>(null)
​
onMounted(() => {
  // canvasRef.value 现在可以安全使用
  const ctx = canvasRef.value?.getContext('2d')
  // ...
})
</script>
​
<template>
  <canvas ref="canvasRef" />
</template>
onBeforeUnmount / onUnmounted

组件被移除前/后调用。适合做清理工作:

javascript 复制代码
import { onBeforeUnmount } from 'vue'
​
let timer: number
onMounted(() => {
  timer = window.setInterval(() => {
    console.log('tick')
  }, 1000)
})
​
onBeforeUnmount(() => {
  clearInterval(timer)  // 防止内存泄漏
})

清理操作至关重要,尤其是定时器、事件监听、WebSocket连接等,否则会造成内存泄漏。

onUpdated

组件重新渲染后调用。一般用于需要访问更新后DOM的场景。初次渲染不会触发。

6.3 生命周期的父子和兄弟关系

父子组件的挂载顺序:

javascript 复制代码
父 setup → 父 onBeforeMount
  → 子 setup → 子 onBeforeMount → 子 onMounted
→ 父 onMounted

子组件的mounted在父组件之前完成,确保父组件挂载时子组件已经就位。

更新时:

javascript 复制代码
父 onBeforeUpdate
  → 子 onBeforeUpdate → 子 onUpdated
→ 父 onUpdated

6.4 与Vue 2的差异

如果你看过Vue 2的文档,会发现钩子名称变了:

  • beforeCreate / created → 被setup()替代

  • beforeMountonBeforeMount

  • mountedonMounted

  • beforeUpdateonBeforeUpdate

  • updatedonUpdated

  • beforeDestroyonBeforeUnmount

  • destroyedonUnmounted

统一加了on前缀,更清晰,都是从vue中导入的函数。


七、综合案例:升级待办事项组件树

让我们将之前的Todo应用重构为组件树,实践Props、Emits、插槽和生命周期。

7.1 组件结构设计

javascript 复制代码
src/
├── types.ts           # 统一 TS 类型
├── App.vue            # 根组件
└── components/
    ├── TodoHeader.vue  # 标题 + 统计
    ├── TodoInput.vue   # 输入添加
    ├── TodoList.vue    # 列表容器(插槽)
    ├── TodoItem.vue    # 单项
    └── TodoFooter.vue  # 全选 + 清理已完成

7.2 统一类型文件:types.ts

javascript 复制代码
export interface Todo {
  id: number
  text: string
  done: boolean
}

为什么要用 TS 接口?

答:给待办事项做类型约束 ,避免传错参数、提高代码可维护性,让编辑器有智能提示。

7.3 TodoHeader.vue

javascript 复制代码
// 接收父组件数据,展示统计信息
<script setup lang="ts">
defineProps<{
  activeCount: number
  total: number
}>()
</script>

<template>
  <div class="todo-header">
    <h1>📋 Vue3 Todo 组件树案例</h1>
    <p class="stats">
      总计 {{ total }} 项 · 未完成 {{ activeCount }} 项
    </p>
  </div>
</template>

<style scoped>
.todo-header {
  text-align: center;
  margin-bottom: 24px;
}
.todo-header h1 {
  font-size: 26px;
  font-weight: 600;
  color: #2d3748;
  margin: 0 0 8px 0;
}
.stats {
  font-size: 14px;
  color: #718096;
  margin: 0;
}
</style>

7.4 TodoInput.vue

javascript 复制代码
<script setup lang="ts">
import { ref } from 'vue'
// 输入框,定义自定义事件,输入完成后,向父组件发送数据
const text = ref('')
const emit = defineEmits<{
  (e: 'add', text: string): void
}>()

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

<template>
  <div class="todo-input">
    <input
      v-model="text"
      @keyup.enter="submit"
      placeholder="输入新任务,回车添加"
    />
    <button @click="submit">添加</button>
  </div>
</template>

<style scoped>
.todo-input {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}
.todo-input input {
  flex: 1;
  padding: 12px 16px;
  border: 1px solid #e2e8f0;
  border-radius: 10px;
  outline: none;
  font-size: 15px;
}
.todo-input input:focus {
  border-color: #4299e1;
  box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.15);
}
.todo-input button {
  padding: 12px 20px;
  background: #4299e1;
  color: white;
  border: none;
  border-radius: 10px;
  cursor: pointer;
  font-weight: 500;
}
.todo-input button:hover {
  background: #3182ce;
}
</style>

7.5 TodoItem.vue

javascript 复制代码
<script setup lang="ts">
import type { Todo } from '../types'
// 单个任务,接收单个任务数据,发出切换 / 删除事件
const props = defineProps<{
  todo: Todo
}>()

const emit = defineEmits<{
  (e: 'toggle', id: number): void
  (e: 'remove', id: number): void
}>()
</script>

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

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 16px;
  background: #f7fafc;
  border-radius: 10px;
}
.todo-item input {
  width: 18px;
  height: 18px;
  accent-color: #48bb78;
}
.text {
  flex: 1;
  font-size: 15px;
  color: #2d3748;
  word-break: break-all;
}
.done .text {
  text-decoration: line-through;
  color: #a0aec0;
}
.remove-btn {
  padding: 6px 10px;
  background: #fef2f2;
  color: #dc2626;
  border: none;
  border-radius: 6px;
  font-size: 13px;
  cursor: pointer;
}
.remove-btn:hover {
  background: #fecaca;
}
</style>

7.6 TodoList.vue(使用作用域插槽)

javascript 复制代码
<script setup lang="ts">
import type { Todo } from '../types'

defineProps<{
  todos: Todo[]
}>()
</script>
// 作用域插槽,只负责循环,不负责渲染,父组件决定如何渲染每一项,高复用、解耦
<template>
  <ul class="todo-list" v-if="todos.length > 0">
    <slot
      name="item"
      v-for="todo in todos"
      :key="todo.id"
      :todo="todo"
    />
  </ul>
  <div class="empty" v-else>暂无任务,添加一个吧 🎯</div>
</template>

<style scoped>
.todo-list {
  list-style: none;
  padding: 0;
  margin: 0 0 20px 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.empty {
  text-align: center;
  padding: 30px 0;
  color: #aaa;
  font-size: 14px;
}
</style>

注意:这里使用作用域插槽将每个todo数据抛出。这种方式让TodoList成为"无渲染组件"的趋势------它只负责循环逻辑,每个项的渲染由父组件决定。当然,直接使用v-for渲染TodoItem也可以,此处为了演示插槽的高级用法。

7.7 TodoFooter.vue

javascript 复制代码
<script setup lang="ts">
// 全选加清理,实现 自定义 v-model,固定语法:modelValue + update:modelValue
defineProps<{
  modelValue: boolean
}>()

const emit = defineEmits<{
  (e: 'update:modelValue', value: boolean): void
  (e: 'clear-completed'): void
}>()
</script>

<template>
  <div class="todo-footer">
    <label class="toggle-all">
      <input
        type="checkbox"
        :checked="modelValue"
        @change="emit('update:modelValue', $event.target.checked)"
      />
      全部{{ modelValue ? '未完成' : '完成' }}
    </label>
    <button class="clear-btn" @click="emit('clear-completed')">
      清理已完成
    </button>
  </div>
</template>

<style scoped>
.todo-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-top: 16px;
  border-top: 1px solid #e2e8f0;
  font-size: 14px;
  color: #718096;
}
.toggle-all {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
}
.toggle-all input {
  cursor: pointer;
  accent-color: #4299e1;
}
.clear-btn {
  background: none;
  border: none;
  color: #718096;
  cursor: pointer;
  font-size: 14px;
}
.clear-btn:hover {
  color: #dc2626;
}
</style>

7.8 App.vue(组装)

javascript 复制代码
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import type { Todo } from './types'
import TodoHeader from './components/TodoHeader.vue'
import TodoInput from './components/TodoInput.vue'
import TodoList from './components/TodoList.vue'
import TodoItem from './components/TodoItem.vue'
import TodoFooter from './components/TodoFooter.vue'

// 数据
// todos:存储所有任务,ref 包裹数组
const todos = ref<Todo[]>([])
// nextId:自增 ID,无需响应式
let nextId = 1

// 计算
// 计算未完成任务数量,具有缓存,依赖变化才重新计算
const activeCount = computed(() =>
  todos.value.filter(t => !t.done).length
)
// 可写计算属性,get:获取全选状态,set:批量设置任务状态,用于实现 v-model 全选
const allDone = computed({
  get: () => todos.value.length > 0 && activeCount.value === 0,
  set: (val: boolean) => {
    todos.value.forEach(t => t.done = val)
  }
})

// 方法
// 添加任务,接收子组件 emit 的数据,push 新增任务,ID 自增
function addTodo(text: string) {
  todos.value.push({ id: nextId++, text, done: false })
}
// 切换完成状态,根据 ID 找到任务,取反 done 状态
function toggleTodo(id: number) {
  const todo = todos.value.find(t => t.id === id)
  if (todo) todo.done = !todo.done
}
// 删除任务,filter 返回新数组,直接替换整个数组 → 必须用 ref
function removeTodo(id: number) {
  todos.value = todos.value.filter(t => t.id !== id)
}
// 清理已完成
function clearCompleted() {
  todos.value = todos.value.filter(t => !t.done)
}

// 生命周期 + 本地存储
// 页面加载后从 localStorage 恢复数据
onMounted(() => {
  const saved = localStorage.getItem('vue3-todos')
  if (saved) {
    try {
      const list = JSON.parse(saved) as Todo[]
      todos.value = list
      nextId = list.length > 0
        ? list.reduce((max, item) => Math.max(max, item.id), 0) + 1
        : 1
    } catch {}
  }
})

// 自动保存
// 深度监听 todos,数据变化自动保存到本地,刷新不丢失
watch(todos, (val) => {
  localStorage.setItem('vue3-todos', JSON.stringify(val))
}, { deep: true })
</script>
// Props 父传子,Emits 子传父,作用域插槽:子组件把数据抛给父组件,自定义 v-model
<template>
  <div class="app">
    <div class="todo-card">
      <TodoHeader :active-count="activeCount" :total="todos.length" />
      <TodoInput @add="addTodo" />
      <TodoList :todos="todos">
        <template #item="{ todo }">
          <TodoItem
            :todo="todo"
            @toggle="toggleTodo"
            @remove="removeTodo"
          />
        </template>
      </TodoList>
      <TodoFooter
        v-if="todos.length > 0"
        v-model="allDone"
        @clear-completed="clearCompleted"
      />
    </div>
  </div>
</template>

<style scoped>
.app {
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background: linear-gradient(135deg, #f5f7fa 0%, #e4eaf5 100%);
  padding: 20px;
}
.todo-card {
  width: 100%;
  max-width: 520px;
  background: white;
  border-radius: 16px;
  padding: 32px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
</style>

7.9 组件树价值总结

重构后,每个组件职责单一:

  • TodoInput只管输入与添加。

  • TodoItem只管单条待办的展示和事件触发。

  • TodoList负责列表循环,通过插槽提供灵活性。

  • TodoFooter管理全选和清理。

  • App作为总调度,持有数据,协调通信。

修改任何一个组件的样式或逻辑,不会影响其他部分。这就是组件化的核心价值。

7.10 功能测试

7.10.1 基础展示测试

页面加载 → 显示标题、统计、输入框,无任务时 → 显示「暂无任务」

7.10.2 添加任务

在输入框输入文字,点击【添加】或按回车,任务出现在列表

7.10.3 切换完成 / 未完成

点击任务复选框,文字变灰 + 加删除线,顶部未完成数量自动变化

7.10.4 删除单条任务

点击某条任务的【删除】,该条消失,数量同步更新

7.10.5 全选 / 取消全选

点击底部「全部完成 / 未完成」,所有任务一键切换状态,未完成数量变为 0

7.10.6 本地存储持久化

添加 / 删除 / 切换一些任务,刷新页面,数据依然存在,不会丢失

7.10.7 清理已完成

点击「清理已完成」,所有已完成任务被删除

7.11 问题解答

为什么要用 ref 而不是 reactive?

因为删除任务时会直接替换整个数组,reactive 包裹数组不能直接赋值,会丢失响应式,而 ref 更适合数组和基本类型。

什么是作用域插槽?

子组件把数据通过插槽抛给父组件,父组件决定如何渲染,实现了解耦和复用。

自定义组件如何实现 v-model?

子组件接收 modelValue,并触发 update:modelValue 事件。

为什么要用 computed?

有缓存,依赖不变不会重复计算,性能更好,代码更简洁。

组件通信方式有哪些?
  • Props 父传子

  • Emits 子传父

  • 插槽

  • provide/inject

  • pinia/vuex


八、总结

本文我们正式进入了Vue组件化的世界,从组件注册、Props定义与TypeScript类型、Emits事件通信、v-model双向绑定、三种插槽机制,到生命周期的各个关键钩子,构成了组件化开发的完整知识拼图。

  • 组件是独立可复用的UI单元,.vue文件将模板、逻辑、样式内聚在一起。

  • defineProps接收父组件数据(单向数据流),withDefaults设置默认值;TypeScript泛型提供编译时类型安全。

  • defineEmits向父组件发送事件;v-model是props+emits的语法糖,支持多绑定。

  • 插槽分为默认、具名和作用域三种,作用域插槽实现"数据在子组件,渲染在父组件"的灵活模式。

  • 生命周期钩子让你在组件的不同阶段执行代码,onMounted适合DOM操作和API请求,onBeforeUnmount用于清理。

  • 组件树设计应遵循"高内聚、低耦合",数据单向流动,通过事件反馈。


如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。

相关推荐
小糯米6011 小时前
JavaScript表达式与运算符
开发语言·javascript·ecmascript
SEO_juper1 小时前
新独立站冷启动收录全攻略:配置、推送、抓取配额优化完整手册
前端·谷歌·seo·跨境电商·外贸·geo·独立站
TinssonTai2 小时前
这个 VS Code 插件让我的 AI Coding 又快又稳 - 旧瓶装新酒
前端·人工智能·程序员
体验家2 小时前
体验家 XMPlus 网页端问卷 SDK 技术解析:用几行 JavaScript 实现精准场景触发与防打扰机制
开发语言·前端·javascript
VidDown2 小时前
VidDown 工具站:视频分辨率技术
javascript·网络·编辑器·音视频·视频编解码·视频
Maimai108082 小时前
Web3 前端交易系统如何落地:从下单 UI 到 Operation 编码、签名与实时状态更新
前端·react.js·ui·架构·前端框架·web3
kidding7232 小时前
高效备忘清单工具类小程序
前端·计算机网络·微信小程序·小程序
IMPYLH2 小时前
HTML 的 <abbr> 元素
前端·算法·html
小鹿软件办公2 小时前
倒计时开启:Chromium 宣布几周内将全面切断 MV2 扩展支持
开发语言·javascript·ublock origin