编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

      • README
      • 待办清单事件驱动
        • 渐进学习节奏
        • 案例背景信息
          • 项目目录结构
          • 一条命令跑起来
        • 目录快速导航
        • 01.项目需求和功能
          • 1.1 需求介绍说明
          • 1.2 功能矩阵总览
          • 1.3 模块分工说明
          • 1.4 知识点速查
        • 02.HTML 骨架与 ESM 入口
          • 2.1 创建项目空文件
          • 2.2 写 index.
          • 2.3 写最小 main
          • 2.4 写一点点 CSS
          • 🧪 阶段 ① 验收
        • 03.state.js 数据层
          • 3.1 为什么要单独建
          • 3.2 写第一版 sta
          • 3.3 在 main.j
          • 🧪 阶段 ② 验收
        • 04.view.js 静态渲染
          • 4.1 为什么不用 in
          • 4.2 写第一版 vie
          • 4.3 在 main.j
          • 🧪 阶段 ③ 验收
        • 05.添加 todo
          • 5.1 为什么是 sub
          • 5.2 表单事件绑定
          • 5.3 接通 state
          • 🧪 阶段 ④ 验收
        • 06.事件委托 - 删除与勾选
          • 6.1 故意造 bug
          • 6.2 改成事件委托
          • 🧪 阶段 ⑤ 验收
        • 07.store.js 持久化
          • 7.1 为什么单独建 s
          • 7.2 写 store.
          • 7.3 把 state.
          • 🧪 阶段 ⑥ 验收
        • 08.命令模式撤销重做
          • 8.1 为什么需要命令模式
          • 8.2 写 Comman
          • 8.3 改造 main.
          • 🧪 阶段 ⑦ 验收
        • 09.增量 DOM diff
          • 9.1 为什么需要 di
          • 9.2 先测量再优化
          • 9.3 改成增量 diff
          • 🧪 阶段 ⑧ 验收
        • 10.项目总结分析
          • 10.1 整体目录结构(最
          • 10.2 核心原理一句话总结
          • 10.3 优缺点分析总结
        • 11.项目技术思考
          • 11.1 闭包 vs cl
          • 11.2 事件委托的代价
          • 11.3 命令模式 vs
        • 12.卷一章节反向索引
        • 13.衔接与延伸
          • 13.1 与下一案例 js
          • 13.2 三个延伸挑战
      • 异步订阅流式解析
      • 视频播放器自实现
      • 图表看板全栈开发
    • 专栏博客

  • CodeX
  • JavaScript入门
  • 综合案例
杨充
2026-06-11
目录

待办清单事件驱动

# 第一章: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: 对比性能数据
1
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 必须做的三件事:

  1. 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
  2. 写一小段代码就刷新浏览器看一次(看到 🧪 标志立刻动手)
  3. 看到预期输出再写下一个 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 基类 + 双栈撤销重做
1
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
1
2
3

⚠️ 新手陷阱:直接双击 index.html 用 file:// 打开会报 CORS error——这是因为 ESM 默认要求 HTTP 协议下加载。必须起一个本地 HTTP 服务器。


# 目录快速导航

点击以下条目即可跳转。

  • 01. 项目需求和功能
    • 1.1 需求介绍说明
    • 1.2 功能矩阵总览
    • 1.3 模块分工说明
    • 1.4 知识点速查
  • 02. HTML 骨架与 ESM 入口
    • 2.1 创建项目空文件
    • 2.2 写 index.
    • 2.3 写最小 main
    • 2.4 写一点点 CSS
  • 03. state.js 数据层
    • 3.1 为什么要单独建
    • 3.2 写第一版 sta
    • 3.3 在 main.j
  • 04. view.js 静态渲染
    • 4.1 为什么不用 in
    • 4.2 写第一版 vie
    • 4.3 在 main.j
  • 05. 添加 todo
    • 5.1 为什么是 sub
    • 5.2 表单事件绑定
    • 5.3 接通 state
  • 06. 事件委托 - 删除与勾选
    • 6.1 故意造 bug
    • 6.2 改成事件委托
  • 07. store.js 持久化
    • 7.1 为什么单独建 s
    • 7.2 写 store.
    • 7.3 把 state.
  • 08. 命令模式撤销重做
    • 8.1 为什么需要命令模式
    • 8.2 写 Comman
    • 8.3 改造 main.
  • 09. 增量 DOM diff
    • 9.1 为什么需要 di
    • 9.2 先测量再优化
    • 9.3 改成增量 diff
  • 10. 项目总结分析
    • 10.1 整体目录结构(最
    • 10.2 核心原理一句话总结
    • 10.3 优缺点分析总结
  • 11. 项目技术思考
    • 11.1 闭包 vs cl
    • 11.2 事件委托的代价
    • 11.3 命令模式 vs
  • 12. 卷一章节反向索引
  • 13. 衔接与延伸
    • 13.1 与下一案例 js
    • 13.2 三个延伸挑战

# 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 │
       └──────┬──────┘
              │ &lt;script type="module" src="src/main.js">
              ▼
        ┌──────────┐
        │ main.js  │   ← 装配中心
        └────┬─────┘
             │
   ┌─────────┼──────────┬───────────┐
   ▼         ▼          ▼           ▼
state.js   view.js   store.js   commands.js
(数据)   (视图)   (持久化)  (撤销栈)
1
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()
1
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 模块管道打通——这是后续所有阶段的根  │
└──────────────────────────────────────────────────┘
1
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
1
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>
1
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');
1

🤔 不要写 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; }
1
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
1
2

操作:浏览器打开 http://localhost:8000 → F12 打开 Console。

预期 Console 输出:

[main] hello jstodo
1

✅ 看到日志 = ESM 管道打通。 ❌ 看到 Failed to load module script: ...MIME type ("text/html") = index.html 路径错或 main.js 路径错。 ❌ 看到 CORS policy blocked = 你直接双击打开了 file://...——必须用 http://localhost。

┌─ 📌 阶段 ① 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                          │
│   • &lt;script type="module"> 加载入口 JS                    │
│   • python3 -m http.server 起本地服务器避免 CORS          │
│   • 5 个空文件占位的"按需生长"开发节奏                    │
│ ⏸ 还没碰的(下阶段才会做):                               │
│   • state.js 数据层(阶段 ②)                              │
│   • DOM 渲染(阶段 ③)                                     │
│   • 事件处理(阶段 ④⑤)                                    │
└──────────────────────────────────────────────────┘
1
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 分钟                                       │
│ 关键思路:闭包封装私有状态 + 发布订阅模式                │
└──────────────────────────────────────────────────┘
1
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 ——
// 你永远不知道是谁、什么时候改了它
1
2
3
4
5
6

问题暴露:

  1. todos 是全局可写——任何模块都能直接突变它(违反封装)
  2. 数据变了没人知道——view 必须主动轮询才能更新
  3. 散在多个文件里改 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);
}
1
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());
1
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) [...]
1
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 不是变量,是一个被闭包保护的、有出入口的房间"   │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12

