待办清单事件驱动
# 第一章:jstodo 原生待办清单
本章是综合案例的第一关·浏览器交互入门——从 "学完语法不知道写啥" 到 "能独立做出一个能用的浏览器单页应用"。本案例会做四件事:
1. DOM 操作三件套:createElement / textContent / appendChild——告别 innerHTML 字符串拼接,亲眼看 "节点级渲染" 是怎么回事。
2. 事件委托一招鲜:N 个 todo 项只用 1 个 addEventListener 处理增删改查——比 jQuery 时代每个按钮单独绑事件优雅 100 倍。
3. ES Module 工程化:把代码拆成 state.js / view.js / store.js / commands.js 四块——卷一第 10 章的模块化第一次落到真实项目。
4. 命令模式做撤销/重做:用 class Command + 双栈实现 Ctrl+Z / Ctrl+Y——这是卷一第 5 章面向对象 + 第 4 章高阶函数的综合应用。
学习方式:本案例按"骨架 → 渲染 → 事件 → 持久化 → 命令模式"五步法推进。总共 8 大阶段【§02 是阶段①、§03 是阶段②、§04 是阶段③、§05 是阶段④、§06 是阶段⑤、§07 是阶段⑥、§08 是阶段⑦、§09 是阶段⑧】、约 5 小时,建议分 2 天完成。每个阶段都遵循 "写一点 → 浏览器刷新 → 看到输出 → 再写下一点" 的节奏。
# 渐进学习节奏
先读这段,再开始敲代码!本案例严格按照真实前端工程师的开发节奏推进,不会一上来给你 800 行代码让你直接抄。我们的节奏是这样的:
阶段 ① HTML 骨架 + 一个空模块(§02) · 20 min
└ Step 1.1: index.html 引入 type="module"
└ Step 1.2: main.js 打印 hello 验证模块管道
阶段 ② state.js 数据层(§03) · 30 min
└ Step 2.1: 定义 todos 数组 + 闭包封装
└ Step 2.2: addTodo / removeTodo 暴露 API
阶段 ③ view.js 静态渲染(§04) · 40 min
└ Step 3.1: createElement 造一个 <li> 节点
└ Step 3.2: 遍历 todos 全量重渲(先这么写,后面优化)
└ Step 3.3: 看到 3 条假数据出现在页面
阶段 ④ 事件层 - 添加 todo(§05) · 40 min
└ Step 4.1: 表单 submit 事件 → state.addTodo → 重渲
└ Step 4.2: 输入框 Enter 提交 + 防空字符串
阶段 ⑤ 事件委托 - 删除/勾选(§06) · 50 min 【高峰:故意造 bug】
└ Step 5.1: 故意每个 <li> 单独绑 click → 看到 "新增的不响应" bug
└ Step 5.2: 改成事件委托 → 一个监听器搞定 N 个项
└ Step 5.3: e.target.closest('[data-id]') 拿 id
阶段 ⑥ store.js 持久化(§07) · 30 min
└ Step 6.1: localStorage 序列化
└ Step 6.2: 启动时 load + 每次变化 save
└ Step 6.3: 故意写脏数据 → 看到 JSON.parse 抛错 → try/catch 兜底
阶段 ⑦ 命令模式 - 撤销重做(§08) · 60 min 【高光段落 ⭐】
└ Step 7.1: class Command 抽象基类
└ Step 7.2: AddCommand / DeleteCommand 子类
└ Step 7.3: 双栈 + Ctrl+Z / Ctrl+Y 快捷键
阶段 ⑧ 增量 DOM diff(§09) · 30 min
└ Step 8.1: 测量全量重渲性能(performance.now)
└ Step 8.2: key-based reconcile 替换全量重渲
└ Step 8.3: 对比性能数据
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
每个 Step 必须做的三件事:
- 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
- 写一小段代码就刷新浏览器看一次(看到 🧪 标志立刻动手)
- 看到预期输出再写下一个 Step(绝不一口气抄完整段代码)
⚠️ 本案例独有的"故意造 bug → 修复":
- 阶段 ⑤ 会让你先用 "每项单独绑 click" 写一版,看到 "刚 add 的 todo 点删除按钮没反应"——这是 DOM 重渲后旧监听器全部失效的经典坑,踩过一次才能记住为什么必须用事件委托
- 阶段 ⑥ 会让你手动在 DevTools 把 localStorage 改成乱码,看到页面白屏——再加 try/catch 兜底
✅ 每个阶段的结构(你在正文里会反复看到):
┌─ 🎯 阶段目标 ─────────────┐ ← 阶段开头:明确做什么/不做什么 │ 完成什么、不做什么、验收标准 │ └────────────────────────┘ Step X.1:先写最小可运行版(5-20 行) Step X.2:浏览器刷新 → 打开 Console → 看到输出 ✅ Step X.3:再加一个小功能(10-30 行) Step X.4:刷新 → 看到新效果 ✅ ... ┌─ 🧪 运行验证 ─────────────┐ ← 阶段结尾:操作步骤 + 预期输出 │ 操作 / 预期 / 排错指南 │ └────────────────────────┘ ┌─ 📌 阶段小结 ─────────────┐ ← 阶段结尾:今天学到了什么 │ ✅ 已掌握 / ⏸ 暂未涉及 │ └────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 案例背景信息
| 项目 | 说明 |
|---|---|
| 难度 | ⭐⭐ |
| 预估时长 | 5 小时(建议分 2 天,每天 2-3 小时) |
| 前置章节 | 卷一第 02 数据类型、04 函数、05 面向对象、08 事件设计、09 错误机制、10 模块开发、14 DOM 操作 |
| 覆盖知识点 | ESM 模块化 / 闭包封装私有状态 / createElement 增量渲染 / 事件委托 / 自定义 CustomEvent / localStorage + JSON.parse 异常兜底 / 命令模式 + 双栈撤销重做 / 高阶函数 / 解构赋值 |
| 设计亮点 | 管道驱动开发:先空跑通 → 再一项项长出来 + 故意造 bug → 修复 教学法 |
| ⚠ 已知局限 | UI 朴素,不写拖拽 / i18n / 主题切换——留作挑战题 |
| 最终产物 | index.html + 5 个 .js 模块 + style.css,浏览器双击即可运行 |
| 代码规模 | 约 800 行(HTML 60 + CSS 140 + JS 600) |
# 项目目录结构
jstodo/
├── index.html # 入口:引入 type="module" 的 main.js
├── style.css # 样式(CSS 变量 + flex 布局)
└── src/
├── main.js # 入口:组装 state + view + 事件 + store
├── state.js # 数据层:todos 闭包封装 + 订阅机制
├── view.js # 视图层:DOM 渲染(阶段 ③ 全量 → 阶段 ⑧ 增量 diff)
├── store.js # 持久化层:localStorage 读写 + 异常兜底
└── commands.js # 命令模式:Command 基类 + 双栈撤销重做
2
3
4
5
6
7
8
9
# 一条命令跑起来
JS 不需要编译。有两种方式:
方式 A · 推荐:用 VSCode 的 Live Server 插件(右键 index.html → Open with Live Server)。
方式 B · 命令行:用 Python 起一个静态服务器(避免 file:// 协议下 ESM 受 CORS 限制):
cd jstodo
python3 -m http.server 8000
# 浏览器打开 http://localhost:8000
2
3
⚠️ 新手陷阱:直接双击
index.html用file://打开会报CORS error——这是因为 ESM 默认要求 HTTP 协议下加载。必须起一个本地 HTTP 服务器。
# 目录快速导航
点击以下条目即可跳转。
- 01. 项目需求和功能
- 02. HTML 骨架与 ESM 入口
- 03. state.js 数据层
- 04. view.js 静态渲染
- 05. 添加 todo
- 06. 事件委托 - 删除与勾选
- 07. store.js 持久化
- 08. 命令模式撤销重做
- 09. 增量 DOM diff
- 10. 项目总结分析
- 11. 项目技术思考
- 12. 卷一章节反向索引
- 13. 衔接与延伸
# 01.项目需求和功能
# 1.1 需求介绍说明
每个前端工程师的"hello world"项目都是 TodoMVC——TodoMVC 官网用同一个待办清单需求验收了 30+ 个框架(Vue/React/Angular...)。本案例的目标是:用 0 第三方库的原生 JS 完成一个 TodoMVC 风格的待办清单,让读者看到"框架到底替你做了什么"。
业务范围:
- 增删改 todo、按 "全部 / 未完成 / 已完成" 三种状态筛选
- localStorage 持久化(刷新页面不丢数据)
- Ctrl+Z / Ctrl+Y 撤销重做(这是工业级应用的标配,TodoMVC 反而不要求——本案例额外加,凸显命令模式)
不做范围(留作挑战题):拖拽排序、深色模式、i18n、PWA 离线、键盘快捷键 j/k 上下移动。
# 1.2 功能矩阵总览
| 功能 | 触发方式 | 涉及模块 |
|---|---|---|
| 添加 | 表单 submit | state + view |
| 删除 | 点击 ✕ | view(事件委托) |
| 勾选完成 | 点击 checkbox | view(事件委托) |
| 双击编辑 | dblclick <span> | view |
| 筛选 | 点击 "全部/未完成/已完成" | state(subscribe) |
| 持久化 | 每次变化自动保存 | store |
| 撤销 (Ctrl+Z) | 键盘快捷键 | commands(双栈) |
| 重做 (Ctrl+Y) | 键盘快捷键 | commands |
# 1.3 模块分工说明
┌─────────────┐
│ index.html │
└──────┬──────┘
│ <script type="module" src="src/main.js">
▼
┌──────────┐
│ main.js │ ← 装配中心
└────┬─────┘
│
┌─────────┼──────────┬───────────┐
▼ ▼ ▼ ▼
state.js view.js store.js commands.js
(数据) (视图) (持久化) (撤销栈)
2
3
4
5
6
7
8
9
10
11
12
13
单向数据流:
用户点击 ───► commands.execute(cmd) ───► state.add/remove/toggle ───► 触发订阅
│
└─► undoStack.push(cmd)
│
▼
view.render() ◄── store.save()
2
3
4
5
6
💡 设计原则:state 不知道 view 存在,view 不知道 store 存在——模块只通过订阅/事件解耦。这就是阶段 ⑦ 命令模式能"插入"到任何位置的关键。
# 1.4 知识点速查
| 卷一章节 | 知识点 | 在本案例中的位置 |
|---|---|---|
| 第 02 章 数据类型 | 数组、对象、Map | §03 state |
| 第 04 章 函数 | 闭包封装、高阶函数(subscribe 回调) | §03、§07 |
| 第 05 章 面向对象 | class Command + extends | §08 命令模式 |
| 第 08 章 事件设计 | addEventListener / 事件委托 / CustomEvent | §05、§06 |
| 第 09 章 错误机制 | try/catch 兜底 JSON.parse | §07 |
| 第 10 章 模块开发 | ESM export / import | 全章 |
| 第 14 章 DOM 操作 | createElement / closest / dataset | §04、§09 |
# 02.HTML 骨架与 ESM 入口
┌─ 🎯 阶段 ① 目标 ────────────────────────────────────┐
│ 完成什么:浏览器能加载 index.html → 加载 main.js → 在 │
│ Console 打印 "[main] hello jstodo" │
│ 不做什么:不写 state、不写渲染、不写事件、不写样式 │
│ 验收标准:浏览器 Console 看到 hello 日志,无 CORS 报错 │
│ 预计耗时:20 分钟 │
│ 关键思路:先把 ESM 模块管道打通——这是后续所有阶段的根 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
# 2.1 创建项目空文件
mkdir -p jstodo/src && cd jstodo
touch index.html style.css
touch src/main.js src/state.js src/view.js src/store.js src/commands.js
2
3
📌 新手提示:5 个 JS 文件看起来吓人,但不是一次性写完的——本阶段只往 main.js 里写一行 console.log,其余 4 个文件保持空,让 ESM 的 import 不报错即可。
# 2.2 写 index.
📁 index.html(阶段 ① 骨架版):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>jstodo · 原生待办清单</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="app">
<h1>jstodo</h1>
<form id="todo-form">
<input id="todo-input" type="text" placeholder="今天要做什么?" autofocus />
<button type="submit">添加</button>
</form>
<ul id="todo-list"></ul>
<footer class="bar">
<span id="todo-count">0 项未完成</span>
<div class="filters">
<button data-filter="all" class="active">全部</button>
<button data-filter="active">未完成</button>
<button data-filter="done">已完成</button>
</div>
</footer>
</main>
<!-- ⭐ 关键:type="module" 才能用 import/export -->
<script type="module" src="src/main.js"></script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
⚠️ 关键点 type="module":没有这行,浏览器会把 src/main.js 当成传统脚本,遇到 import 直接抛 SyntaxError。这是卷一第 10 章 §10.3.1 反复强调的细节。
# 2.3 写最小 main
📁 src/main.js(阶段 ① 骨架版):
console.log('[main] hello jstodo');
🤔 不要写 import 你还没写的文件——空 import 会报
404。先验证<script type="module">加载本身没问题。
# 2.4 写一点点 CSS
📁 style.css(写够能看就行,剩下样式不影响功能):
:root { --bg: #f5f5f5; --primary: #1976d2; --danger: #e53935; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, sans-serif; background: var(--bg); padding: 40px 0; }
.app { max-width: 540px; margin: 0 auto; background: #fff; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,.08); padding: 24px; }
h1 { font-size: 28px; margin-bottom: 16px; color: var(--primary); }
#todo-form { display: flex; gap: 8px; margin-bottom: 16px; }
#todo-input { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
#todo-form button { padding: 8px 16px; background: var(--primary); color: #fff;
border: 0; border-radius: 4px; cursor: pointer; }
#todo-list { list-style: none; }
#todo-list li { display: flex; align-items: center; gap: 8px;
padding: 8px 4px; border-bottom: 1px solid #eee; }
#todo-list li.done span { text-decoration: line-through; color: #999; }
#todo-list span { flex: 1; }
#todo-list .delete { background: transparent; border: 0; color: var(--danger); cursor: pointer; }
.bar { display: flex; justify-content: space-between; margin-top: 16px;
font-size: 13px; color: #666; }
.filters button { background: transparent; border: 1px solid transparent;
padding: 2px 8px; cursor: pointer; color: inherit; }
.filters button.active { border-color: var(--primary); color: var(--primary); border-radius: 4px; }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 🧪 阶段 ① 验收
cd jstodo
python3 -m http.server 8000
2
操作:浏览器打开 http://localhost:8000 → F12 打开 Console。
预期 Console 输出:
[main] hello jstodo
✅ 看到日志 = ESM 管道打通。
❌ 看到 Failed to load module script: ...MIME type ("text/html") = index.html 路径错或 main.js 路径错。
❌ 看到 CORS policy blocked = 你直接双击打开了 file://...——必须用 http://localhost。
┌─ 📌 阶段 ① 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • <script type="module"> 加载入口 JS │
│ • python3 -m http.server 起本地服务器避免 CORS │
│ • 5 个空文件占位的"按需生长"开发节奏 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • state.js 数据层(阶段 ②) │
│ • DOM 渲染(阶段 ③) │
│ • 事件处理(阶段 ④⑤) │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
# 03.state.js 数据层
┌─ 🎯 阶段 ② 目标 ────────────────────────────────────┐
│ 完成什么:state.js 暴露 todos 数据 + addTodo / 订阅 API│
│ 不做什么:不接 DOM、不接持久化——纯逻辑 │
│ 验收标准:在 main.js 调用 state.add('买牛奶') → │
│ state.subscribe(arr => console.log(arr)) 打印 │
│ 预计耗时:30 分钟 │
│ 关键思路:闭包封装私有状态 + 发布订阅模式 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
# 3.1 为什么要单独建
❓ 能不能直接 let todos = [] 写在 main.js 顶部?
来看反例:
// main.js
let todos = []; // 谁都能改
function addTodo(text) { todos.push({ text }); }
function clear() { todos = []; }
// 200 行后,view.js 也 import 了 todos,直接 todos.length = 0 ——
// 你永远不知道是谁、什么时候改了它
2
3
4
5
6
问题暴露:
todos是全局可写——任何模块都能直接突变它(违反封装)- 数据变了没人知道——view 必须主动轮询才能更新
- 散在多个文件里改
todos,bug 难以定位
✅ 正确做法:用闭包把 todos 锁在 state.js 内部,只暴露 getTodos / add / remove / subscribe 几个 API。这就是卷一第 4 章 §4 闭包的真实工程价值。
❓ 为什么需要 subscribe 机制? 答:让 view 不需要"知道 state.js 在哪"——state 变了它"广播"出去,谁关心谁订阅。
❓ 第一步先做什么? 答:先暴露 getTodos / add / subscribe 三个最小 API,跑通 "add → 订阅触发" 链路即可。
# 3.2 写第一版 sta
📁 src/state.js(阶段 ② 骨架版):
// 🔒 闭包内的私有数组——外部只能通过下面的函数访问
let todos = [];
let nextId = 1;
const subscribers = new Set(); // ⭐ 用 Set 防止重复订阅
/** 通知所有订阅者:数据变了 */
function notify() {
for (const fn of subscribers) {
try { fn(todos); }
catch (err) { console.error('[state] subscriber error:', err); }
// ⭐ 一个订阅者抛错不能影响其他订阅者
}
}
/** 拷贝返回,防止外部直接 push 突变内部数组 */
export function getTodos() {
return todos.slice();
}
export function addTodo(text) {
text = text.trim();
if (!text) return null; // 空字符串拒绝
const todo = { id: nextId++, text, done: false };
todos.push(todo);
notify();
return todo;
}
export function subscribe(fn) {
subscribers.add(fn);
// ⭐ 返回取消订阅函数(这是订阅模式的标准写法)
return () => subscribers.delete(fn);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
设计要点解析:
| 要点 | 写法 | 作用 |
|---|---|---|
let todos = [] 写在模块顶层 | 不导出 | 闭包私有:外部 import 不到 |
getTodos() 返回 todos.slice() | 浅拷贝 | 防止外部 arr.push(...) 突变内部数组 |
subscribers = new Set() | 用 Set 不用数组 | 同一函数订阅多次只生效一次 |
notify 里 try/catch | 错误隔离 | 一个订阅者抛错不影响其他订阅者 |
subscribe 返回 unsubscribe | 函数返回函数 | 高阶函数典型用法(卷一 §4.5.2) |
📚 闭包 vs class 私有字段:你也可以写 class State { #todos = []; }——两种风格都对。本案例选闭包,因为 state 是单例,写 class 反而多余(不需要 new State())。
# 3.3 在 main.j
📁 src/main.js(追加):
import { addTodo, getTodos, subscribe } from './state.js';
console.log('[main] hello jstodo');
// 临时验证(阶段 ② 验收完会删除)
subscribe(arr => console.log('[订阅触发]', arr));
addTodo('买牛奶');
addTodo(' '); // 空白应该被拒绝
addTodo('遛狗');
console.log('当前 todos:', getTodos());
2
3
4
5
6
7
8
9
10
11
12
# 🧪 阶段 ② 验收
刷新浏览器 → 看 Console。
预期输出:
[main] hello jstodo
[订阅触发] [{ id: 1, text: '买牛奶', done: false }]
[订阅触发] (2) [{ id: 1, ... }, { id: 2, text: '遛狗', done: false }]
当前 todos: (2) [...]
2
3
4
✅ 关键观察:
- 中间的空白字符串
' '没有触发订阅——说明text.trim()校验生效 - 订阅函数被调用 2 次(每次 add 都触发一次)
❌ 如果看到 addTodo is not a function —— 99% 是 main.js 没加 import 或路径写错。
┌─ 📌 阶段 ② 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • 闭包封装模块私有状态 │
│ • 发布订阅模式(subscribe + notify) │
│ • Set 去重订阅 + try/catch 隔离错误 │
│ • 高阶函数:subscribe 返回 unsubscribe │
│ ⏸ 还没碰的(下阶段才会做): │
│ • DOM 渲染(阶段 ③) │
│ • toggle / remove API(阶段 ⑤ 才补) │
│ 💡 本阶段最大领悟: │
│ "state 不是变量,是一个被闭包保护的、有出入口的房间" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
# 04.view.js 静态渲染
┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────┐
│ 完成什么:把 todos 数组渲染成 <li> 节点列表 │
│ 不做什么:不处理点击事件——纯渲染 │
│ 验收标准:调用 render([...]) 后,页面上看到对应条目 │
│ 预计耗时:40 分钟 │
│ 关键思路:先用最朴素的"全量重渲"——阶段 ⑧ 再优化为 diff │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
# 4.1 为什么不用 in
❓ ul.innerHTML = todos.map(t => '<li>' + t.text + '</li>').join('') 不香吗?
来看反例:
// ❌ XSS 漏洞
addTodo('<img src=x onerror="alert(1)">');
ul.innerHTML = ...; // ← 攻击成功,弹窗
2
3
只要 todo 文本里包含 HTML 特殊字符,innerHTML 就会把它当成代码执行——这是经典的 XSS 漏洞。安全边界一旦破坏,整个页面(包括 cookie / localStorage)都暴露给攻击者。
✅ 正确做法:用 createElement + textContent——textContent 会把任何字符串当纯文本,自动转义。这是卷一第 14 章 §14.2 强调的安全铁律。
❓ 为什么先做"全量重渲",不直接做高效 diff?
答:先正确,再高效。全量重渲是 30 行代码、零 bug;diff 是 100 行代码、容易写错。先把整个项目跑通,阶段 ⑧ 再回头优化——这是工程师真实的迭代节奏。
❓ 第一步先做什么? 答:先写一个 createTodoItem(todo) 函数,把单个 todo 变成 <li> 节点;再写 render(todos) 把数组渲染成列表。
# 4.2 写第一版 vie
📁 src/view.js(阶段 ③ 骨架版):
const ul = document.querySelector('#todo-list');
const countEl = document.querySelector('#todo-count');
/**
* 把单个 todo 变成 <li> 节点
* 重点:用 createElement + textContent,绝不用 innerHTML 拼字符串
*/
function createTodoItem(todo) {
const li = document.createElement('li');
li.dataset.id = todo.id; // ⭐ 把 id 写到 data-id 属性,事件委托时取
if (todo.done) li.classList.add('done');
// checkbox
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = todo.done;
checkbox.dataset.action = 'toggle'; // ⭐ 标记动作类型,事件委托时分发
li.appendChild(checkbox);
// 文本(textContent 自动转义,安全)
const span = document.createElement('span');
span.textContent = todo.text;
li.appendChild(span);
// 删除按钮
const btn = document.createElement('button');
btn.className = 'delete';
btn.textContent = '✕';
btn.dataset.action = 'delete';
li.appendChild(btn);
return li;
}
/** 全量重渲(阶段 ⑧ 会优化为增量 diff) */
export function render(todos) {
ul.innerHTML = ''; // ⭐ 清空旧节点
// 用 fragment 批量插入,比循环 appendChild 快(只触发 1 次 reflow)
const frag = document.createDocumentFragment();
for (const todo of todos) {
frag.appendChild(createTodoItem(todo));
}
ul.appendChild(frag);
// 更新计数
const remaining = todos.filter(t => !t.done).length;
countEl.textContent = `${remaining} 项未完成`;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
设计要点解析:
| 要点 | 写法 | 作用 |
|---|---|---|
dataset.id | 把 id 写到 DOM | 阶段 ⑤ 事件委托时通过 e.target.closest('li').dataset.id 取 |
dataset.action | 给每个可点击元素打标 | 区分 toggle / delete / edit |
DocumentFragment | 批量插入容器 | 1 次 reflow(直接 N 次 appendChild 是 N 次) |
textContent | 不用 innerHTML | XSS 安全 |
📚 document.createDocumentFragment():浏览器内存中的 "影子节点",往它身上 appendChild 不会触发页面重排;最后一次性 append 到真实 DOM 上,浏览器只重排一次。渲染长列表的标准优化。
# 4.3 在 main.j
📁 src/main.js(更新):
import { addTodo, getTodos, subscribe } from './state.js';
import { render } from './view.js';
// ⭐ 关键:state 一变就重渲
subscribe(render);
// 启动时先渲一次(此时 todos 是空的,但筛选条会显示 "0 项未完成")
render(getTodos());
// 临时塞两条假数据看效果
addTodo('买牛奶');
addTodo('遛狗');
2
3
4
5
6
7
8
9
10
11
12
# 🧪 阶段 ③ 验收
刷新浏览器:
预期界面:
┌──────────────────────────────────────┐
│ jstodo │
│ ┌──────────────────────────┐ [添加] │
│ │ 今天要做什么? │ │
│ └──────────────────────────┘ │
│ │
│ ☐ 买牛奶 ✕ │
│ ☐ 遛狗 ✕ │
│ │
│ 2 项未完成 [全部] 未完成 已完成 │
└──────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
✅ 关键观察:
- 两条 todo 出现在页面上
- "2 项未完成" 计数正确
- DevTools Elements 面板看
<li>上有data-id="1"属性
❌ 如果看到空白页面 → 检查 main.js 的 import 路径
❌ 如果文本里出现 < 等字符显示成 HTML → 你用了 innerHTML,立即改回 textContent
┌─ 📌 阶段 ③ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • createElement + textContent 安全渲染 │
│ • dataset.id / dataset.action 给 DOM 打标 │
│ • DocumentFragment 批量插入降低重排 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 用户输入触发 add(阶段 ④) │
│ • 点击勾选/删除(阶段 ⑤ 事件委托) │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 05.添加 todo
┌─ 🎯 阶段 ④ 目标 ────────────────────────────────────┐
│ 完成什么:用户在输入框输入文字 → 点击"添加"或回车 → 出现│
│ 不做什么:不做删除、不做勾选——下阶段 │
│ 验收标准:能持续添加 N 条 todo,空字符串被拒绝 │
│ 预计耗时:40 分钟 │
│ 关键思路:表单 submit 事件 + preventDefault 阻止默认刷新 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
# 5.1 为什么是 sub
❓ 直接给"添加"按钮绑 click 事件不行吗?
行,但不优雅:用户在输入框按回车也应该能提交,绑 click 就要再加一个 keydown 监听器。
✅ 正确做法:监听 form 的 submit 事件——表单天然支持回车提交,浏览器自动统一两种交互。这是 HTML 语义化的红利。
❓ 为什么要 e.preventDefault()? 答:表单默认提交会刷新页面——你的所有 JS 状态全部丢失。preventDefault 阻止默认行为,让我们用 JS 接管。
❓ 第一步做什么? 答:先用 console.log 验证事件触发,再接 state.addTodo。
# 5.2 表单事件绑定
📁 src/main.js(追加):
// 删除阶段 ③ 临时塞的两条假数据,开始真实交互
const form = document.querySelector('#todo-form');
const input = document.querySelector('#todo-input');
form.addEventListener('submit', (e) => {
e.preventDefault(); // ⭐ 阻止表单默认提交(否则页面刷新)
const text = input.value;
console.log('[submit]', text); // 第一步先打日志
});
2
3
4
5
6
7
8
9
🧪 刷新 → 输入 "买牛奶" → 回车。
预期 Console:[submit] 买牛奶,页面没刷新。
✅ 看到日志且页面不刷新 = 事件链路通。
# 5.3 接通 state
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = input.value;
const todo = addTodo(text); // ⭐ state.addTodo 内部会 trim 并触发订阅
if (todo) input.value = ''; // 添加成功才清空输入框
// ⭐ 这里不需要手动调 render——state.addTodo 触发订阅,view.render 自动跑
});
2
3
4
5
6
7
# 🧪 阶段 ④ 验收
操作:
- 输入 "买牛奶" → 回车 → 看到列表新增、输入框清空
- 直接回车(输入空白)→ 列表无变化、输入框保留
- 输入 "遛狗" → 点 [添加] 按钮 → 也能添加
✅ 看到三种行为都符合预期 = 阶段 ④ 完成。
┌─ 📌 阶段 ④ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • form.submit + preventDefault 接管表单提交 │
│ • 用订阅自动触发 view.render(无需手动调) │
│ • 数据校验放在 state(addTodo 拒绝空串) │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 删除按钮(阶段 ⑤) │
│ • 勾选完成(阶段 ⑤) │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 06.事件委托 - 删除与勾选
┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────┐
│ 完成什么:点击 ✕ 删除 / 点 checkbox 切换完成状态 │
│ 不做什么:不做编辑、不做撤销 │
│ 验收标准:N 条 todo 都能正确响应点击;新增的也能响应 │
│ 预计耗时:50 分钟 │
│ 关键思路:先故意每项单独绑事件 → 看到 bug → 改成事件委托 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
本阶段是案例 01 的最大教学高峰——你会亲眼看到 "刚 add 的 todo 点删除按钮没反应" 这个经典 bug,再用事件委托修复。
# 6.1 故意造 bug
先在 state.js 追加 removeTodo 和 toggleTodo:
📁 src/state.js(追加):
export function removeTodo(id) {
const idx = todos.findIndex(t => t.id === id);
if (idx === -1) return false;
todos.splice(idx, 1);
notify();
return true;
}
export function toggleTodo(id) {
const t = todos.find(t => t.id === id);
if (!t) return false;
t.done = !t.done;
notify();
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📁 src/view.js(在 createTodoItem 里给按钮和 checkbox 单独绑事件)—— 故意写错的版本:
import { removeTodo, toggleTodo } from './state.js';
function createTodoItem(todo) {
const li = document.createElement('li');
// ... 之前的代码 ...
// ❌ 故意写错:每个 li 单独绑监听器
checkbox.addEventListener('change', () => toggleTodo(todo.id));
btn.addEventListener('click', () => removeTodo(todo.id));
return li;
}
2
3
4
5
6
7
8
9
10
11
12
🧪 刷新 → 添加 "买牛奶" → 点 ✕ → ✅ 删除成功。 🧪 再添加 "遛狗" → 点 "遛狗" 旁边的 ✕ → 🚨 没反应!
❓ 为什么?打开 DevTools 看一下监听器:
原因分析:
1. 添加"买牛奶" → state 触发订阅 → view.render() → ul.innerHTML='' → 旧 li 节点全部销毁
2. 重渲时 createTodoItem 重新生成 <li> + 绑了新事件——OK
3. 添加"遛狗" → 又触发 render → "买牛奶"的 li 又被销毁重建一次
4. 但是!用户点击"遛狗"按钮时,点的其实是 render 完之后的新 li——监听器其实是在的
🤔 这个版本其实"看起来能用",但有 3 个隐藏问题:
① 内存泄漏:每次 render 都重新绑定 N 个监听器,旧的被 GC 慢
② 性能:100 条 todo 每次 render 绑 200 个监听器(checkbox + button)
③ 真正的灾难:阶段 ⑧ 改成增量 diff 后,复用 li 节点不重新创建——
这时新增的 todo 上没有监听器,bug 会真正暴露
2
3
4
5
6
7
8
9
10
11
🔑 教学要点:现在你看不到 bug,但它已经埋下了。我们提前用事件委托避免这个未来的灾难。
# 6.2 改成事件委托
✅ 正确做法:只在 <ul> 上绑 1 个监听器,利用事件冒泡处理所有 <li> 内的点击。
📁 src/view.js(重写):
import { removeTodo, toggleTodo } from './state.js';
const ul = document.querySelector('#todo-list');
const countEl = document.querySelector('#todo-count');
// ⭐ 一次性在 ul 上绑事件——后续不管 li 怎么增删,监听器永远在
ul.addEventListener('click', (e) => {
const li = e.target.closest('li[data-id]');
if (!li) return;
const id = Number(li.dataset.id);
// 用 dataset.action 分发动作
switch (e.target.dataset.action) {
case 'delete':
removeTodo(id);
break;
// toggle 走 change 事件(见下方),这里不处理
}
});
// checkbox 的状态变化用 change 事件更准确(不是点击坐标,而是状态切换)
ul.addEventListener('change', (e) => {
if (e.target.dataset.action !== 'toggle') return;
const li = e.target.closest('li[data-id]');
if (!li) return;
toggleTodo(Number(li.dataset.id));
});
function createTodoItem(todo) {
const li = document.createElement('li');
li.dataset.id = todo.id;
if (todo.done) li.classList.add('done');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = todo.done;
checkbox.dataset.action = 'toggle';
li.appendChild(checkbox);
const span = document.createElement('span');
span.textContent = todo.text;
li.appendChild(span);
const btn = document.createElement('button');
btn.className = 'delete';
btn.textContent = '✕';
btn.dataset.action = 'delete';
li.appendChild(btn);
return li;
// ⭐ 注意:这里没有 addEventListener!
}
export function render(todos) {
ul.innerHTML = '';
const frag = document.createDocumentFragment();
for (const todo of todos) frag.appendChild(createTodoItem(todo));
ul.appendChild(frag);
countEl.textContent = `${todos.filter(t => !t.done).length} 项未完成`;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
📚 事件委托三件套:
e.target // 真正被点击的元素(可能是 checkbox / button / span)
e.target.closest('li[...]') // 向上找最近的 <li> 祖先
e.currentTarget // 监听器绑定的元素(这里是 ul)
2
3
closest() 是事件委托的灵魂方法——卷一第 14 章 §14.4 介绍过。它从 e.target 出发向上爬,找到第一个匹配选择器的祖先节点(包括自身)。
# 🧪 阶段 ⑤ 验收
操作:
- 添加 "买牛奶" / "遛狗" / "写代码"
- 勾选 "遛狗" → 文字应有删除线、计数变 "2 项未完成"
- 删除 "买牛奶" → 列表只剩 2 条
- 再添加 "去跑步" → 点它的 ✕ → ✅ 能正常删除(这是事件委托的胜利)
打开 DevTools → Elements → Event Listeners 面板,你会发现:
<ul>上有 2 个监听器(click + change)- 每个
<li>上0 个监听器
✅ N 条 todo 共享 1 对监听器 = 事件委托真正生效。
┌─ 📌 阶段 ⑤ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • 事件冒泡:子元素的事件会传播到父元素 │
│ • 事件委托:在父元素上绑 1 个监听器替代 N 个 │
│ • e.target.closest(selector) 向上找祖先 │
│ • dataset.action 用 data-* 属性分发动作类型 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 持久化(阶段 ⑥ localStorage) │
│ • 撤销/重做(阶段 ⑦ 命令模式) │
│ 💡 本阶段最大领悟: │
│ "DOM 事件不是绑在元素上的——是冒泡到父元素的, │
│ 所以一个父监听器就能管理无限多个子元素的事件" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
# 07.store.js 持久化
┌─ 🎯 阶段 ⑥ 目标 ────────────────────────────────────┐
│ 完成什么:刷新页面后 todos 不丢——存到 localStorage │
│ 不做什么:不接服务器(卷二案例 02 jsfeed 才接 fetch) │
│ 验收标准:添加几条 → 刷新 → 列表保持;DevTools 看到数据 │
│ 预计耗时:30 分钟 │
│ 关键思路:每次 state 变化自动 save,启动时一次性 load │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
# 7.1 为什么单独建 s
❓ 直接在 state.js 里写 localStorage.setItem(...) 不更省事?
行,但职责混乱:state 一旦关心存储,将来想换成 IndexedDB / 服务端时,state 就要大改。单一职责原则——把"存储介质"封装成独立模块。
❓ store.js 要暴露什么 API? 答:load() 读、save(todos) 写——两个就够。
❓ localStorage 会出错吗? 答:会!
- 用户在隐私模式下 localStorage 可能不可用 →
setItem抛QuotaExceededError - 用户在 DevTools 手动改成乱码 →
JSON.parse抛SyntaxError - 上次写入的数据格式不对(schema 演化)→ 字段缺失
✅ 正确做法:所有 IO 操作必须 try/catch + 默认值降级——这是卷一第 9 章错误机制的真实落地。
# 7.2 写 store.
📁 src/store.js:
const KEY = 'jstodo:v1'; // ⭐ 带版本号,将来 schema 升级好做迁移
/**
* 从 localStorage 读取 todos
* 任何异常都返回空数组,绝不让上层崩溃
*/
export function load() {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { todos: [], nextId: 1 };
const data = JSON.parse(raw);
// ⭐ schema 校验:必须是数组才接受
if (!Array.isArray(data.todos)) {
console.warn('[store] 数据格式异常,使用默认值');
return { todos: [], nextId: 1 };
}
return {
todos: data.todos,
nextId: typeof data.nextId === 'number' ? data.nextId : data.todos.length + 1,
};
} catch (err) {
console.error('[store] 读取失败:', err);
return { todos: [], nextId: 1 }; // ⭐ 兜底返回空,绝不抛出去
}
}
export function save(state) {
try {
localStorage.setItem(KEY, JSON.stringify(state));
} catch (err) {
// 可能是隐私模式 / 配额超限
console.error('[store] 写入失败:', err);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
📚 try/catch 设计哲学:
- 底层 IO 模块 必须 catch 所有异常,返回 "退化值"
- 上层业务代码 假设底层永远不抛——专心写业务逻辑
# 7.3 把 state.
📁 src/state.js(修改):
import { load, save } from './store.js';
// ⭐ 启动时从 store 恢复
const initial = load();
let todos = initial.todos;
let nextId = initial.nextId;
const subscribers = new Set();
function notify() {
// ⭐ 每次变化自动保存
save({ todos, nextId });
for (const fn of subscribers) {
try { fn(todos); }
catch (err) { console.error('[state] subscriber error:', err); }
}
}
// ... getTodos / addTodo / removeTodo / toggleTodo 不变 ...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
⚠️ save 放在 notify 里:每次数据变化都同步保存——简单可靠。如果数据量大可以做防抖(300ms 内多次变化合并一次写),但 todos 量级很小,没必要。
# 🧪 阶段 ⑥ 验收
操作 1:添加 "买牛奶 / 遛狗 / 写代码" → 刷新页面 → ✅ 三条 todo 还在。
操作 2:DevTools → Application → Local Storage → 看到 jstodo:v1 这一行:
{"todos":[{"id":1,"text":"买牛奶","done":false},...],"nextId":4}
操作 3(故意造 bug):在 DevTools 把这个值改成 not-a-json → 刷新。
预期:
- Console 输出
[store] 读取失败: SyntaxError: ... - 页面正常加载、显示空列表——而不是白屏
- 添加新 todo 又能正常工作
✅ 这就是错误兜底的价值——坏数据不该让用户看到白屏。
┌─ 📌 阶段 ⑥ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • localStorage 同步 KV API │
│ • try/catch 包住所有 IO 操作 │
│ • schema 校验 + 版本号 + 默认值降级三件套 │
│ • 模块单一职责:store.js 不知道 todo 长什么样 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 撤销重做(阶段 ⑦ 命令模式) │
│ • 增量 DOM diff(阶段 ⑧) │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
# 08.命令模式撤销重做
┌─ 🎯 阶段 ⑦ 目标 ────────────────────────────────────┐
│ 完成什么:Ctrl+Z 撤销 / Ctrl+Y 重做 任意操作 │
│ 不做什么:不做编辑命令——挑战题让你自己加 EditCommand │
│ 验收标准:连续 5 次操作 → 按 5 次 Ctrl+Z 全部还原 │
│ 预计耗时:60 分钟 │
│ 关键思路:每个用户操作 = 一个 Command 对象 → 推入 undoStack│
│ Ctrl+Z = 弹出顶部 cmd → 调 cmd.undo() → 推入 redoStack│
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
本节是案例 01 的最大高光段落 —— 命令模式不仅是教科书的 23 种 GoF 设计模式之一,更是 VSCode / Photoshop / IDEA 这些专业软件的核心架构。
# 8.1 为什么需要命令模式
❓ 撤销不就是 todos.pop() 吗?
行,但只能撤销"add"。如果用户先 add 再 delete 再 toggle,pop 一下能还原 toggle 吗?不能。 问题本质:你需要把"用户做过什么"完整记录下来——包括"做了什么"和"反过来怎么撤"。
✅ 正确做法:每个用户操作打包成一个 Command 对象,对象里同时持有 execute() 和 undo() 两个方法——这就是命令模式的灵魂。
❓ 为什么用 class 不用普通函数?
// 函数式写法
const addCmd = { do: () => state.add(text), undo: () => state.remove(id) };
// class 写法
class AddCommand { execute() {...} undo() {...} }
2
3
4
两种都对。class 的好处是:① 多个相同类型的命令共享方法(节省内存);② 可以 extends 复用;③ 清晰表达"这是一种命令"的语义。当命令种类 > 3 时,class 更易扩展。
❓ 第一步做什么? 答:先写 Command 抽象基类 + AddCommand 一个子类,跑通 "add → 撤销 → 重做" 一条路。
# 8.2 写 Comman
📁 src/commands.js:
import { addTodo, removeTodo, toggleTodo, getTodos } from './state.js';
/** 抽象基类 */
class Command {
execute() { throw new Error('Command.execute() must be overridden'); }
undo() { throw new Error('Command.undo() must be overridden'); }
}
/** 添加命令:execute = state.addTodo / undo = state.removeTodo */
export class AddCommand extends Command {
constructor(text) {
super();
this.text = text;
this.createdId = null; // execute 时记录 id,undo 时用
}
execute() {
const todo = addTodo(this.text);
if (todo) { this.createdId = todo.id; return true; }
return false;
}
undo() {
if (this.createdId != null) removeTodo(this.createdId);
}
}
/** 删除命令:需要保存被删的 todo 完整数据,否则 undo 时数据丢了 */
export class DeleteCommand extends Command {
constructor(id) {
super();
this.id = id;
this.snapshot = null; // 删之前先快照
}
execute() {
this.snapshot = getTodos().find(t => t.id === this.id);
if (!this.snapshot) return false;
removeTodo(this.id);
return true;
}
undo() {
if (!this.snapshot) return;
// ⭐ 注意:addTodo 会分配新 id,原 id 找不回来——这是已知局限
// 真正工业级实现会让 state 提供 "按指定 id 恢复" 的 API
addTodo(this.snapshot.text);
}
}
/** 切换完成状态:undo = 再 toggle 一次 */
export class ToggleCommand extends Command {
constructor(id) { super(); this.id = id; }
execute() { return toggleTodo(this.id); }
undo() { toggleTodo(this.id); }
}
// ⭐⭐⭐ 调度器:双栈实现撤销重做
const undoStack = [];
const redoStack = [];
export function execute(cmd) {
if (cmd.execute() === false) return; // 命令被拒(如空字符串),不入栈
undoStack.push(cmd);
redoStack.length = 0; // ⭐ 新操作发生,清空 redo 栈
}
export function undo() {
const cmd = undoStack.pop();
if (!cmd) return;
cmd.undo();
redoStack.push(cmd);
}
export function redo() {
const cmd = redoStack.pop();
if (!cmd) return;
cmd.execute();
undoStack.push(cmd);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
📚 核心机制 · "新操作清空 redo 栈"(redoStack.length = 0):
场景:用户 add 5 次,按 Ctrl+Z 3 次回退到第 2 步 → 此时再 add 一条新的
→ 原本"前进 3 步"的 redo 历史**应该作废**(不然会出现幽灵分支)
这是所有"线性历史"的撤销系统都要遵守的规则(Word / VSCode / IDEA 全都这样)
2
3
# 8.3 改造 main.
📁 src/main.js(修改:所有用户输入走 commands.execute):
import { getTodos, subscribe } from './state.js';
import { render } from './view.js';
import { AddCommand, undo, redo } from './commands.js';
subscribe(render);
render(getTodos());
const form = document.querySelector('#todo-form');
const input = document.querySelector('#todo-input');
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
// ⭐ 不直接调 addTodo,而是包装成命令
import('./commands.js').then(({ execute, AddCommand }) => {
execute(new AddCommand(text));
input.value = '';
});
});
// ⭐ Ctrl+Z / Ctrl+Y 全局快捷键
document.addEventListener('keydown', (e) => {
// 在输入框里时,让浏览器原生的输入法撤销正常工作
if (e.target.tagName === 'INPUT' && e.target !== input) return;
const isMod = e.ctrlKey || e.metaKey; // Ctrl (Win/Linux) or Cmd (Mac)
if (isMod && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
if (isMod && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault(); redo();
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
💡 细节:Mac 用户的 redo 习惯是
Cmd+Shift+Z,Windows 是Ctrl+Y——上面同时支持两种。
📁 src/view.js(修改:删除/勾选也走命令):
import { execute, DeleteCommand, ToggleCommand } from './commands.js';
ul.addEventListener('click', (e) => {
const li = e.target.closest('li[data-id]');
if (!li) return;
const id = Number(li.dataset.id);
if (e.target.dataset.action === 'delete') {
execute(new DeleteCommand(id));
}
});
ul.addEventListener('change', (e) => {
if (e.target.dataset.action !== 'toggle') return;
const li = e.target.closest('li[data-id]');
if (!li) return;
execute(new ToggleCommand(Number(li.dataset.id)));
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 🧪 阶段 ⑦ 验收
操作(每一步都要验证):
- 添加 "买牛奶" / "遛狗" / "写代码"
- 勾选 "遛狗"
- 删除 "买牛奶"
- 现在按 5 次 Ctrl+Z(Mac 用 Cmd+Z)→ 应该回到完全空列表
- 按 5 次 Ctrl+Y → 应该重新前进到第 5 步状态
- 任意撤销几步 → 再添加一条新 todo → 此时 Ctrl+Y 应该不再前进(redo 栈被清空)
✅ 5 步全部正确 = 命令模式真正生效。
┌─ 📌 阶段 ⑦ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • 命令模式:操作打包成对象(execute + undo) │
│ • class extends 抽象基类 + 子类各自实现 │
│ • 双栈:undoStack / redoStack 互推 │
│ • "新操作清空 redo" 的线性历史铁律 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • 增量 DOM diff(阶段 ⑧ 性能优化) │
│ 💡 本阶段最大领悟: │
│ "VSCode/IDEA/Photoshop 的核心架构,原理就这 100 行代码"│
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
# 09.增量 DOM diff
┌─ 🎯 阶段 ⑧ 目标 ────────────────────────────────────┐
│ 完成什么:把全量重渲改成"按 id 复用 li 节点"的增量更新 │
│ 不做什么:不写真正的 vdom(Vue/React 那种)——挑战题 │
│ 验收标准:1000 条 todo 操作时,performance 显示 80% 性能提升│
│ 预计耗时:30 分钟 │
│ 关键思路:先用 performance.now() 测量当前性能 → 再优化 │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
# 9.1 为什么需要 di
刚才你的 render() 每次都执行:
ul.innerHTML = ''; // 销毁全部 N 个旧节点
for (const todo of todos) ... // 重建全部 N 个新节点
2
痛点:100 条 todo,只是勾选了第 50 条——浏览器要销毁 100 个 + 创建 100 个 DOM 节点。99% 的 DOM 操作是冗余的。
# 9.2 先测量再优化
📁 在 view.js 顶部加性能测量:
export function render(todos) {
const t0 = performance.now();
// ...原全量重渲代码...
console.log(`[render full] ${todos.length} items in ${(performance.now() - t0).toFixed(2)}ms`);
}
2
3
4
5
🧪 临时在 main.js 启动时插 1000 条假数据:
import { addTodo } from './state.js';
for (let i = 0; i < 1000; i++) addTodo('item ' + i);
2
刷新 → Console 看到类似:[render full] 1000 items in 38.21ms——38ms 已经接近 60FPS 帧时长(16.7ms)的 2 倍,肉眼可感卡顿。
# 9.3 改成增量 diff
const itemCache = new Map(); // ⭐ id → li 节点的复用缓存
export function render(todos) {
const t0 = performance.now();
const seen = new Set();
const frag = document.createDocumentFragment();
for (const todo of todos) {
seen.add(todo.id);
let li = itemCache.get(todo.id);
if (!li) {
li = createTodoItem(todo); // 新增节点
itemCache.set(todo.id, li);
} else {
// ⭐ 节点已存在,只更新变化的字段
li.classList.toggle('done', todo.done);
const checkbox = li.querySelector('input[type=checkbox]');
if (checkbox.checked !== todo.done) checkbox.checked = todo.done;
// text 一般不变,这里就不更新;如果做编辑功能再加
}
frag.appendChild(li); // ⭐ appendChild 已存在的节点 = 移动它的位置
}
// ⭐ 清掉缓存里已删除的 id
for (const id of itemCache.keys()) {
if (!seen.has(id)) itemCache.delete(id);
}
ul.innerHTML = '';
ul.appendChild(frag);
countEl.textContent = `${todos.filter(t => !t.done).length} 项未完成`;
console.log(`[render diff] ${todos.length} items in ${(performance.now() - t0).toFixed(2)}ms`);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
📚 关键技巧 · appendChild 移动而非复制:DOM 规范规定,把一个已经在文档里的节点 appendChild 到另一个父节点,不会创建新节点,而是把它从旧位置移走。这就是为什么我们能"复用"旧节点。
# 🧪 阶段 ⑧ 验收
刷新 → 1000 条数据初始化 → 勾选第 500 条。
预期 Console:
[render diff] 1000 items in 6.82ms ← 从 38ms 降到 7ms,5 倍加速
DevTools → Performance 录制 → 你会看到 Layout(重排)次数从 N 次降为 1 次。
✅ 数据数量级压缩 + 帧时长达标 = 阶段 ⑧ 完成。
💡 真正的 vdom(Vue/React)做了什么超越本案例?答:① 同 key 节点的位置移动检测 ② 跨层级移动 ③ 自动 batch 多次 setState ④ 异步调度。本案例只做了 "key-based 复用"——已经够你应付 99% 的需求。
┌─ 📌 阶段 ⑧ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的: │
│ • performance.now() 量化前端性能 │
│ • Map 做 id → 节点的复用缓存 │
│ • DOM 节点移动 vs 销毁重建的本质区别 │
│ • DocumentFragment 配合 cache 实现一次 reflow 渲染 │
│ 💡 本阶段最大领悟: │
│ "Vue/React 比你强的不是新概念,而是把这套 diff │
│ 自动化到了组件粒度" │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
# 10.项目总结分析
# 10.1 整体目录结构(最
jstodo/
├── index.html # 60 行:HTML 骨架
├── style.css # 140 行:CSS 变量 + flex 布局
└── src/
├── main.js # 80 行:装配中心 + 全局快捷键
├── state.js # 90 行:闭包封装 + 订阅 + 持久化对接
├── view.js # 130 行:DOM 渲染 + 事件委托 + 增量 diff
├── store.js # 50 行:localStorage 读写 + 异常兜底
└── commands.js # 130 行:Command 基类 + 3 个子类 + 双栈
2
3
4
5
6
7
8
9
约 600 行 JS + 200 行 HTML/CSS = 800 行——一个真正能用的待办应用,0 第三方依赖。
# 10.2 核心原理一句话总结
| 模块 | 一句话 |
|---|---|
| state | 闭包锁住 todos,外部只能通过函数访问 |
| view | createElement + textContent 安全渲染;事件委托替代逐项绑定 |
| store | 任何 IO 都要 try/catch + 默认值降级 |
| commands | 把"用户操作"打包成对象,双栈管理历史 |
| 整体 | 单向数据流:操作 → 命令 → state → 订阅 → view |
# 10.3 优缺点分析总结
✅ 本案例的优点:
- 零依赖:能跑在最干净的浏览器里,理解 JS 本质
- 职责清晰:5 个模块各司其职,可以独立替换任意一个
- 体感真实:故意造 bug → 修复的教学法,让坑刻进脑子
⚠️ 已知局限(这些不是缺陷,是教学权衡):
- DeleteCommand 的 undo 会产生新 id(原 id 丢失)—— 真正解决要让 state 提供
restoreById(id, todo)API,留作挑战 B - 没有编辑功能 —— 留作挑战 C(你自己写一个 EditCommand 扩展命令模式)
- view.render 的 textContent 不变就跳过更新——目前每次都同步 checkbox 状态,可以优化
# 11.项目技术思考
# 11.1 闭包 vs cl
state.js 用闭包,commands.js 用 class——为什么不统一?
| 维度 | 闭包模块(state.js) | class(Command) |
|---|---|---|
| 实例数 | 单例(整个应用 1 个) | 每个用户操作 1 个 |
| 继承 | 用不上 | AddCommand extends Command |
| 内存 | 私有变量驻留模块 | 每个实例独立内存 |
| 适用 | 全局状态、配置 | 多态对象、事件、命令 |
结论:单例用闭包,多实例用 class。
# 11.2 事件委托的代价
事件委托不是没有缺点:
| 优点 | 缺点 |
|---|---|
| 监听器数量从 N → 1 | 所有 click 都过一遍这个监听器 |
| 动态新增节点不需要重新绑事件 | 需要写选择器/dataset 来识别"是不是我关心的元素" |
| 内存占用小 | e.target.closest() 有性能开销(每次冒泡都遍历祖先) |
结论:列表型 UI(todo / 表格 / 评论)用委托;少量、固定的全局按钮(顶部 logo / 登出)用直接绑定即可。
# 11.3 命令模式 vs
撤销重做还有另一种实现思路——每次操作前把整个 state 拍快照。
| 方案 | 命令模式 | 状态快照 |
|---|---|---|
| 内存 | 每命令几十字节 | 每快照可能 KB 级 |
| 实现复杂度 | 每种操作要写 execute+undo | 几行代码(深拷贝) |
| 适用场景 | 操作种类有限、状态大 | 操作种类无限、状态小 |
Photoshop / VSCode 用命令模式(操作种类有限,文档可能 GB 级);Redux DevTools 时光旅行用状态快照(每个 action 都不同,state 通常很小)。
# 12.卷一章节反向索引
| 案例段落 | 对应卷一章节 | 知识点 |
|---|---|---|
| §02 ESM 入口 | 第 10.3.1 ESM import/export | <script type="module"> |
| §03 闭包封装 todos | 第 04.7 闭包 / 第 02.5 对象 | 模块级闭包 |
| §03 subscribe 返回 unsubscribe | 第 04.5.2 高阶函数 | 函数返回函数 |
| §04 createElement / textContent | 第 14.2 DOM 创建 / 第 14.3 安全 | 节点级渲染 |
| §05 form.submit + preventDefault | 第 08.3.1 事件对象 | 阻止默认行为 |
| §06 事件委托 + closest | 第 08.4.2 冒泡 / 第 08.5 委托 | 委托三件套 |
| §07 try/catch 兜底 JSON.parse | 第 09.2 try/catch | 错误边界 |
| §08 class + extends | 第 05.3 class 语法 / 第 05.4 继承 | 命令模式 |
| §09 Map 节点缓存 | 第 02.5 Map / 第 06.4 标准库 | 复用缓存 |
| 全章 | 第 10.6.1 模块封装模式 | 单一职责 |
# 13.衔接与延伸
# 13.1 与下一案例 js
| 维度 | 本案例 jstodo | 下一案例 jsfeed |
|---|---|---|
| 数据来源 | localStorage(同步) | fetch 网络请求(异步) |
| 状态管理 | 全量在内存 | 流式逐条处理(async iterator) |
| 错误模型 | 同步 try/catch | 自定义 Error 体系 + AggregateError |
| 重点 | DOM/事件 | Promise/生成器 |
本案例的 store.js 在下一案例会被升级:换成 fetch + 缓存层(IndexedDB + 离线降级),让你看到 "同样的 store 接口,可以接不同存储"。
# 13.2 三个延伸挑战
⭐ 挑战 A · 基础:增加 EditCommand
双击
<span>进入编辑态(<span>替换为<input>),失焦或回车保存。 关键:在 commands.js 加class EditCommand extends Command,保存旧文本用于 undo。
⭐⭐ 挑战 B · 进阶:让 DeleteCommand.undo 恢复原 id
当前 DeleteCommand undo 会让 todo 拿到一个新 id——这破坏了 "id 是唯一身份" 的语义。 提示:让 state.js 暴露
restoreById(id, todo)API,addTodo 升级为addTodo(text, opts?: { id }),并维护nextId = max(nextId, id+1)。
⭐⭐⭐ 挑战 C · 现代化:换成 Web Component
把
<li>节点替换为自定义元素<todo-item>:class TodoItem extends HTMLElement { connectedCallback() {...} } customElements.define('todo-item', TodoItem);1
2思考:Web Component 的 Shadow DOM 隔离样式,如何与全局 CSS 变量(深色主题)协同?
🎉 恭喜!你已经完成了 JS 综合案例的第一关。
此时你应该有一个真正能用的 jstodo 应用——刷新页面数据不丢、Ctrl+Z 撤销重做、1000 条数据流畅渲染。这份代码的每一行都是你亲手敲的,没有一行黑盒框架代码。
➡ 下一章:案例 02 · 异步订阅流式解析