L02 · 第一个组件:TodoItem
🎯 本节目标:创建 TodoItem 组件,理解 Props 单向数据流
📦 本节产出:可渲染静态 Todo 项的组件 + 父组件传值演示
🔗 前置钩子:L01 的项目结构(知道在哪建组件、import 即注册)
🔗 后续钩子:L03 将用 ref() 让数据变成响应式的1. 什么是组件
组件是 Vue 最核心的概念:一段可复用的、自包含的 UI 单元。
组件的好处:
- 复用:同一个
TodoItem组件渲染多条数据 - 封装:每个组件管好自己的逻辑和样式(
scoped) - 组合:小组件拼装成大组件,大组件拼装成页面
2. 创建第一个组件:TodoItem
2.1 创建文件
在 src/components/ 下新建 TodoItem.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 中使用
<!-- 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
<!-- 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 父组件传值
<!-- 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 泛型,编译时类型检查
const props = defineProps<{
text: string
done: boolean
priority?: 'low' | 'medium' | 'high' // 可选 prop
}>()4.2 带默认值的声明
// 使用 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 会报只读属性错误,开发模式运行时也会警告
props.text = '修改后的文本'
// 具体警告文本随版本而异为什么要这样设计?
那子组件想改数据怎么办? → L05 会讲 emit(子组件通知父组件去改)。
6. 模板语法基础
下面是语法片段,示例中的 count、itemId、priority、isUrgent 需要在对应组件的脚本中声明。
6.1 插值
<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 / :
<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 绑定的三种方式
<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)**属性,体验如何扩展已有组件:
<!-- 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
<!-- 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-atvscreatedAt: 在模板中使用 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 |
| 在子组件里修改 props | Vue 会报 readonly 警告 | 用 emit 通知父组件修改 |
忘记写 scoped | 样式会泄漏到全局 | <style scoped> |
📐 最佳实践
- 组件粒度:一个组件只做一件事。如果你发现
<template>超过 100 行,考虑拆分子组件 - Props 命名:用 camelCase 定义(
createdAt),模板中用 kebab-case 传值(created-at) - 类型声明:本教程用
defineProps<T>();需要自定义运行时validator时可改用对象声明。TypeScript 类型不能代替对外部数据的运行时校验 - 默认值:只有业务需要默认行为时才设置默认值;可选属性也可以保留
undefined,例如未填写的createdAt
Git 提交
git add .
git commit -m "L02: TodoItem 组件 + Props + 模板语法"🔗 钩子连接
→ 下一节:L03 · 响应式数据:ref / reactive
L03 将解决一个关键问题:现在的 Todo 数据是写死在模板里的,无法动态添加。
L03 会用到 L02 的:
TodoItem组件(接收动态数据渲染)defineProps(保持接口不变)- 模板语法(用
v-for循环渲染——但那是 L04 的事)
L03 将引入:
ref()让原始值变成响应式reactive()让对象变成响应式- 理解"数据变 → 视图自动更新"的心智模型