告别割裂式学习:待办清单项目,一次性掌握数组、本地存储与事件委托
你也许背过
map、filter的用法,也用过localStorage,但一遇到真实项目就不知道怎么组合?
本文通过一个完整的待办清单应用,带你真正理解:数据驱动视图、状态持久化、如何优雅地操作数组。
重点:我会用最通俗的白话讲清楚「事件委托」——新手最头疼的概念之一。
为什么第二个项目必须是待办清单?
待办清单(TodoMVC)被称为“前端的力学题”。它看起来简单,却包含了现代 Web 应用的核心模式:
- 数据模型:用数组存储对象,每个对象有
id、text、completed - 增删改查(CRUD):添加、删除、更新完成状态
- 数据持久化:
localStorage保存数据,刷新页面不丢失 - 事件委托:处理动态生成的 DOM 元素
- 条件渲染:根据数据状态展示不同样式
- 筛选过滤:全部/未完成/已完成
完成它之后,你会发现很多中大型项目的基础模块都长这样。
第一步:搭建界面(HTML + CSS)
我们先写好一个干净、现代的界面:输入框、添加按钮、待办列表、筛选栏和统计信息。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>待办清单|JavaScript 实战</title>
<style>
* { box-sizing: border-box; }
body {
background: #f1f5f9;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
padding: 2rem;
}
.todo-app {
max-width: 500px;
width: 100%;
background: white;
border-radius: 1rem;
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
padding: 1.5rem;
}
h1 { margin-top: 0; font-size: 1.8rem; color: #0f172a; }
.add-form { display: flex; gap: 8px; margin-bottom: 1.5rem; }
.add-form input {
flex: 1; padding: 10px; border: 1px solid #cbd5e1;
border-radius: 8px; font-size: 1rem;
}
.add-form button {
background: #3b82f6; color: white; border: none;
border-radius: 8px; padding: 0 16px; cursor: pointer;
}
.todo-list { list-style: none; padding: 0; margin: 0 0 1rem 0; }
.todo-item {
display: flex; align-items: center; gap: 12px;
padding: 10px; border-bottom: 1px solid #e2e8f0;
}
.todo-item.completed span { text-decoration: line-through; color: #94a3b8; }
.todo-item span { flex: 1; }
.delete-btn {
background: #ef4444; color: white; border: none;
border-radius: 6px; padding: 4px 12px; cursor: pointer;
}
.filter-bar { display: flex; gap: 8px; margin-top: 1rem; }
.filter-btn {
background: #e2e8f0; border: none; border-radius: 20px;
padding: 6px 12px; cursor: pointer;
}
.filter-btn.active { background: #3b82f6; color: white; }
.stats { margin-top: 1rem; font-size: 0.9rem; color: #475569; text-align: center; }
</style>
</head>
<body>
<div class="todo-app">
<h1>✅ 待办清单</h1>
<div class="add-form">
<input type="text" id="todoInput" placeholder="写一个待办..." autocomplete="off">
<button id="addBtn">添加</button>
</div>
<ul class="todo-list" id="todoList"></ul>
<div class="filter-bar">
<button class="filter-btn active" data-filter="all">全部</button>
<button class="filter-btn" data-filter="active">未完成</button>
<button class="filter-btn" data-filter="completed">已完成</button>
</div>
<div class="stats" id="stats"></div>
</div>
<script>
// 所有 JavaScript 代码将放在这里
</script>
</body>
</html>
第二步:核心知识点拆解(重点:事件委托)
2.1 数据结构设计
每个待办项是一个对象:
{
id: Date.now(), // 唯一标识,不用下标避免删除错乱
text: '学习JavaScript',
completed: false
}
整个应用的数据就是一个数组 todos。
2.2 从 localStorage 读取和保存
function loadTodos() {
const stored = localStorage.getItem('todos');
return stored ? JSON.parse(stored) : [];
}
function saveTodos(todos) {
localStorage.setItem('todos', JSON.stringify(todos));
}
2.3 “数据 → 视图”的渲染函数
核心思想:只要数据 todos 变了,就彻底重新生成列表 HTML。不需要手动操作每一个 DOM 元素。
let currentFilter = 'all';
function render() {
// 1. 根据筛选条件过滤
let filtered = todos;
if (currentFilter === 'active') {
filtered = todos.filter(t => !t.completed);
} else if (currentFilter === 'completed') {
filtered = todos.filter(t => t.completed);
}
// 2. 生成 HTML 字符串
const html = filtered.map(todo => `
<li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
<input type="checkbox" ${todo.completed ? 'checked' : ''}>
<span>${escapeHtml(todo.text)}</span>
<button class="delete-btn">删除</button>
</li>
`).join('');
document.getElementById('todoList').innerHTML = html;
// 3. 统计
const total = todos.length;
const completedCount = todos.filter(t => t.completed).length;
document.getElementById('stats').innerHTML = `共 ${total} 项,已完成 ${completedCount} 项`;
}
2.4 🔥 事件委托(新手必看!)
问题:为什么需要事件委托?
我们所有的待办项(包括里面的“删除”按钮和复选框)都是通过 render() 动态生成的。如果直接写:
document.querySelector('.delete-btn').addEventListener('click', function() { ... })
这行代码执行时,页面上还没有 .delete-btn(因为列表是后来才渲染出来的),所以根本绑定不上。哪怕你写在 render() 之后,每次重新渲染旧 DOM 会被替换,之前绑定的监听器也会丢失。
传统解决方案:每次渲染后重新绑定事件——非常麻烦且容易出错。
解决方案:事件委托
利用 事件冒泡 机制:点击某个元素后,事件会一直向上传播到它的父元素、祖先元素。我们可以把监听器挂到已经存在的父容器上(比如 <ul id="todoList">),然后通过 e.target 判断实际点击的是哪个子元素,再做出相应的处理。
生活类比:
- 普通绑定 = 在每个员工身上安装一个专线电话(员工离职换人,电话就没了)。
- 事件委托 = 只在部门经理那放一部总机,谁打来电话就通过分机号转给对应的人(员工换人,分机号不变,依然能找到)。
代码实现(逐行注释)
document.getElementById('todoList').addEventListener('click', (e) => {
// e 是事件对象,e.target 是用户实际点击的最深层元素(可能是按钮、复选框、<span>等)
const target = e.target;
// 关键方法:closest('.todo-item')
// 它会沿着祖先链向上查找,找到第一个匹配 '.todo-item' 选择器的元素。
// 这样无论你点击的是按钮、复选框还是文字,都能拿到当前待办项所在的 <li>
const li = target.closest('.todo-item');
if (!li) return; // 如果点击的不是待办项内部,直接忽略
// 从 li 上读取 data-id 属性,这是我们在渲染时设置的
const id = Number(li.dataset.id);
// 判断点击的是“删除按钮”
if (target.classList.contains('delete-btn')) {
// 删除:过滤掉 id 匹配的项
todos = todos.filter(t => t.id !== id);
saveTodos(todos);
render(); // 重新渲染列表
return;
}
// 判断点击的是“复选框”(input type="checkbox")
if (target.type === 'checkbox') {
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = target.checked; // 根据复选框状态更新
saveTodos(todos);
render();
}
}
});
为什么这样就能工作?
- 监听器挂载在
#todoList上,而这个<ul>自始至终存在,不会被替换。 - 每次点击,事件冒泡到
<ul>,我们检查e.target,根据点击的元素类名或类型做出不同操作。 - 对于新添加的待办项,不需要额外绑定任何事件——事件委托会自然处理。
这就是事件委托的威力:只需一个监听器,就能管理未来所有动态生成的子元素。
2.5 添加待办
function addTodo() {
const input = document.getElementById('todoInput');
const text = input.value.trim();
if (text === '') return;
const newTodo = {
id: Date.now(),
text: text,
completed: false
};
todos.push(newTodo);
saveTodos(todos);
input.value = '';
render();
}
2.6 筛选功能
改变 currentFilter,然后重新调用 render() 即可。
function setFilter(filter) {
currentFilter = filter;
// 更新按钮高亮
document.querySelectorAll('.filter-btn').forEach(btn => {
if (btn.dataset.filter === filter) btn.classList.add('active');
else btn.classList.remove('active');
});
render();
}
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => setFilter(btn.dataset.filter));
});
2.7 初始化
let todos = loadTodos();
currentFilter = 'all';
render();
// 绑定添加按钮和回车事件
document.getElementById('addBtn').addEventListener('click', addTodo);
document.getElementById('todoInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') addTodo();
});
// 事件委托已经挂载在 ul 上了,不再需要额外绑定
第三步:完整代码(可以直接运行)
下面是把所有部分整合在一起,复制保存为 .html 即可体验。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>待办清单|JavaScript 实战</title>
<style>
* { box-sizing: border-box; }
body {
background: #f1f5f9;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
padding: 2rem;
}
.todo-app {
max-width: 500px;
width: 100%;
background: white;
border-radius: 1rem;
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
padding: 1.5rem;
}
h1 { margin-top: 0; font-size: 1.8rem; color: #0f172a; }
.add-form { display: flex; gap: 8px; margin-bottom: 1.5rem; }
.add-form input {
flex: 1; padding: 10px; border: 1px solid #cbd5e1;
border-radius: 8px; font-size: 1rem;
}
.add-form button {
background: #3b82f6; color: white; border: none;
border-radius: 8px; padding: 0 16px; cursor: pointer;
}
.todo-list { list-style: none; padding: 0; margin: 0 0 1rem 0; }
.todo-item {
display: flex; align-items: center; gap: 12px;
padding: 10px; border-bottom: 1px solid #e2e8f0;
}
.todo-item.completed span { text-decoration: line-through; color: #94a3b8; }
.todo-item span { flex: 1; }
.delete-btn {
background: #ef4444; color: white; border: none;
border-radius: 6px; padding: 4px 12px; cursor: pointer;
}
.filter-bar { display: flex; gap: 8px; margin-top: 1rem; }
.filter-btn {
background: #e2e8f0; border: none; border-radius: 20px;
padding: 6px 12px; cursor: pointer;
}
.filter-btn.active { background: #3b82f6; color: white; }
.stats { margin-top: 1rem; font-size: 0.9rem; color: #475569; text-align: center; }
</style>
</head>
<body>
<div class="todo-app">
<h1>✅ 待办清单</h1>
<div class="add-form">
<input type="text" id="todoInput" placeholder="写一个待办..." autocomplete="off">
<button id="addBtn">添加</button>
</div>
<ul class="todo-list" id="todoList"></ul>
<div class="filter-bar">
<button class="filter-btn active" data-filter="all">全部</button>
<button class="filter-btn" data-filter="active">未完成</button>
<button class="filter-btn" data-filter="completed">已完成</button>
</div>
<div class="stats" id="stats"></div>
</div>
<script>
// ---------- 工具函数 ----------
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&';
if (m === '<') return '<';
if (m === '>') return '>';
return m;
});
}
// ---------- 数据持久化 ----------
function loadTodos() {
const stored = localStorage.getItem('todos');
return stored ? JSON.parse(stored) : [];
}
function saveTodos(todos) {
localStorage.setItem('todos', JSON.stringify(todos));
}
// ---------- 全局状态 ----------
let todos = loadTodos();
let currentFilter = 'all';
// ---------- 渲染(数据 → 视图) ----------
function render() {
// 过滤
let filtered = todos;
if (currentFilter === 'active') {
filtered = todos.filter(t => !t.completed);
} else if (currentFilter === 'completed') {
filtered = todos.filter(t => t.completed);
}
// 生成 HTML
const listHtml = filtered.map(todo => `
<li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
<input type="checkbox" ${todo.completed ? 'checked' : ''}>
<span>${escapeHtml(todo.text)}</span>
<button class="delete-btn">删除</button>
</li>
`).join('');
document.getElementById('todoList').innerHTML = listHtml;
// 统计
const total = todos.length;
const completedCount = todos.filter(t => t.completed).length;
document.getElementById('stats').innerHTML = `共 ${total} 项,已完成 ${completedCount} 项`;
}
// ---------- 添加待办 ----------
function addTodo() {
const input = document.getElementById('todoInput');
const text = input.value.trim();
if (text === '') return;
const newTodo = {
id: Date.now(),
text: text,
completed: false
};
todos.push(newTodo);
saveTodos(todos);
input.value = '';
render();
}
// ---------- 事件委托(核心) ----------
function handleListClick(e) {
const target = e.target;
// 通过 closest 找到当前待办项所在的 <li>
const li = target.closest('.todo-item');
if (!li) return;
const id = Number(li.dataset.id);
// 删除按钮
if (target.classList.contains('delete-btn')) {
todos = todos.filter(t => t.id !== id);
saveTodos(todos);
render();
return;
}
// 复选框(切换完成状态)
if (target.type === 'checkbox') {
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = target.checked;
saveTodos(todos);
render();
}
}
}
// ---------- 筛选 ----------
function setFilter(filter) {
currentFilter = filter;
document.querySelectorAll('.filter-btn').forEach(btn => {
if (btn.dataset.filter === filter) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
render();
}
// ---------- 初始化 ----------
function init() {
render();
document.getElementById('addBtn').addEventListener('click', addTodo);
document.getElementById('todoList').addEventListener('click', handleListClick);
document.getElementById('todoInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') addTodo();
});
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => setFilter(btn.dataset.filter));
});
}
init();
</script>
</body>
</html>
第四步:你从中学到了什么?
通过这个项目,你不再只是孤立地知道数组方法、事件监听、本地存储,而是真正理解了它们如何协作构建一个真实应用。
- 数据驱动视图:我们修改
todos,然后重新render(),而不是手忙脚乱地操作 DOM。这是 React/Vue 等框架的思想源头。 - 不可变数据:删除时用
filter返回新数组(虽然添加用了push,但删除和更新都是不可变或可追踪的)。 - 🔥 事件委托:用最简单的方式处理动态元素,不再为绑定事件发愁。
- 本地存储:前端数据持久化的基础。
下一步你可以扩展什么?
- 编辑待办:双击文字变成可编辑的输入框(使用
contenteditable)。 - 清空已完成:添加一个按钮,一次删除所有
completed为true的项。 - 拖拽排序:使用
drag-and-dropAPI 重新排序待办。 - 数据导出/导入:把
todos导出为.json文件,也可以从文件导入。
每完成一个扩展,你都会对 JavaScript 更加自信。
如果你已经完成了猜数字和待办清单,下一个项目(记账本)将帮你熟练掌握 reduce、日期格式化、图表库的使用。