用原生JavaScript实现待办事项列表:完整项目实战教程
本教程详细讲解如何使用纯原生JavaScript从零构建一个完整的待办事项列表应用,涵盖数据状态管理、单向数据流、事件委托、本地持久化存储及过滤功能。
待办事项列表(TodoList)是前端开发中最经典且最能检验基础功底的实战项目。不依赖 React、Vue 等现代前端框架,纯粹使用原生 JavaScript 构建待办应用,不仅能够让开发者透彻理解 DOM 节点的创建、更新与销毁,还能深入掌握单向数据流、事件委托模式以及客户端持久化存储(LocalStorage)的核心机制。本文将以模块化和数据驱动的思想,带领读者从零实现一个具备增删、状态切换、过滤筛选与本地持久化的高性能待办事项管理系统。
一、项目设计思路与核心架构
许多初学者在初次编写原生 JavaScript 项目时,容易陷入“直接操作 DOM”的泥潭:点击添加按钮就拼接一段字符串插入 HTML,点击删除就找到父节点调用 remove()。这种做法在简单场景下尚可应付,但一旦涉及“过滤未完成项”、“刷新后保持状态”、“批量勾选”等复合需求,DOM 状态与界面展示极易失步,代码也会变得臃肿脆弱。
现代化原生 JavaScript 开发的核心理念是“状态驱动视图(State-Driven View)”。我们将待办列表的所有业务逻辑建立在纯 JavaScript 内存数组之上,界面的任何变动均源自内存状态的改变,具体流程包括四个环节:
- 状态存储(State):使用包含对象项的纯数组维护待办数据,每个任务具备唯一
id、描述text和完成状态completed。 - 持久化同步(LocalStorage):任何对状态数组的增、删、改操作,均立即将数据序列化写入浏览器本地缓存。
- 统一渲染(Render):编写无副作用的纯渲染函数,根据当前状态及筛选条件全量或增量刷新 DOM 树。
- 事件委托(Event Delegation):在列表父容器上监听交互事件,通过事件冒泡机制识别目标子元素并派发状态修改。
二、开发步骤与实现路径
为了让项目层次清晰且易于维护,我们将整体实现工作拆解为四个关键步骤:
- 结构搭建:编写语义化 HTML 骨架与整洁的基础样式,确立输入框、操作按钮、列表容器及过滤面板。
- 状态建模与输入捕获:定义
todos核心数组结构,监听表单提交事件,处理文字校验与唯一标识生成。 - 事件委托与视图重绘:在无缝列表层挂载委托监听器,利用冒泡处理切换完成状态与删除操作,触发
render()。 - 数据持久化与高级过滤:封装 LocalStorage 读写逻辑,结合
filter状态渲染“全部 / 进行中 / 已完成”,并支持清空已完成项。
三、页面结构与样式搭建
界面的 HTML 应尽量保持语义化与简洁。主要由三部分构成:用于输入待办的表单 <form>、呈现条目的无序列表 <ul>,以及底部的状态统计与过滤控制栏。
<div class="todo-app">
<h1>我的待办清单</h1>
<!-- 添加任务表单 -->
<form id="todo-form" class="todo-form">
<input
type="text"
id="todo-input"
placeholder="需要做些什么?"
autocomplete="off"
autofocus
/>
<button type="submit" id="add-btn">添加</button>
</form>
<!-- 待办事项列表展示区 -->
<ul id="todo-list" class="todo-list"></ul>
<!-- 底部操作与过滤栏 -->
<div id="todo-footer" class="todo-footer">
<span id="todo-count">0 个待办</span>
<div class="filters" id="filters">
<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" class="clear-btn">清除已完成</button>
</div>
</div>
在样式设计中,使用 Flexbox 保证各组件在不同分辨率下的整齐排列。为了突出完成状态,当待办项拥有 completed 类名时,给文本添加横向贯穿删除线(line-through)并适当降低透明度。
四、核心功能逐项实现
1. 数据模型与初始化
为了让每个任务具备唯一性,每个待办项均保存为形如 { id: 1718000000000, text: '学习原生 JS', completed: false } 的对象。我们在应用启动时首先尝试从 localStorage 中提取历史数据,若无历史记录则初始化为空数组:
// 状态定义
let todos = JSON.parse(localStorage.getItem('todo_items_storage')) || [];
let currentFilter = 'all'; // 可选值: 'all' | 'active' | 'completed'
// DOM 元素引用
const todoForm = document.getElementById('todo-form');
const todoInput = document.getElementById('todo-input');
const todoList = document.getElementById('todo-list');
const todoCount = document.getElementById('todo-count');
const filtersContainer = document.getElementById('filters');
const clearCompletedBtn = document.getElementById('clear-completed');
2. 数据持久化助手函数
为了保证无论执行添加、修改或删除操作时本地缓存都能即时同步,我们编写一个统一的 saveTodos() 函数:
function saveTodos() {
localStorage.setItem('todo_items_storage', JSON.stringify(todos));
}
3. 添加新任务
为表单绑定 submit 事件,通过 e.preventDefault() 拦截浏览器默认的刷新跳转行为。读取用户输入并利用 trim() 消除首尾空白;若内容非空,则生成新任务对象并推入 todos 数组:
todoForm.addEventListener('submit', function(e) {
e.preventDefault();
const text = todoInput.value.trim();
if (!text) {
return; // 禁止提交空字符
}
const newTodo = {
id: Date.now(), // 使用当前毫秒时间戳作为唯一ID
text: text,
completed: false
};
todos.push(newTodo);
saveTodos();
render();
todoInput.value = ''; // 清空输入框
todoInput.focus();
});
4. 基于事件委托的高效交互机制
很多开发者在创建 <li> 元素时,会为其中的复选框和删除按钮分别调用 addEventListener。当待办项数量达到数百个,或者列表频繁增删时,过多的事件监听器会造成严重的内存占用,甚至引发内存泄漏。
事件委托(Event Delegation)是解决该问题的规范途径:由于 DOM 事件存在冒泡机制(Bubble Phase),在 <ul> 容器上只需绑定一次 click 监听器,即可捕获其内部所有子元素的点击事件。通过检查触发源(e.target)的类名或自定义属性,即可精准判断用户是点击了“切换勾选”还是“删除按钮”。
todoList.addEventListener('click', function(e) {
// 向上寻找最近的带有 data-id 的 li 元素
const li = e.target.closest('li[data-id]');
if (!li) return;
const id = Number(li.dataset.id);
// 1. 点击了勾选框
if (e.target.classList.contains('toggle-checkbox')) {
const targetTodo = todos.find(item => item.id === id);
if (targetTodo) {
targetTodo.completed = !targetTodo.completed;
saveTodos();
render();
}
}
// 2. 点击了删除按钮
if (e.target.classList.contains('delete-btn')) {
todos = todos.filter(item => item.id !== id);
saveTodos();
render();
}
});
5. 数据渲染与过滤逻辑
渲染函数 render() 是界面的唯一出口。它负责获取经过当前过滤器筛选的条目,构建 HTML 片段并批量赋值给 todoList.innerHTML,同时更新计数器与清除按钮状态:
function render() {
// 1. 根据当前筛选状态过滤列表
let filteredTodos = todos;
if (currentFilter === 'active') {
filteredTodos = todos.filter(t => !t.completed);
} else if (currentFilter === 'completed') {
filteredTodos = todos.filter(t => t.completed);
}
// 2. 组装条目 HTML(严防 XSS:使用字符转义函数对文本过滤)
todoList.innerHTML = filteredTodos.map(todo => `
<li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
<input
type="checkbox"
class="toggle-checkbox"
${todo.completed ? 'checked' : ''}
/>
<span class="todo-text">${escapeHTML(todo.text)}</span>
<button class="delete-btn" title="删除该任务">×</button>
</li>
`).join('');
// 3. 更新剩余未完成计数
const activeCount = todos.filter(t => !t.completed).length;
todoCount.textContent = `${activeCount} 个待办未完成`;
// 4. 控制“清除已完成”按钮的显示与隐藏
const hasCompleted = todos.some(t => t.completed);
clearCompletedBtn.style.visibility = hasCompleted ? 'visible' : 'hidden';
}
// 辅助转义函数,杜绝 HTML 注入漏洞
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
6. 过滤切换与批量清除
为过滤按钮与批量清除按钮分别绑定监听:
// 过滤按钮点击切换
filtersContainer.addEventListener('click', function(e) {
if (e.target.tagName !== 'BUTTON') return;
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
e.target.classList.add('active');
currentFilter = e.target.dataset.filter;
render();
});
// 清除已完成任务
clearCompletedBtn.addEventListener('click', function() {
todos = todos.filter(t => !t.completed);
saveTodos();
render();
});
五、完整源码整合实现
为了让读者能够立即运行并检验成果,下面提供单文件内联的完整可运行代码。读者可将其直接保存为本地 HTML 文件(例如 todo.html)并在任何现代浏览器中打开:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>原生 JavaScript 待办事项应用</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: sans-serif; background: #F8F9FA; display: flex; justify-content: center; padding-top: 50px; }
.todo-app { width: 480px; background: #fff; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); padding: 24px; }
h1 { font-size: 22px; color: #4F6272; text-align: center; margin-bottom: 20px; }
.todo-form { display: flex; gap: 8px; margin-bottom: 16px; }
.todo-form input { flex: 1; padding: 10px 14px; border: 1px solid #CBD5E1; border-radius: 4px; font-size: 14px; outline: none; }
.todo-form input:focus { border-color: #477DAD; }
.todo-form button { padding: 10px 18px; background: #477DAD; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.todo-list { list-style: none; margin-bottom: 16px; max-height: 360px; overflow-y: auto; }
.todo-item { display: flex; align-items: center; padding: 10px 4px; border-bottom: 1px solid #F1F5F9; }
.todo-item.completed .todo-text { text-decoration: line-through; color: #94A3B8; }
.todo-item input[type="checkbox"] { margin-right: 12px; cursor: pointer; width: 16px; height: 16px; }
.todo-text { flex: 1; font-size: 14px; color: #1E293B; word-break: break-all; }
.delete-btn { background: none; border: none; color: #EF4444; font-size: 20px; cursor: pointer; opacity: 0; transition: opacity 0.2s; padding: 0 4px; }
.todo-item:hover .delete-btn { opacity: 1; }
.todo-footer { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #64748B; padding-top: 12px; border-top: 1px solid #E2E8F0; }
.filter-btn { background: none; border: 1px solid transparent; padding: 4px 8px; border-radius: 4px; cursor: pointer; color: #64748B; }
.filter-btn.active { border-color: #477DAD; color: #477DAD; font-weight: bold; }
.clear-btn { background: none; border: none; color: #EF4444; cursor: pointer; }
</style>
</head>
<body>
<div class="todo-app">
<h1>待办清单</h1>
<form id="todo-form" class="todo-form">
<input type="text" id="todo-input" placeholder="输入待办事项..." autocomplete="off" />
<button type="submit">添加</button>
</form>
<ul id="todo-list" class="todo-list"></ul>
<div class="todo-footer">
<span id="todo-count">0 个待办</span>
<div id="filters">
<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" class="clear-btn">清除已完成</button>
</div>
</div>
<script>
let todos = JSON.parse(localStorage.getItem('todo_items_storage')) || [];
let currentFilter = 'all';
const todoForm = document.getElementById('todo-form');
const todoInput = document.getElementById('todo-input');
const todoList = document.getElementById('todo-list');
const todoCount = document.getElementById('todo-count');
const filtersContainer = document.getElementById('filters');
const clearCompletedBtn = document.getElementById('clear-completed');
function saveTodos() {
localStorage.setItem('todo_items_storage', JSON.stringify(todos));
}
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function render() {
let filtered = todos;
if (currentFilter === 'active') filtered = todos.filter(t => !t.completed);
if (currentFilter === 'completed') filtered = todos.filter(t => t.completed);
todoList.innerHTML = filtered.map(item => `
<li class="todo-item ${item.completed ? 'completed' : ''}" data-id="${item.id}">
<input type="checkbox" class="toggle-checkbox" ${item.completed ? 'checked' : ''}>
<span class="todo-text">${escapeHTML(item.text)}</span>
<button class="delete-btn">×</button>
</li>
`).join('');
const activeCount = todos.filter(t => !t.completed).length;
todoCount.textContent = `${activeCount} 个待办未完成`;
clearCompletedBtn.style.visibility = todos.some(t => t.completed) ? 'visible' : 'hidden';
}
todoForm.addEventListener('submit', function(e) {
e.preventDefault();
const text = todoInput.value.trim();
if (!text) return;
todos.push({ id: Date.now(), text, completed: false });
saveTodos();
render();
todoInput.value = '';
});
todoList.addEventListener('click', function(e) {
const li = e.target.closest('li[data-id]');
if (!li) return;
const id = Number(li.dataset.id);
if (e.target.classList.contains('toggle-checkbox')) {
const item = todos.find(t => t.id === id);
if (item) {
item.completed = !item.completed;
saveTodos();
render();
}
}
if (e.target.classList.contains('delete-btn')) {
todos = todos.filter(t => t.id !== id);
saveTodos();
render();
}
});
filtersContainer.addEventListener('click', function(e) {
if (e.target.tagName !== 'BUTTON') return;
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
e.target.classList.add('active');
currentFilter = e.target.dataset.filter;
render();
});
clearCompletedBtn.addEventListener('click', function() {
todos = todos.filter(t => !t.completed);
saveTodos();
render();
});
// 页面初次加载立即执行渲染
render();
</script>
</body>
</html>
六、高频陷阱与开发避坑指南
在原生 JavaScript 项目开发中,有若干细节极易被忽视并引发隐蔽 Bug:
当通过模板字符串拼接 HTML 时,如果直接将 todo.text 嵌入节点(如 <span>${todo.text}</span>),若用户输入形如 <img src="x" onerror="alert(1)"> 的恶意代码,浏览器将直接解析执行。因此必须使用 escapeHTML() 借助 textContent 转换转义,或者直接通过 document.createElement() 创建节点赋值。
存储在 LocalStorage 中的内容必须为纯字符串。在调用 JSON.parse() 时,务必提供兜底默认值 || []。此外,HTML5 的 dataset.id 取出的值永远是字符串,而通过 Date.now() 存储的 id 是数字类型,在比对或过滤时须显式调用 Number(li.dataset.id),避免因 === 全等匹配导致找不到目标元素。
通过本项目实战,我们用纯原生技术实现了完整的数据流控制模型。无论是后续进阶学习虚拟 DOM 差异比较算法,还是深入理解主流现代前端框架的响应式更新原理,这些基于原生 JavaScript 的事件与状态管理模式都将是最坚实的技术底座。
AOS.js 动画不触发怎么办?排查 DOM 加载与移动端兼容问题
2026-11-05 17:02:26
sigma.js 怎么实现节点点击、拖拽与悬停提示交互
2026-11-05 15:03:27
Matter.js 与 PixiJS/Three.js 集成:分离物理计算与渲染
2026-11-05 13:43:48
主流JS弹幕库选型指南:Danmaku.js等库的功能与体积对比
2026-11-05 09:28:19
JavaScript点击事件监听与交互效果实例
2026-11-05 09:10:19








