Skip to content

L02 · 第一个组件:TodoItem ​

🎯 本节目标:创建 TodoItem 组件,理解 Props 单向数据流
📦 本节产出:可渲染静态 Todo 项的组件 + 父组件传值演示
🔗 前置钩子:L01 的项目结构(知道在哪建组件、import 即注册)
🔗 后续钩子:L03 将用 ref() 让数据变成响应式的

1. 什么是组件 ​

组件是 Vue 最核心的概念:一段可复用的、自包含的 UI 单元。

组件的好处:

  • 复用:同一个 TodoItem 组件渲染多条数据
  • 封装:每个组件管好自己的逻辑和样式(scoped)
  • 组合:小组件拼装成大组件,大组件拼装成页面

2. 创建第一个组件:TodoItem ​

2.1 创建文件 ​

在 src/components/ 下新建 TodoItem.vue:

vue
<!-- src/components/TodoItem.vue -->
<script setup lang="ts">
// 暂时为空——还没有 Props
</script>

<template>
  <div class="todo-item">
    <span class="todo-text">学习 Vue 3</span>
  </div>
</template>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  padding: 12px 16px;
  background: #fff;
  border-radius: 8px;
  border: 1px solid #e8e8e8;
  transition: box-shadow 0.2s ease;
}

.todo-item:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

.todo-text {
  font-size: 1rem;
  color: #2c3e50;
}
</style>

2.2 在 App.vue 中使用 ​

vue
<!-- src/App.vue -->
<script setup lang="ts">
import TodoItem from './components/TodoItem.vue'
// ⬆ import 就自动注册了,不需要 components: { TodoItem }
</script>

<template>
  <div class="app">
    <header class="app-header">
      <h1>📝 Vue Todo</h1>
    </header>

    <main class="app-main">
      <TodoItem />
      <TodoItem />
      <TodoItem />
      <!-- ⬆ 同一个组件可以多次使用,但现在每个都显示一样的文字 -->
    </main>
  </div>
</template>

注意组件命名规则: 组件名使用 PascalCase(TodoItem),文件名也用 PascalCase(TodoItem.vue)。在模板中可以用 <TodoItem /> 或 <todo-item />,但推荐 PascalCase 以区分原生 HTML 标签。


3. 用 Props 让组件接收数据 ​

现在三个 TodoItem 显示一样的文字,没有意义。我们需要让父组件 传数据 给子组件。

3.1 定义 Props ​

vue
<!-- src/components/TodoItem.vue -->
<script setup lang="ts">
// defineProps 是编译器宏,不需要 import
defineProps<{
  text: string       // Todo 的文本内容
  done: boolean      // 是否已完成
}>()
</script>

<template>
  <div class="todo-item" :class="{ 'is-done': done }">
    <span class="todo-text">{{ text }}</span>
    <!-- ⬆ {{ text }} 模板插值:把 JS 表达式渲染为文本 -->
    <!-- ⬆ 在 template 中访问 props 不需要 props.text,直接用 text -->
  </div>
</template>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  padding: 12px 16px;
  background: #fff;
  border-radius: 8px;
  border: 1px solid #e8e8e8;
  margin-bottom: 8px;
  transition: all 0.2s ease;
}

.todo-item:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

.todo-item.is-done {
  opacity: 0.6;
  background: #f9f9f9;
}

.todo-item.is-done .todo-text {
  text-decoration: line-through;
  color: #999;
}

.todo-text {
  font-size: 1rem;
  color: #2c3e50;
}
</style>

3.2 父组件传值 ​

vue
<!-- src/App.vue -->
<script setup lang="ts">
import TodoItem from './components/TodoItem.vue'
</script>

<template>
  <div class="app">
    <header class="app-header">
      <h1>📝 Vue Todo</h1>
    </header>

    <main class="app-main">
      <!-- 通过属性传递 Props -->
      <TodoItem text="学习 Vue 3 基础" :done="false" />
      <TodoItem text="理解组件和 Props" :done="false" />
      <TodoItem text="搭建项目脚手架" :done="true" />
      <!--                            ⬆ :done 冒号绑定,传 JS 值 -->
      <!--                     done="true" 传字符串;单写 done 才会转为布尔 true -->
    </main>
  </div>
</template>

:done="false" vs done="false" 的区别:

数字、对象、数组和布尔表达式使用 : 绑定。Boolean prop 有一个例外:<TodoItem text="学习" done /> 中单写 done 等价于 :done="true";done="false" 仍是字符串。


4. Props 的 TypeScript 类型声明 ​

这里使用 TypeScript 类型声明,再按需要添加默认值。Vue 也支持运行时对象声明;两种声明不能在同一次 defineProps 调用中混用。

4.1 纯类型声明(推荐) ​

