JavaScript实战项目实例:从零构建待办事项列表应用
本教程详细讲解如何使用原生JavaScript从零构建一个完整的待办事项列表应用(Todo List)。涵盖数据模型设计、DOM操作、事件委托机制、LocalStorage持久化存储与安全防范,帮助开发者掌握以数据驱动为核心的前端工程思维。
待办事项列表(Todo List)是前端开发中检验基础知识与工程能力的经典实战项目。本文将抛弃依赖和花哨的脚手架,直接使用原生 HTML5、CSS3 与 JavaScript,从零构建一个具备任务增删改查、状态切换、多维度筛选以及 LocalStorage 本地持久化的完整单页应用,帮助读者深入理解现代前端开发的核心底层机制:数据驱动视图与事件委托模型。
一、系统架构与数据模型设计
初学者制作交互组件时,常习惯于直接操作 DOM 节点,例如在点击添加按钮时直接生成 <li> 标签拼接到页面中。这种做法会导致视图与业务逻辑高度耦合,随着功能扩展极难维护与持久化。
在工业级开发中,我们遵循“单一数据源(Single Source of Truth)”原则:页面上显示的一切内容,本质上都是内存中 JavaScript 状态(State)的投影。视图层的交互只负责修改状态数据,状态变化后再统一驱动界面重绘并同步到本地存储。
在这个模型下,每一个待办任务(Todo Item)用一个标准的 JavaScript 对象表示:
interface TodoItem {
id: string; // 唯一标识符,推荐基于时间戳或UUID生成
text: string; // 任务文本内容
completed: boolean; // 完成状态标志位
createdAt: number; // 创建时间戳
}
二、核心实施步骤与代码实现
构建本应用分为四个清晰的进阶阶段,遵循自底向上的模块化实现路线:
第 1 步:HTML5 语义化结构
界面的基本结构包含四个功能区:顶部表单、任务列表容器、底部状态计数及过滤控件。完整结构如下:
<!-- index.html 核心结构 -->
<div class="todo-app">
<header class="app-header">
<h1>我的待办事项</h1>
<form id="todo-form" class="todo-form">
<input
type="text"
id="todo-input"
placeholder="需要完成什么任务?"
autocomplete="off"
required
/>
<button type="submit" id="add-btn">添加</button>
</form>
</header>
<main class="app-main">
<ul id="todo-list" class="todo-list"></ul>
</main>
<footer class="app-footer" id="app-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>
</footer>
</div>
第 2 步:CSS3 关键视觉与交互样式
良好的样式不仅仅是美化,更是向用户传达交互状态的媒介(如已完成项的删除线与灰度处理):
/* 关键样式片段 */
.todo-list {
list-style: none;
margin: 0;
padding: 0;
}
.todo-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #edf2f7;
transition: background-color 0.2s ease;
}
.todo-item:hover {
background-color: #f8fafc;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #94a3b8;
}
.todo-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
margin-right: 12px;
}
.todo-text {
flex: 1;
font-size: 15px;
color: #334155;
word-break: break-all;
}
.delete-btn {
background: none;
border: none;
color: #ef4444;
font-size: 18px;
cursor: pointer;
opacity: 0.6;
padding: 4px 8px;
}
.delete-btn:hover {
opacity: 1;
}
三、深层机制解析:DOM 事件委托机制
很多初学者在向列表中添加新的待办项时,会习惯性地这样写代码:
// 不推荐的写法:直接为新增的每个子元素绑定监听器
const deleteBtn = newItem.querySelector('.delete-btn');
deleteBtn.addEventListener('click', () => { ... });
这种直接为每个动态元素绑定事件监听的做法存在两大弊端:
- 内存泄露风险与开销激增:如果列表中有成百上千项任务,浏览器需要维护成百上千个事件监听器对象,造成不必要的内存占用。
- 生命周期绑定繁琐:每次新增元素或列表重新渲染时,都必须手动为其内部的按钮再次挂载监听;若元素被删除而监听未释放,容易导致内存滞留。
正解是利用浏览器的事件冒泡(Event Bubbling)机制实施事件委托(Event Delegation):在稳定的父容器(<ul id="todo-list">)上仅绑定一次监听,当子元素被点击时,事件向上冒泡到父级,父级通过 event.target 判断真实触发的目标节点及其挂载的 data-id 属性,实现高效寻址。
四、核心业务逻辑完整实现
以下是经过精细组织的应用脚本核心,我们将功能模块划分为:状态初始化、纯函数数据操作、渲染函数以及统一事件分发。
// app.js - 完整纯原生逻辑
(() => {
// 1. 常量与持久化键名定义
const STORAGE_KEY = 'javascript_todos_v1';
// 2. 状态定义
let todos = loadTodos();
let currentFilter = 'all'; // 可选值: 'all' | 'active' | 'completed'
// 3. DOM 元素缓存
const form = document.getElementById('todo-form');
const input = document.getElementById('todo-input');
const todoList = document.getElementById('todo-list');
const todoCount = document.getElementById('todo-count');
const filterContainer = document.getElementById('filters');
const clearCompletedBtn = document.getElementById('clear-completed');
// 4. 辅助函数:安全防范 XSS 转义
function escapeHTML(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 5. 存储层:从 LocalStorage 读取与写入
function loadTodos() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (err) {
console.error('无法解析本地存储数据:', err);
return [];
}
}
function saveTodos() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
} catch (err) {
console.error('持久化存储失败:', err);
}
}
// 6. 状态更新纯操作
function addTodo(text) {
const trimmed = text.trim();
if (!trimmed) return;
const newTodo = {
id: Date.now().toString(),
text: trimmed,
completed: false,
createdAt: Date.now()
};
todos.unshift(newTodo);
saveTodos();
render();
}
function toggleTodo(id) {
todos = todos.map(todo => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed };
}
return todo;
});
saveTodos();
render();
}
function deleteTodo(id) {
todos = todos.filter(todo => todo.id !== id);
saveTodos();
render();
}
function clearCompleted() {
todos = todos.filter(todo => !todo.completed);
saveTodos();
render();
}
// 7. 视图渲染引擎
function render() {
// 根据当前筛选状态计算要显示的数据
const filteredTodos = todos.filter(todo => {
if (currentFilter === 'active') return !todo.completed;
if (currentFilter === 'completed') return todo.completed;
return true;
});
// 生成列表 HTML(使用 dataset 存储操作类型和对应任务ID)
if (filteredTodos.length === 0) {
todoList.innerHTML = '<li class="empty-tip">暂无待办事项</li>';
} else {
todoList.innerHTML = filteredTodos.map(todo => `
<li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
<input
type="checkbox"
class="todo-checkbox"
data-action="toggle"
data-id="${todo.id}"
${todo.completed ? 'checked' : ''}
/>
<span class="todo-text">${escapeHTML(todo.text)}</span>
<button class="delete-btn" data-action="delete" data-id="${todo.id}" title="删除任务">×</button>
</li>
`).join('');
}
// 更新剩余计数
const activeCount = todos.filter(t => !t.completed).length;
todoCount.textContent = `${activeCount} 项待完成`;
// 更新过滤器按钮高亮态
const buttons = filterContainer.querySelectorAll('.filter-btn');
buttons.forEach(btn => {
if (btn.dataset.filter === currentFilter) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// 8. 统一事件绑定
// 表单提交事件
form.addEventListener('submit', (e) => {
e.preventDefault();
addTodo(input.value);
input.value = '';
input.focus();
});
// 列表父级事件委托
todoList.addEventListener('click', (e) => {
const target = e.target;
const action = target.dataset.action;
const id = target.dataset.id;
if (!action || !id) return;
if (action === 'toggle') {
toggleTodo(id);
} else if (action === 'delete') {
deleteTodo(id);
}
});
// 过滤器切换
filterContainer.addEventListener('click', (e) => {
const filter = e.target.dataset.filter;
if (filter) {
currentFilter = filter;
render();
}
});
// 清除已完成
clearCompletedBtn.addEventListener('click', () => {
clearCompleted();
});
// 初次启动执行首屏渲染
render();
})();
五、生产环境关键细节与避坑指南
防范 XSS 漏洞:直接把用户的输入拼接进 innerHTML 会带来跨站脚本攻击隐患(例如输入 <img src=x onerror=alert(1)>)。在上文的 render() 中,我们显式调用了 escapeHTML 对用户输入进行严格字符实体转义,或者可以使用原生的 element.textContent = todo.text 来杜绝脚本注入。
- 唯一标识符(ID)的选取:虽然单机 Demo 中使用
Date.now().toString()足以应付常规操作,但在并发脚本连续生成元素时可能产生重复。在现代浏览器环境下,推荐使用原生crypto.randomUUID()生成符合标准 RFC 4122 的强随机唯一 ID。 - 存储配额与错误捕获:
localStorage的容量通常限制为 5MB 左右,并且在隐身模式或配额超限时调用setItem会抛出QuotaExceededError。因此所有针对本地存储的调用都必须包裹在try...catch块中,防止整页脚本崩溃。 - 输入空白字符的过滤:务必在将数据压入数组前执行
text.trim(),阻止用户提交纯空格产生的幽灵无意义任务。
六、应用生命周期与闭环总结
回顾整个 Todo List 应用的实现过程,从最开始的空页面,到用户添加第一条任务,再到刷新浏览器后恢复数据,整套系统的生命周期形成了一个严谨的闭环:
通过这个小巧而精悍的项目,我们不仅掌握了标准的 DOM 操作与事件委托机制,更重要的是建立起了数据驱动视图的现代前端心智模型。当后续进一步学习 Vue 的响应式系统或 React 的单向数据流与 Hook 体系时,你会发现它们底层的设计哲学与我们今天从零编写的待办事项列表完全一脉相承。
JavaScript零基础入门:从环境搭建到运行第一段代码
2026-11-05 15:29:58
HTML链接中javascript:void(0)的作用及现代替代方案
2026-11-05 10:07:47
在线调试 JS 时怎么引入 jQuery 或 Vue 等第三方库
2026-11-05 08:53:56
原生JavaScript实现简易颜色选择器原理与代码
2026-11-03 23:22:19
JS原生表单序列化:FormData与URLSearchParams用法详解
2026-11-03 23:13:21