# 04.view.js 静态渲染

┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────┐
│ 完成什么:把 todos 数组渲染成 &lt;li> 节点列表             │
│ 不做什么:不处理点击事件——纯渲染                        │
│ 验收标准:调用 render([...]) 后,页面上看到对应条目      │
│ 预计耗时:40 分钟                                       │
│ 关键思路:先用最朴素的"全量重渲"——阶段 ⑧ 再优化为 diff  │
└──────────────────────────────────────────────────┘
1
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 = ...;  // ← 攻击成功,弹窗
1
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} 项未完成`;
}
1
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('遛狗');
1
2
3
4
5
6
7
8
9
10
11
12

# 🧪 阶段 ③ 验收

刷新浏览器:

预期界面:

┌──────────────────────────────────────┐
│  jstodo                              │
│  ┌──────────────────────────┐ [添加]  │
│  │ 今天要做什么?             │        │
│  └──────────────────────────┘        │
│                                      │
│  ☐  买牛奶                  ✕       │
│  ☐  遛狗                    ✕       │
│                                      │
│  2 项未完成    [全部] 未完成 已完成   │
└──────────────────────────────────────┘
1
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(阶段 ④)                          │
│   • 点击勾选/删除(阶段 ⑤ 事件委托)                    │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

# 05.添加 todo

┌─ 🎯 阶段 ④ 目标 ────────────────────────────────────┐
│ 完成什么:用户在输入框输入文字 → 点击"添加"或回车 → 出现│
│ 不做什么:不做删除、不做勾选——下阶段                   │
│ 验收标准:能持续添加 N 条 todo,空字符串被拒绝          │
│ 预计耗时:40 分钟                                       │
│ 关键思路:表单 submit 事件 + preventDefault 阻止默认刷新 │
└──────────────────────────────────────────────────┘
1
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);   // 第一步先打日志
});
1
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 自动跑
});
1
2
3
4
5
6
7

# 🧪 阶段 ④ 验收

操作:

  1. 输入 "买牛奶" → 回车 → 看到列表新增、输入框清空
  2. 直接回车(输入空白)→ 列表无变化、输入框保留
  3. 输入 "遛狗" → 点 [添加] 按钮 → 也能添加

✅ 看到三种行为都符合预期 = 阶段 ④ 完成。

┌─ 📌 阶段 ④ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • form.submit + preventDefault 接管表单提交           │
│   • 用订阅自动触发 view.render(无需手动调)            │
│   • 数据校验放在 state(addTodo 拒绝空串)              │
│ ⏸ 还没碰的(下阶段才会做):                            │
│   • 删除按钮(阶段 ⑤)                                  │
│   • 勾选完成(阶段 ⑤)                                  │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

# 06.事件委托 - 删除与勾选

┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────┐
│ 完成什么:点击 ✕ 删除 / 点 checkbox 切换完成状态        │
│ 不做什么:不做编辑、不做撤销                            │
│ 验收标准:N 条 todo 都能正确响应点击;新增的也能响应     │
│ 预计耗时:50 分钟                                       │
│ 关键思路:先故意每项单独绑事件 → 看到 bug → 改成事件委托 │
└──────────────────────────────────────────────────┘
1
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;
}
1
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;
}
1
2
3
4
5
6
7
8
9
10
11
12

🧪 刷新 → 添加 "买牛奶" → 点 ✕ → ✅ 删除成功。 🧪 再添加 "遛狗" → 点 "遛狗" 旁边的 ✕ → 🚨 没反应!

❓ 为什么?打开 DevTools 看一下监听器:

原因分析:
1. 添加"买牛奶" → state 触发订阅 → view.render() → ul.innerHTML='' → 旧 li 节点全部销毁
2. 重渲时 createTodoItem 重新生成 &lt;li> + 绑了新事件——OK
3. 添加"遛狗" → 又触发 render → "买牛奶"的 li 又被销毁重建一次
4. 但是!用户点击"遛狗"按钮时,点的其实是 render 完之后的新 li——监听器其实是在的

🤔 这个版本其实"看起来能用",但有 3 个隐藏问题:
   ① 内存泄漏:每次 render 都重新绑定 N 个监听器,旧的被 GC 慢
   ② 性能:100 条 todo 每次 render 绑 200 个监听器(checkbox + button)
   ③ 真正的灾难:阶段 ⑧ 改成增量 diff 后,复用 li 节点不重新创建——
      这时新增的 todo 上没有监听器,bug 会真正暴露
1
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} 项未完成`;
}
1
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)
1
2
3

