JavaScript实战小项目:原生JS实现待办事项列表(Todo List)
本篇教程通过原生JavaScript实现一个完整的Todo List待办事项列表,涵盖结构搭建、添加任务、状态切换、删除任务、本地存储持久化及状态筛选,深入解析核心DOM操作与事件委托机制。
待办事项列表(Todo List)是前端开发中最经典的练习项目,它麻雀虽小五脏俱全,集中涵盖了 DOM 元素获取、节点动态创建与挂载、事件监听与事件委托、浏览器本地存储(localStorage)以及数据驱动视图的基本思想。本文将完全脱离前端框架,使用纯原生 HTML、CSS 与 JavaScript,从零构建一个支持任务增删、状态标记、分类筛选与持久化存储的待办事项应用。
一、项目整体架构与设计思路
在编写代码前,明确数据与界面的关系是保证逻辑清晰的前提。传统的 DOM 脚本开发常陷入“直接操作页面元素修改状态”的混乱中,导致数据与显示脱节。本项目采用“状态驱动视图(State-Driven UI)”的轻量模式:界面的每次变化均源于内存中数组数据的更新,随后通过一个统一的渲染函数将最新数据映射到 HTML 视图上。
每个待办事项被建模为一个标准 JavaScript 对象:
{
id: 1717200000000, // 唯一时间戳标识符
text: "学习原生 JavaScript DOM 操作", // 任务具体文本
completed: false // 任务完成标记
}
二、HTML结构与基础样式搭建
界面的基本骨架包括四个主要区域:头部输入区、主体任务列表区、底部状态栏以及过滤切换按钮。我们创建基础 HTML 片段与辅助 CSS:
<!-- 容器根节点 -->
<div class="todo-app">
<header class="todo-header">
<h2>待办清单</h2>
<div class="input-group">
<input type="text" id="todo-input" placeholder="输入待办事项后按回车添加..." autofocus>
<button id="add-btn">添加</button>
</div>
</header>
<!-- 列表展示区 -->
<ul id="todo-list" class="todo-list"></ul>
<!-- 状态筛选与统计区 -->
<footer class="todo-footer">
<span id="items-left">0 项未完成</span>
<div class="filter-group">
<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>
<button id="clear-completed-btn">清除已完成</button>
</footer>
</div>
三、分步实施核心功能
实现该项目分为五个明确的阶段步骤,从状态定义、输入捕获到事件委托机制的建立,读者应按照逻辑顺序逐步演进。
第 1 步:DOM 引用与初始状态初始化
首先获取关键节点的引用,并从浏览器的 localStorage 中恢复先前保存的任务。若无历史记录,则初始化为空数组:
// 获取相关 DOM 元素
const todoInput = document.getElementById('todo-input');
const addBtn = document.getElementById('add-btn');
const todoList = document.getElementById('todo-list');
const itemsLeft = document.getElementById('items-left');
const clearCompletedBtn = document.getElementById('clear-completed-btn');
const filterBtns = document.querySelectorAll('.filter-btn');
// 应用核心状态
let todos = JSON.parse(localStorage.getItem('todos_app_data')) || [];
let currentFilter = 'all'; // 筛选状态:'all' | 'active' | 'completed'
第 2 步:处理用户输入与任务添加
任务的添加通常有两种交互入口:点击“添加”按钮或在输入框内按下 Enter 键。添加前必须调用 trim() 去除首尾无意义的空白字符,防止空条目加入:
function addTodo() {
const text = todoInput.value.trim();
if (text === '') {
alert('请输入任务内容!');
return;
}
const newTodo = {
id: Date.now(), // 以毫秒级时间戳作为唯一标识
text: text,
completed: false
};
todos.push(newTodo);
todoInput.value = ''; // 清空输入框
saveAndRender();
}
// 绑定添加按钮点击事件与回车按键事件
addBtn.addEventListener('click', addTodo);
todoInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
addTodo();
}
});
第 3 步:深入理解并使用“事件委托”管理列表交互
列表中的任务是动态增减的。如果在每次创建 <li> 时都分别为其内部的复选框和删除按钮绑定 addEventListener,会带来两个严重问题:频繁绑定增加内存占用,且后续动态添加的项必须重新绑定。最标准、最优雅的做法是在父容器 <ul> 上进行事件委托(Event Delegation)。
事件委托的具体实现代码如下。利用 e.target 识别点击目标,再通过 closest('li') 获取对应的数据 ID:
todoList.addEventListener('click', (e) => {
const target = e.target;
const li = target.closest('li');
if (!li) return;
const id = Number(li.dataset.id);
// 点击了完成复选框
if (target.classList.contains('toggle-checkbox')) {
const item = todos.find(t => t.id === id);
if (item) {
item.completed = target.checked;
saveAndRender();
}
}
// 点击了删除按钮
if (target.classList.contains('delete-btn')) {
todos = todos.filter(t => t.id !== id);
saveAndRender();
}
});
第 4 步:持久化存储与视图渲染逻辑
saveAndRender() 函数是整个系统的数据同步核心。它负责将当前 todos 序列化存入 localStorage,随后根据选中的过滤器更新 DOM:
function saveAndRender() {
// 1. 持久化存储到浏览器
localStorage.setItem('todos_app_data', JSON.stringify(todos));
// 2. 依据筛选条件筛选待展示项
let filteredTodos = todos;
if (currentFilter === 'active') {
filteredTodos = todos.filter(t => !t.completed);
} else if (currentFilter === 'completed') {
filteredTodos = todos.filter(t => t.completed);
}
// 3. 构建并挂载 HTML 片段
todoList.innerHTML = '';
filteredTodos.forEach(item => {
const li = document.createElement('li');
li.className = `todo-item ${item.completed ? 'completed' : ''}`;
li.dataset.id = item.id;
// 安全拼接:避免直接写入未转义字符串
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'toggle-checkbox';
checkbox.checked = item.completed;
const span = document.createElement('span');
span.className = 'todo-text';
span.textContent = item.text;
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-btn';
deleteBtn.textContent = '删除';
li.appendChild(checkbox);
li.appendChild(span);
li.appendChild(deleteBtn);
todoList.appendChild(li);
});
// 4. 更新未完成任务计数
const activeCount = todos.filter(t => !t.completed).length;
itemsLeft.textContent = `${activeCount} 项未完成`;
}
element.textContent 或做字符转义,切勿使用直接字符串插值赋值给 innerHTML(例如 <span>${item.text}</span>),否则容易遭受 XSS(跨站脚本攻击)。
第 5 步:状态过滤与已完成任务清理
通过底部的过滤按钮,用户可以在“全部”、“未完成”和“已完成”三者间自由切换。同时提供一键清空所有已完成任务的便捷操作:
// 状态过滤事件绑定
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
filterBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.filter;
saveAndRender();
});
});
// 清除所有已完成项
clearCompletedBtn.addEventListener('click', () => {
todos = todos.filter(t => !t.completed);
saveAndRender();
});
// 首次加载初始化渲染
saveAndRender();
四、常见易错问题与排查
初学者在实现该项目时,最容易在以下三个环节出现逻辑故障或表现异常:
- ID 类型不匹配导致查找失败:在 HTML 属性中,通过
dataset.id取出的值永远是字符串类型(String)。而在我们定义对象时,id使用的是数值类型的Date.now()。若直接使用严格相等比较t.id === li.dataset.id会永远返回false。必须使用Number(li.dataset.id)显式转换后再比较。 - localStorage 存取未做 JSON 序列化:
localStorage只能保存字符串数据。如果直接执行localStorage.setItem('key', todos),存储进去的将是无意义的[object Object]字符串。读取和写入时必须分别配合JSON.stringify()与JSON.parse()使用,并给解析失败或初次加载提供兜底空数组|| []。 - 输入空任务引发的脏数据:未进行
trim()校验时,用户连续敲击空格也能成功创建空白条目,破坏列表排版与统计真实性。
五、知识体系与技术要点总结
通过实现这样一个原生的 Todo List 项目,我们打通了现代前端框架(如 React/Vue)底层所依赖的核心知识链路。回顾全文,各模块的技术要点与核心职责如下表图所示:
掌握了原生 JavaScript 的 DOM 事件委托机制与数据驱动视图的思想后,在后续接触现代框架(如 Vue 的响应式数据、React 的不可变更新)时,便能看透框架在底层所封装的核心行为,从而写出性能更高、更加严谨的前端代码。
JavaScript将图片转换为Base64编码的两种途径
2026-11-05 16:43:05
怎么在 Vue 或 React 项目中正确集成 animate.js
2026-11-05 10:23:38
JS注册事件:addEventListener基础语法与参数详解
2026-11-05 08:44:56
JSP和JS的区别:运行环境与服务端/客户端职责解析
2026-11-04 23:34:36
pdf.js 官方下载地址:GitHub Release 获取预编译包
2026-11-04 23:07:27