typescript
// 利用 TypeScript 泛型,编译时类型检查
const props = defineProps<{
  text: string
  done: boolean
  priority?: 'low' | 'medium' | 'high'  // 可选 prop
}>()

4.2 带默认值的声明 ​

typescript
// 使用 withDefaults 设置默认值
const props = withDefaults(
  defineProps<{
    text: string
    done?: boolean
    priority?: 'low' | 'medium' | 'high'
  }>(),
  {
    done: false,
    priority: 'medium'
  }
)

4.3 两种方式对比 ​

特性纯类型 defineProps<T>()带默认值 withDefaults()
类型安全✅ 完整✅ 完整
默认值单独声明类型不设置默认值通过第二个参数设置
可读性简洁稍冗长
适用场景无需默认值,可含可选 prop需要默认值

Vue 3.5 也支持响应式 Props 解构及原生默认值,例如 const { priority = 'medium' } = defineProps<{ priority?: string }>()。本课保留 withDefaults,方便后续统一用 props.xxx 访问。参见 Vue 官方 Props 文档 和 <script setup> 文档。

5. 单向数据流:最重要的规则 ​

Props 绑定是只读的,子组件应通过事件请求父组件修改数据。 对象和数组的嵌套属性仍可能被修改,因为引用是共享的;应避免这样绕过父组件更新。

如果你在子组件里尝试修改:

typescript
// ❌ TypeScript 会报只读属性错误,开发模式运行时也会警告
props.text = '修改后的文本'
// 具体警告文本随版本而异

为什么要这样设计?

那子组件想改数据怎么办? → L05 会讲 emit(子组件通知父组件去改)。


6. 模板语法基础 ​

下面是语法片段,示例中的 count、itemId、priority、isUrgent 需要在对应组件的脚本中声明。

6.1 插值 ​

vue
<template>
  <!-- 文本插值:渲染 JS 表达式的结果 -->
  <span>{{ text }}</span>
  <span>{{ text.toUpperCase() }}</span>
  <span>{{ done ? '✅' : '⬜' }}</span>
  <span>{{ `共 ${count} 项` }}</span>

  <!-- ⚠️ 只能用表达式,不能用语句 -->
  <!-- ❌ {{ if (done) { return '完成' } }} -->
  <!-- ❌ {{ let x = 1 }} -->
</template>

6.2 属性绑定 v-bind / : ​

vue
<template>
  <!-- 完整语法 -->
  <div v-bind:id="itemId">...</div>

  <!-- 简写语法(推荐) -->
  <div :id="itemId">...</div>

  <!-- 绑定多个属性 -->
  <div :class="{ 'is-done': done, 'is-urgent': priority === 'high' }">...</div>

  <!-- 动态绑定 style -->
  <div :style="{ color: done ? '#999' : '#333' }">...</div>
</template>

6.3 class 绑定的三种方式 ​

vue
<template>
  <!-- 对象语法:key 是类名,value 是布尔值 -->
  <div :class="{ 'is-done': done, 'is-urgent': isUrgent }">...</div>

  <!-- 数组语法:可以混合静态和动态 -->
  <div :class="['todo-item', done ? 'is-done' : '']">...</div>

  <!-- 与静态 class 共存 -->
  <div class="todo-item" :class="{ 'is-done': done }">...</div>
  <!-- done 为 true 时渲染为 class="todo-item is-done"(自动合并) -->
</template>

7. 完善 TodoItem 组件 ​

把学到的知识综合运用,完善组件。我们在此增加一个新功能——**优先级(priority)**属性,体验如何扩展已有组件:

vue
<!-- src/components/TodoItem.vue 完整版 -->
<script setup lang="ts">
withDefaults(
  defineProps<{
    text: string
    done?: boolean
    priority?: 'low' | 'medium' | 'high'
    createdAt?: string
  }>(),
  {
    done: false,
    priority: 'medium',
  }
)

// 这里只在模板使用 prop,直接用 prop 名即可
// 若脚本也要读取,可保存返回值:const props = withDefaults(...)
</script>

<template>
  <div
    class="todo-item"
    :class="{
      'is-done': done,
      [`priority-${priority}`]: true,
    }"
  >
    <span class="status-icon">{{ done ? '✅' : '⬜' }}</span>
    <div class="todo-content">
      <span class="todo-text">{{ text }}</span>
      <span v-if="createdAt" class="todo-date">{{ createdAt }}</span>
    </div>
    <span class="priority-badge">{{ priority }}</span>
  </div>
</template>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  background: #fff;
  border-radius: 8px;
  border: 1px solid #e8e8e8;
  margin-bottom: 8px;
  transition: all 0.2s ease;
}

.todo-item:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
  transform: translateY(-1px);
}