closest() 是事件委托的灵魂方法——卷一第 14 章 §14.4 介绍过。它从 e.target 出发向上爬,找到第一个匹配选择器的祖先节点(包括自身)。

# 🧪 阶段 ⑤ 验收

操作:

  1. 添加 "买牛奶" / "遛狗" / "写代码"
  2. 勾选 "遛狗" → 文字应有删除线、计数变 "2 项未完成"
  3. 删除 "买牛奶" → 列表只剩 2 条
  4. 再添加 "去跑步" → 点它的 ✕ → ✅ 能正常删除(这是事件委托的胜利)

打开 DevTools → Elements → Event Listeners 面板,你会发现:

  • <ul> 上有 2 个监听器(click + change)
  • 每个 <li> 上0 个监听器

✅ N 条 todo 共享 1 对监听器 = 事件委托真正生效。

┌─ 📌 阶段 ⑤ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • 事件冒泡:子元素的事件会传播到父元素                │
│   • 事件委托:在父元素上绑 1 个监听器替代 N 个          │
│   • e.target.closest(selector) 向上找祖先              │
│   • dataset.action 用 data-* 属性分发动作类型           │
│ ⏸ 还没碰的(下阶段才会做):                            │
│   • 持久化(阶段 ⑥ localStorage)                       │
│   • 撤销/重做(阶段 ⑦ 命令模式)                        │
│ 💡 本阶段最大领悟:                                      │
│   "DOM 事件不是绑在元素上的——是冒泡到父元素的,         │
│    所以一个父监听器就能管理无限多个子元素的事件"        │
└──────────────────────────────────────────────────┘
1
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   │
└──────────────────────────────────────────────────┘
1
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);
  }
}
1
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 不变 ...
1
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}
1

