告别割裂式学习:待办清单项目,一次性掌握数组、本地存储与事件委托

5 阅读8分钟

告别割裂式学习:待办清单项目,一次性掌握数组、本地存储与事件委托

你也许背过 mapfilter 的用法,也用过 localStorage,但一遇到真实项目就不知道怎么组合?
本文通过一个完整的待办清单应用,带你真正理解:数据驱动视图、状态持久化、如何优雅地操作数组。
重点:我会用最通俗的白话讲清楚「事件委托」——新手最头疼的概念之一。


为什么第二个项目必须是待办清单?

待办清单(TodoMVC)被称为“前端的力学题”。它看起来简单,却包含了现代 Web 应用的核心模式:

  • 数据模型:用数组存储对象,每个对象有 idtextcompleted
  • 增删改查(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 '&amp;';
            if (m === '<') return '&lt;';
            if (m === '>') return '&gt;';
            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)。
  • 清空已完成:添加一个按钮,一次删除所有 completedtrue 的项。
  • 拖拽排序:使用 drag-and-drop API 重新排序待办。
  • 数据导出/导入:把 todos 导出为 .json 文件,也可以从文件导入。

每完成一个扩展,你都会对 JavaScript 更加自信。

如果你已经完成了猜数字和待办清单,下一个项目(记账本)将帮你熟练掌握 reduce、日期格式化、图表库的使用。