第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\nSimple tech illustration expla\n\nAI comic creation scene, creat\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 拆分成「可复用」的小块——就像乐高积木一样,组件也能「拼装」!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。