.todo-item.is-done {
  opacity: 0.55;
  background: #fafafa;
}

.todo-item.is-done .todo-text {
  text-decoration: line-through;
  color: #999;
}

.status-icon {
  font-size: 1.2rem;
  cursor: pointer;
  user-select: none;
}

.todo-content {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.todo-text {
  font-size: 1rem;
  color: #2c3e50;
}

.todo-date {
  font-size: 0.75rem;
  color: #aaa;
  margin-top: 2px;
}

.priority-badge {
  font-size: 0.7rem;
  padding: 2px 8px;
  border-radius: 10px;
  text-transform: uppercase;
  font-weight: 600;
  letter-spacing: 0.5px;
}

.priority-low .priority-badge {
  background: #e8f5e9;
  color: #4caf50;
}

.priority-medium .priority-badge {
  background: #fff3e0;
  color: #ff9800;
}

.priority-high .priority-badge {
  background: #ffebee;
  color: #f44336;
}
</style>

更新 App.vue ​

vue
<!-- src/App.vue -->
<script setup lang="ts">
import TodoItem from './components/TodoItem.vue'
</script>

<template>
  <div class="app">
    <header class="app-header">
      <h1>📝 Vue Todo</h1>
      <p class="subtitle">Phase 1 — 用 Vue 3 从零做一个 Todo App</p>
    </header>

    <main class="app-main">
      <TodoItem
        text="搭建项目脚手架"
        :done="true"
        priority="low"
        created-at="2024-01-01"
      />
      <TodoItem
        text="理解组件和 Props"
        :done="false"
        priority="medium"
        created-at="2024-01-02"
      />
      <TodoItem
        text="学习响应式系统"
        :done="false"
        priority="high"
      />
    </main>
  </div>
</template>

<style scoped>
.app {
  max-width: 640px;
  margin: 0 auto;
  padding: 2rem;
}

.app-header {
  text-align: center;
  margin-bottom: 2rem;
}

.app-header h1 {
  font-size: 2rem;
  color: #42b883;
  margin-bottom: 0.25rem;
}

.subtitle {
  color: #888;
  font-size: 0.9rem;
}
</style>

注意 created-at vs createdAt: 在模板中使用 kebab-case(created-at),Vue 自动转为 camelCase(createdAt)。两种写法都支持,但 HTML 属性习惯用 kebab-case。


8. 组件数据流全景 ​


9. 本节总结 ​

知识清单 ​

检查清单 ​

  • [ ] 能在 src/components/ 下创建 .vue 组件
  • [ ] 知道 <script setup> 中 import 即自动注册
  • [ ] 能用 defineProps<T>() 声明 Props 类型
  • [ ] 能用 withDefaults() 设置默认值
  • [ ] 能解释为什么 Props 是只读的(单向数据流)
  • [ ] 能区分 :done="false" 和 done="false"
  • [ ] 能使用 、:class、:style 绑定数据

🐞 防坑指南 ​

坑说明正确做法
Props 不加冒号done="false" 传的是字符串 "false"(truthy!):done="false" 传布尔值
混淆模板与脚本的访问方式定义了 const props 时,模板中的 props.text 合法模板可写 ;脚本按声明方式访问
文件命名混用风格PascalCase 和 kebab-case 文件名都可用本项目统一用 PascalCase:TodoItem.vue
在子组件里修改 propsVue 会报 readonly 警告用 emit 通知父组件修改
忘记写 scoped样式会泄漏到全局<style scoped>

📐 最佳实践 ​

  1. 组件粒度:一个组件只做一件事。如果你发现 <template> 超过 100 行,考虑拆分子组件
  2. Props 命名:用 camelCase 定义(createdAt),模板中用 kebab-case 传值(created-at)
  3. 类型声明:本教程用 defineProps<T>();需要自定义运行时 validator 时可改用对象声明。TypeScript 类型不能代替对外部数据的运行时校验
  4. 默认值:只有业务需要默认行为时才设置默认值;可选属性也可以保留 undefined,例如未填写的 createdAt

Git 提交 ​

bash
git add .
git commit -m "L02: TodoItem 组件 + Props + 模板语法"

🔗 钩子连接 ​

→ 下一节:L03 · 响应式数据:ref / reactive ​

L03 将解决一个关键问题:现在的 Todo 数据是写死在模板里的,无法动态添加。

L03 会用到 L02 的:

  • TodoItem 组件(接收动态数据渲染)
  • defineProps(保持接口不变)
  • 模板语法(用 v-for 循环渲染——但那是 L04 的事)

L03 将引入:

  • ref() 让原始值变成响应式
  • reactive() 让对象变成响应式
  • 理解"数据变 → 视图自动更新"的心智模型