操作 3(故意造 bug):在 DevTools 把这个值改成 not-a-json → 刷新。

预期:

  • Console 输出 [store] 读取失败: SyntaxError: ...
  • 页面正常加载、显示空列表——而不是白屏
  • 添加新 todo 又能正常工作

✅ 这就是错误兜底的价值——坏数据不该让用户看到白屏。

┌─ 📌 阶段 ⑥ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • localStorage 同步 KV API                           │
│   • try/catch 包住所有 IO 操作                          │
│   • schema 校验 + 版本号 + 默认值降级三件套             │
│   • 模块单一职责:store.js 不知道 todo 长什么样         │
│ ⏸ 还没碰的(下阶段才会做):                            │
│   • 撤销重做(阶段 ⑦ 命令模式)                         │
│   • 增量 DOM diff(阶段 ⑧)                             │
└──────────────────────────────────────────────────┘
1
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│
└──────────────────────────────────────────────────┘
1
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() {...} }
1
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);
}
1
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 全都这样)
1
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();
  }
});
1
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)));
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 🧪 阶段 ⑦ 验收

操作(每一步都要验证):

  1. 添加 "买牛奶" / "遛狗" / "写代码"
  2. 勾选 "遛狗"
  3. 删除 "买牛奶"
  4. 现在按 5 次 Ctrl+Z(Mac 用 Cmd+Z)→ 应该回到完全空列表
  5. 按 5 次 Ctrl+Y → 应该重新前进到第 5 步状态
  6. 任意撤销几步 → 再添加一条新 todo → 此时 Ctrl+Y 应该不再前进(redo 栈被清空)

✅ 5 步全部正确 = 命令模式真正生效。

┌─ 📌 阶段 ⑦ 小结 ──────────────────────────────────┐
│ ✅ 你刚刚掌握的:                                       │
│   • 命令模式:操作打包成对象(execute + undo)          │
│   • class extends 抽象基类 + 子类各自实现               │
│   • 双栈:undoStack / redoStack 互推                    │
│   • "新操作清空 redo" 的线性历史铁律                    │
│ ⏸ 还没碰的(下阶段才会做):                            │
│   • 增量 DOM diff(阶段 ⑧ 性能优化)                    │
│ 💡 本阶段最大领悟:                                      │
│   "VSCode/IDEA/Photoshop 的核心架构,原理就这 100 行代码"│
└──────────────────────────────────────────────────┘
1
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() 测量当前性能 → 再优化  │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7

# 9.1 为什么需要 di

刚才你的 render() 每次都执行:

ul.innerHTML = '';                  // 销毁全部 N 个旧节点
for (const todo of todos) ...       // 重建全部 N 个新节点
1
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`);
}
1
2
3
4
5

🧪 临时在 main.js 启动时插 1000 条假数据:

import { addTodo } from './state.js';
for (let i = 0; i < 1000; i++) addTodo('item ' + i);
1
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`);
}
1
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 倍加速
1

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        │
│    自动化到了组件粒度"                                  │
└──────────────────────────────────────────────────┘
1
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 个子类 + 双栈
1
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 · 异步订阅流式解析

上次更新: 2026/06/16, 14:18:46
README
异步订阅流式解析

← README 异步订阅流式解析→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式