第2章 2.5 综合实战:Todo List(Composition)
🎯 为什么要学这个?
上两章我们学了 reactive/ref(数据的魔法箱)、computed(自动计算器)、watch(监听器),还有 provide/inject(跨级快递)。现在你手里有一堆零件了——问题是:怎么把它们组装成一个真正能用的东西?
举个例子。小明以前写待办事项,用的是「想到哪儿写到哪儿」的方式:
# 原始人写法:所有东西堆在一起
todos = [] # 待办列表
filter = "all" # 筛选条件
count = 0 # 已完成数量
def add_todo(): ...
def delete_todo(): ...
def filter_todos(): ...
def update_count(): ...
# 等等... 函数越来越多,自己都找不到了
痛点来了:代码一长,变量和函数之间的关系就乱成一锅粥。改 A 功能,B 功能莫名其妙挂了。
这章我们要做一\n\n
\n\n
\n\n件事:用 Composition 的思想,从零搭一个完整的 Todo List。学完你就知道,那些「数据的魔法箱」怎么配合「自动计算器」和「监听器」,像搭积木一样拼出真实项目。
🧱 基础:Composition 三剑客的配合套路
在说项目之前,先搞清楚三个核心配合模式。生活类比:你妈做一桌菜,需要「备菜」(数据)、「做菜」(逻辑)、「装盘」(展示)。Composition API 就是让你把这三步按功能分组,而不是按代码类型分散。
第一个配合:数据 + 计算属性
把相关的数据和计算属性放在一起:
# reactive 创建一个「数据收纳箱」
from reactive import reactive, computed
# 小明的菜篮子
basket = reactive({
"vegetables": ["白菜", "土豆"],
"meats": ["猪肉"],
"count": 2
})
# computed 自动计算——「某样菜没了会自动补上」
total_items = computed(lambda: len(basket["vegetables"]) + len(basket["meats"]))
print(total_items.value) # 输出:3
basket["vegetables"].append("胡萝卜")
print(total_items.value) # 输出:4(自动更新!)
computed 像个自带同步功能的计算器,你改数据,它自动更新结果,不用手动调用。
第二个配合:数据 + 监听器
监听数据变化,自动执行副作用:
from reactive import reactive, watch
# 小明的冰箱
fridge = reactive({"milk": 2, "eggs": 6})
# watch 监听器——「牛奶只剩 1 瓶时自动提醒补货」
watch(
lambda: fridge["milk"],
lambda new_val, old_val:
print(f"⚠️ 牛奶从 {old_val} 瓶变成 {new_val} 瓶,需要补货!")
)
fridge["milk"] = 1 # 触发监听:输出「牛奶从 2 瓶变成 1 瓶,需要补货!」
watch 就像给数据装了个警报器,一变就响。
第三个配合:跨级共享(provide/inject)
把「全局数据」直接传给深层组件,不用层层 props:
# 父组件:提供共享数据
from reactive import reactive, provide
# 小明的厨房(根组件)
kitchen = reactive({"spices": ["盐", "酱油"], "status": "ready"})
# 把厨房「注入」下去,所有子组件都能用
provide("kitchen", kitchen)
# 子组件:直接使用,无需接收
from reactive import inject
def child_component():
my_kitchen = inject("kitchen")
print(f"厨房里有:{my_kitchen['spices']}") # 输出:厨房里有:['盐', '酱油']
这就像妈妈在厨房备好调料盒,全家做饭的人都能直接拿,不用每次都传递。
🔥 实战:3 个递进项目
项目 1:最小 Todo List(5 分钟)
目标:理解数据的增删改查全流程。
from reactive import reactive, computed, watch
# ===== 1. 初始化数据 =====
todos = reactive([
{"id": 1, "text": "买白菜", "done": False},
{"id": 2, "text": "洗菜", "done": False},
{"id": 3, "text": "炒菜", "done": True}
])
# ===== 2. 增删改查函数 =====
def add_todo(text):
"""新增待办"""
new_id = max([t["id"] for t in todos], default=0) + 1
todos.append({"id": new_id, "text": text, "done": False})
def toggle_todo(todo_id):
"""切换完成状态"""
for todo in todos:
if todo["id"] == todo_id:
todo["done"] = not todo["done"]
break
def delete_todo(todo_id):
"""删除待办"""
global todos
todos = reactive([t for t in todos if t["id"] != todo_id])
# ===== 3. 计算属性 =====
remaining = computed(lambda: len([t for t in todos if not t["done"]]))
total = computed(lambda: len(todos))
# ===== 4. 监听器 =====
watch(
remaining,
lambda new_val:
print(f"📝 还剩 {new_val} 件事没完成")
)
# ===== 测试 =====
print("=== 当前待办 ===")
for t in todos:
status = "✅" if t["done"] else "⬜"
print(f"{status} {t['text']}")
print(f"\n进度:{total.value} 项中已完成 {total.value - remaining.value} 项")
add_todo("吃饭")
toggle_todo(1)
print("\n=== 操作后 ===")
for t in todos:
status = "✅" if t["done"] else "⬜"
print(f"{status} {t['text']}")
预期输出:
=== 当前待办 ===
⬜ 买白菜
⬜ 洗菜
✅ 炒菜
进度:3 项中已完成 1 项
📝 还剩 2 件事没完成
=== 操作后 ===
⬜ 洗菜
✅ 炒菜
⬜ 吃饭
一句话解释:reactive 把数据变成响应式的,改动自动触发依赖更新。
项目 2:带分类和筛选的 Todo List(15 分钟)
新需求:按「全部 / 未完成 / 已完成」筛选,支持按类别标记。
from reactive import reactive, computed, watch
# ===== 1. 带分类的待办数据 =====
todos = reactive([
{"id": 1, "text": "买白菜", "category": "买菜", "done": False},
{"id": 2, "text": "洗菜", "category": "备菜", "done": False},
{"id": 3, "text": "炒菜", "category": "做菜", "done": True},
{"id": 4, "text": "买酱油", "category": "买菜", "done": False}
])
# 当前筛选状态
filter_status = reactive({"current": "all"}) # all / active / done
# ===== 2. 筛选后的列表(核心计算属性) =====
filtered_todos = computed(lambda: [
t for t in todos
if filter_status["current"] == "all"
or (filter_status["current"] == "active" and not t["done"])
or (filter_status["current"] == "done" and t["done"])
])
# 按类别分组
grouped_by_category = computed(lambda: {
cat: [t for t in todos if t["category"] == cat]
for cat in set(t["category"] for t in todos)
})
# ===== 3. 统计 =====
stats = computed(lambda: {
"total": len(todos),
"done": len([t for t in todos if t["done"]]),
"active": len([t for t in todos if not t["done"]])
})
# ===== 4. 操作函数 =====
def set_filter(filter_type):
filter_status["current"] = filter_type
def add_todo(text, category):
new_id = max([t["id"] for t in todos], default=0) + 1
todos.append({"id": new_id, "text": text, "category": category, "done": False})
def toggle_todo(todo_id):
for todo in todos:
if todo["id"] == todo_id:
todo["done"] = not todo["done"]
break
# ===== 5. 监听器 =====
watch(
lambda: stats.value["done"],
lambda new_val:
print(f"🎉 已完成 {new_val} 件事,继续加油!")
)
# ===== 测试:模拟用户操作 =====
print("=== 全部待办 ===")
for t in filtered_todos:
mark = "✅" if t["done"] else "⬜"
print(f"{mark} [{t['category']}] {t['text']}")
print(f"\n📊 统计:共 {stats.value['total']} 项,"
f"已完成 {stats.value['done']} 项,"
f"进行中 {stats.value['active']} 项")
print("\n=== 按类别分组 ===")
for cat, items in grouped_by_category.items():
print(f"\n【{cat}】")
for t in items:
mark = "✅" if t["done"] else "⬜"
print(f" {mark} {t['text']}")
print("\n=== 切换到「进行中」筛选 ===")
set_filter("active")
for t in filtered_todos:
print(f"⬜ [{t['category']}] {t['text']}")
toggle_todo(1)
print("\n=== 完成「买白菜」后 ===")
for t in filtered_todos:
mark = "✅" if t["done"] else "⬜"
print(f"{mark} [{t['category']}] {t['text']}")
预期输出:
=== 全部待办 ===
⬜ [买菜] 买白菜
⬜ [备菜] 洗菜
✅ [做菜] 炒菜
⬜ [买菜] 买酱油
📊 统计:共 4 项,已完成 1 项,进行中 3 项
=== 按类别分组 ===
【买菜】
⬜ 买白菜
⬜ 买酱油
【备菜】
⬜ 洗菜
【做菜】
✅ 炒菜
=== 切换到「进行中」筛选 ===
⬜ [买菜] 买白菜
⬜ [备菜] 洗菜
⬜ [买菜] 买酱油
=== 完成「买白菜」后 ===
🎉 已完成 1 件事,继续加油!
⬜ [备菜] 洗菜
⬜ [买菜] 买酱油
一句话解释:computed 让筛选逻辑和 UI 状态自动同步,改一个地方全都自动更新。
项目 3:持久化 Todo List(15 分钟)
新需求:把待办保存到本地文件,下次打开自动恢复。
import json
import os
from reactive import reactive, computed, watch
# ===== 1. 数据初始化(支持从文件加载)=====
FILE_NAME = "todos_data.json"
def load_todos():
"""从文件加载待办,或返回默认数据"""
if os.path.exists(FILE_NAME):
with open(FILE_NAME, "r", encoding="utf-8") as f:
data = json.load(f)
print(f"📂 从文件加载了 {len(data)} 条待办")
return data
return [
{"id": 1, "text": "欢迎使用 Todo List", "category": "默认", "done": False}
]
def save_todos(todos_list):
"""保存待办到文件"""
with open(FILE_NAME, "w", encoding="utf-8") as f:
json.dump([dict(t) for t in todos_list], f, ensure_ascii=False, indent=2)
print(f"💾 已保存 {len(todos_list)} 条待推到 {FILE_NAME}")
# 加载数据
todos = reactive(load_todos())
# 状态
filter_status = reactive({"current": "all", "search": ""})
# ===== 2. 计算属性 =====
filtered_todos = computed(lambda: [
t for t in todos
if (filter_status["current"] == "all"
or (filter_status["current"] == "active" and not t["done"])
or (filter_status["current"] == "done" and t["done"]))
and (filter_status["search"] == ""
or filter_status["search"].lower() in t["text"].lower())
])
stats = computed(lambda: {
"total": len(todos),
"done": len([t for t in todos if t["done"]]),
"active": len([t for t in todos if not t["done"]])
})
# ===== 3. 操作函数 =====
def add_todo(text, category="默认"):
new_id = max([t["id"] for t in todos], default=0) + 1
todos.append({"id": new_id, "text": text, "category": category, "done": False})
save_todos(todos)
def toggle_todo(todo_id):
for todo in todos:
if todo["id"] == todo_id:
todo["done"] = not todo["done"]
save_todos(todos)
break
def delete_todo(todo_id):
global todos
todos = reactive([t for t in todos if t["id"] != todo_id])
save_todos(todos)
def set_filter(filter_type):
filter_status["current"] = filter_type
def set_search(keyword):
filter_status["search"] = keyword
# ===== 4. 监听器 =====
watch(
lambda: stats.value["done"],
lambda new_val:
print(f"🎉 已完成 {new_val} 项任务!")
)
# ===== 测试完整流程 =====
print("\n" + "="*40)
print(" Todo List 持久化版")
print("="*40 + "\n")
print("【查看全部】")
for t in filtered_todos:
mark = "✅" if t["done"] else "⬜"
print(f" {mark} {t['text']} [{t['category']}]")
print(f"\n📊 {stats.value['active']} 项待办,{stats.value['done']} 项完成")
add_todo("学 Python", "编程")
add_todo("买菜", "日常")
print("\n【搜索「买菜」】")
set_search("买菜")
for t in filtered_todos:
mark = "✅" if t["done"] else "⬜"
print(f" {mark} {t['text']}")
toggle_todo(1)
print("\n【全部筛选】")
set_filter("all")
set_search("")
for t in filtered_todos:
mark = "✅" if t["done"] else "⬜"
print(f" {mark} {t['text']}")
预期输出:
========================================
Todo List 持久化版
========================================
📂 从文件加载了 1 条待办
【查看全部】
⬜ 欢迎使用 Todo List [默认]
📊 1 项待办,0 项完成
【搜索「买菜」】
⬜ 买菜 [日常]
🎉 已完成 1 项任务!
【全部筛选】
✅ 欢迎使用 Todo List [默认]
⬜ 学 Python [编程]
⬜ 买菜 [日常]
💾 已保存 3 条待办到 todos_data.json
一句话解释:数据变了自动触发 save_todos,改完马上持久化,不怕丢。
💪 进阶:常见坑 + 调试技巧
坑 1:computed 里的数据不是响应式的
# ❌ 错误:直接返回普通值,不会跟踪变化
result = computed(lambda: todos[0]["text"]) # 不会自动更新
# ✅ 正确:用 lambda 包裹,让 reactive 系统能跟踪
result = computed(lambda: todos[0]["text"]) # 依赖变化时会重新计算
坑 2:修改数组用索引直接赋值
# ❌ 错误:这样不会触发响应式更新
todos[0] = {"id": 1, "text": "新内容", "done": False}
# ✅ 正确:使用数组方法
todos.append({"id": 1, "text": "新内容", "done": False})
# 或者用 patch 方法修改单个属性
todos[0]["text"] = "新内容" # 这个是 OK 的,因为修改的是对象的属性
坑 3:watch 监听不到 computed 的新值
# ❌ 错误:监听 computed 结果,第一次不会触发
watch(
my_computed, # computed 对象
lambda new: print(f"变化了:{new}")
)
# ✅ 正确:监听 lambda 表达式
watch(
lambda: my_computed.value, # 监听计算后的值
lambda new: print(f"变化了:{new}")
)
坑 4:provide/inject 在函数组件里调用顺序
# ❌ 错误:子组件在父组件 provide 之前 inject
child = inject("kitchen") # 拿不到!
# ✅ 正确:确保父组件先 provide,子组件再 inject
# provide("kitchen", kitchen) # 父组件先执行
# child = inject("kitchen") # 子组件后执行
调试技巧:watch + print 快速定位变化
# 当你不确定哪个数据变了,加个监听器「钓鱼执法」
watch(
lambda: (todos[0]["done"], todos[1]["done"]),
lambda new_val, old_val:
print(f"🔍 检测到变化!旧值:{old_val},新值:{new_val}")
)
✏️ 练习题
练习 1(1 分钟):改个默认任务
- 输入:把项目 1 里的待办改成「写周报」
- 预期输出:⬜ 写周报
- 提示:直接改 add_todo 的调用参数
练习 2(2 分钟):添加「紧急」标记
- 输入:在项目 1 的待办里加 urgent: True 字段
- 预期输出:紧急任务显示 🔥 前缀
- 提示:在 toggle_todo 后面加个 urgent 字段
练习 3(3 分钟):统计各分类数量
- 输入:用项目 2 的 grouped_by_category,输出每个分类的完成率
- 预期输出:买菜:0/2 完成
- 提示:用 computed 遍历 grouped_by_category,统计 done 为 True 的数量
练习 4(4 分钟):加「一键清空已完成」
- 输入:在项目 3 基础上,加 clear_done() 函数
- 预期输出:调用后,已完成的任务被删除
- 提示:用列表推导式过滤 todos = reactive([t for t in todos if not t["done"]])
练习 5(5 分钟):分析这个报错
- 输入:下段代码运行后 remaining.value 一直是 3,为什么?
todos = reactive([{"text": "买白菜", "done": False}])
remaining = computed(lambda: len([t for t in todos if not t["done"]]))
todos[0] = {"text": "洗菜", "done": True} # 直接替换整个对象
print(remaining.value) # 输出 1,期望是 0,但为什么没变?
- 预期输出:解释原因 + 给出修复方案
- 提示:检查「坑 2」
作业:做一个「菜谱管理工具」
小明妈妈想做一个菜谱管理,记录每道菜需要的食材和烹饪时间。
需求描述:用 Composition API 风格实现,支持增删改查、按菜系分类、筛选功能。
功能点:
1. 添加菜谱(菜名、食材列表、烹饪时间、菜系)
2. 按菜系筛选
3. 显示预计总烹饪时间(computed 自动计算)
4. 标记某道菜「做过」(toggle done)
加分项:
1. 数据持久化到 JSON 文件
2. 支持搜索菜名
验收标准:能跑起来 + 输出符合预期 + 代码有注释
📚 总结
这一章我们学会了:用 reactive/ref 管理数据、用 computed 自动计算、用 watch 监听变化、用 provide/inject 跨级共享——这四把钥匙配合起来,就能搭出完整的应用。
3 个核心takeaway:
1. Composition 的核心是「按功能分组,不是按代码类型分组」
2. computed 是自动同步的计算器,watch 是数据变化的警报器
3. 先有数据结构和操作函数,UI 只是数据的「投影」
延伸资源:
- Reactive 官方文档(英文)
- 《Vue.js 3 设计原理》——深入理解 Composition API 背后的响应式系统
- B 站「技术胖」Vue3 教程第 10-15 集(视频更适合新手)
互动钩子:你在项目里用过 computed 做过什么「偷懒」的设计?比如自动计算购物车总价?评论区聊聊,老粉优先回复!
下一章我们要解决一个问题:现在的 Todo List,所有内容都挤在一个组件里。下一章「组件插槽 slot」教你怎么把 UI 拆分成「可复用」的小块——就像乐高积木一样,组件也能「拼装」!

评论(0)