JavaScript实战项目实例:从零构建待办事项清单应用
本教程详细讲解如何使用纯原生JavaScript、HTML5与CSS3从零构建功能完备的待办事项清单应用,涵盖状态驱动视图设计、事件委托机制、数据持久化存储与防御式编程实践。
一、项目设计与数据流转架构
在编写代码前,明确软件系统的架构与数据流动模式是避免代码混乱的关键。初学者编写原生JavaScript项目时,常见的误区是将状态直接存储在DOM节点上(例如通过读取类名或属性判断任务是否完成),这会导致DOM操作与业务逻辑高度耦合。
本项目采用“状态驱动视图(State-Driven UI)”的单向数据流设计。我们维护一个独立的纯内存JavaScript数组作为真实数据源(Single Source of Truth),所有用户交互(输入、勾选、删除)仅负责修改该状态数据;状态改变后触发专门的渲染函数将新状态映射到DOM中,并同步序列化至本地缓存。
二、构建流程与步骤概览
将大任务拆解为条理清晰的步骤是工程化实现的必备能力。从零构建一个健壮的待办事项应用,通常分为四个标准阶段:
- 语义化DOM骨架搭建:使用规范的HTML5标签构建清晰的文档树结构,包括任务输入表单、过滤器控制栏及任务列表容器。
- 核心状态与数据契约设计:确定每个任务对象的数据结构,规范其唯一标识符(ID)、内容与完成标记,并建立本地存储读写工具函数。
- 渲染函数与事件委托绑定:编写根据状态生成HTML的纯函数,并在外层父级容器上利用事件委托集中捕获用户的各项交互行为。
- 业务逻辑闭环与边界防御:实现待办添加、删除、切换状态、清空已完成功能,同时处理空输入校验、HTML转义防XSS注入与异常恢复。
三、第一步:HTML结构设计与基础样式
我们首先创建清晰的HTML语义化结构。为了保证无障碍访问(Accessibility)并支持键盘回车直接提交,整个输入区域必须被包含在<form>元素中。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>原生JS待办事项清单</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="todo-app">
<header class="app-header">
<h1>待办事项清单</h1>
<p class="subtitle">专注高效,记录每一项关键行动</p>
</header>
<!-- 任务创建表单 -->
<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>
<!-- 过滤与控制工具条 -->
<div class="toolbar">
<div class="filters" id="filter-group">
<button type="button" class="filter-btn active" data-filter="all">全部</button>
<button type="button" class="filter-btn" data-filter="active">进行中</button>
<button type="button" class="filter-btn" data-filter="completed">已完成</button>
</div>
<span class="count-display" id="todo-count">剩余 0 项未完成</span>
</div>
<!-- 动态列表渲染容器 -->
<ul id="todo-list" class="todo-list">
<!-- 此处将由 JavaScript 根据 state 动态注入 li 节点 -->
</ul>
<!-- 底部清理操作 -->
<footer class="app-footer">
<button type="button" id="clear-completed-btn" class="clear-btn">清除已完成事项</button>
</footer>
</main>
<script src="app.js"></script>
</body>
</html>
关键布局样式采用现代CSS Flexbox布局,为已完成状态赋予淡化和删除线效果:
/* 基础样式片段 */
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #FFFFFF;
border-bottom: 1px solid #ECE7DE;
transition: background 0.2s ease;
}
.todo-item:hover {
background: #FAF8F5;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #9AA5B1;
}
.todo-item .delete-btn {
background: transparent;
color: #C53030;
border: none;
cursor: pointer;
padding: 4px 8px;
font-size: 14px;
}
四、第二步:状态模型与本地存储抽象
任何一个健壮的数据驱动应用,都必须明确规定数据项的形状(Schema)。在待办清单中,每个任务项均定义为包含以下属性的JavaScript对象:
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
string | number |
全局唯一标识符,本教程采用时间戳字符串保证唯一性 |
text |
string |
待办事项文本内容,经过清洗转义后的纯文本 |
completed |
boolean |
任务是否已完成(true 或 false) |
createdAt |
number |
任务创建的毫秒级时间戳 |
接下来,在 app.js 的起始位置定义持久化存储与核心全局状态:
// 存储键名常量
const STORAGE_KEY = 'javascript_todolist_data_v1';
// 本地存储读写工具封装(具备容错兜底机制)
function loadStoredTodos() {
try {
const rawData = localStorage.getItem(STORAGE_KEY);
return rawData ? JSON.parse(rawData) : [];
} catch (error) {
console.error('读取本地存储失败,初始化为空数组:', error);
return [];
}
}
function persistTodos(todos) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
} catch (error) {
console.error('写入本地存储失败:', error);
}
}
// 核心应用状态
let appState = {
todos: loadStoredTodos(),
currentFilter: 'all' // 可选值:'all' | 'active' | 'completed'
};
五、第三步:事件委托机制与精准动作分发
在待办清单应用中,用户会不断添加新的待办项。如果我们对每一个新生成的 <li> 或按钮单独使用 addEventListener,不仅会导致内存开销随着待办数量线性增加,而且在节点被销毁替换时容易造成内存泄漏。
事件委托(Event Delegation)是前端性能优化的黄金准则。我们将单一的 click 监听器挂载到静态存在的父容器 <ul id="todo-list"> 上,利用事件冒泡机制捕获所有子孙元素的点击;通过检查触发目标 event.target 上的自定义属性 data-action 和 data-id,精确判断用户的操作意图并调用对应的数据修改函数。
六、第四步:完整的JavaScript核心实现
下面是完整的 app.js 生产代码。代码严格遵照模块化职责分离规范,包含了安全转义工具、状态更新函数、过滤计算派生以及渲染器。
/**
* 待办事项应用完整业务实现
*/
(function() {
'use strict';
// 1. 存储键名与安全工具
const STORAGE_KEY = 'javascript_todolist_data_v1';
// 预防 XSS 注入的纯字符转义函数
function escapeHTML(str) {
if (!str) return '';
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 2. 本地持久化封装
function loadStoredTodos() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
console.error('加载本地存储失败,重置为空:', e);
return [];
}
}
function persistTodos(todos) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
} catch (e) {
console.error('保存至本地存储失败:', e);
}
}
// 3. 应用单例状态
const state = {
todos: loadStoredTodos(),
filter: 'all' // 'all' | 'active' | 'completed'
};
// 4. DOM 元素缓存
const dom = {
form: document.getElementById('todo-form'),
input: document.getElementById('todo-input'),
list: document.getElementById('todo-list'),
count: document.getElementById('todo-count'),
filterGroup: document.getElementById('filter-group'),
clearBtn: document.getElementById('clear-completed-btn')
};
// 5. 状态操作纯逻辑函数
function addTodo(text) {
const trimmed = text.trim();
if (!trimmed) return;
const newTodo = {
id: 'todo_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5),
text: trimmed,
completed: false,
createdAt: Date.now()
};
state.todos.unshift(newTodo);
saveAndRender();
}
function toggleTodo(id) {
const target = state.todos.find(item => item.id === id);
if (target) {
target.completed = !target.completed;
saveAndRender();
}
}
function deleteTodo(id) {
state.todos = state.todos.filter(item => item.id !== id);
saveAndRender();
}
function clearCompleted() {
state.todos = state.todos.filter(item => !item.completed);
saveAndRender();
}
function setFilter(filterType) {
state.filter = filterType;
render();
}
function saveAndRender() {
persistTodos(state.todos);
render();
}
// 6. 视图渲染引擎
function render() {
// 6.1 依据当前 filter 计算派生列表
let visibleTodos = state.todos;
if (state.filter === 'active') {
visibleTodos = state.todos.filter(t => !t.completed);
} else if (state.filter === 'completed') {
visibleTodos = state.todos.filter(t => t.completed);
}
// 6.2 渲染待办列表项(若为空则输出空状态占位)
if (visibleTodos.length === 0) {
dom.list.innerHTML = `
<li class="empty-placeholder" style="text-align:center;padding:32px;color:#9AA5B1;">
当前分类下没有任何待办任务
</li>
`;
} else {
dom.list.innerHTML = visibleTodos.map(todo => `
<li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
<label class="todo-label" style="display:flex;align-items:center;gap:12px;flex:1;cursor:pointer;">
<input
type="checkbox"
data-action="toggle"
${todo.completed ? 'checked' : ''}
>
<span class="todo-text">${escapeHTML(todo.text)}</span>
</label>
<button
type="button"
class="delete-btn"
data-action="delete"
aria-label="删除任务"
>
删除
</button>
</li>
`).join('');
}
// 6.3 更新未完成统计
const uncompletedCount = state.todos.filter(t => !t.completed).length;
dom.count.textContent = `剩余 ${uncompletedCount} 项未完成`;
// 6.4 更新过滤按钮激活样式
const buttons = dom.filterGroup.querySelectorAll('.filter-btn');
buttons.forEach(btn => {
if (btn.dataset.filter === state.filter) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// 7. 事件绑定与初始化
function initEvents() {
// 7.1 表单提交事件
dom.form.addEventListener('submit', function(e) {
e.preventDefault();
addTodo(dom.input.value);
dom.input.value = '';
dom.input.focus();
});
// 7.2 列表容器上的事件委托
dom.list.addEventListener('click', function(e) {
const actionTrigger = e.target.closest('[data-action]');
if (!actionTrigger) return;
const itemRow = actionTrigger.closest('[data-id]');
if (!itemRow) return;
const id = itemRow.dataset.id;
const action = actionTrigger.dataset.action;
if (action === 'toggle') {
toggleTodo(id);
} else if (action === 'delete') {
deleteTodo(id);
}
});
// 7.3 过滤按钮组事件委托
dom.filterGroup.addEventListener('click', function(e) {
const btn = e.target.closest('.filter-btn');
if (btn && btn.dataset.filter) {
setFilter(btn.dataset.filter);
}
});
// 7.4 清理已完成按钮
dom.clearBtn.addEventListener('click', function() {
clearCompleted();
});
}
// 8. 启动应用
initEvents();
render();
})();
七、关键工程细节与避坑指南
在利用模板字符串动态拼接 innerHTML 时,若未对用户输入的文本进行过滤,恶意用户可能输入形如 <img src="x" onerror="alert(1)"> 的字符引发脚本执行。本实战中专门封装了 escapeHTML() 函数,对 &, <, >, ", ' 等敏感字符进行转义,筑牢客户端安全底线。
浏览器对 LocalStorage 施加了约 5MB 的存储上限,且在用户开启“无痕浏览模式”或本地存储被浏览器策略禁用时,调用 setItem 会直接抛出 QuotaExceededError 运行时异常。务必像本教程所示,使用 try...catch 代码块包裹持久化操作,保证应用在存储失败时内存逻辑依然可用。
八、技术总结与拓展进阶体系
通过从零构建这个待办清单应用,我们实践了前端开发中最核心的几个设计哲学:从被动的手动DOM微操转向状态驱动视图;从松散零碎的事件监听转向父级事件委托;从内存易失转向LocalStorage持久化同步。这套模式不仅适用于原生开发,更是现代前端框架(如 React、Vue)底层运作逻辑的缩影。
anime.js 快速入门:怎么引入库并编写第一个 DOM 动画
2026-11-05 14:22:43
在线JS解密后变量名仍乱码?结合浏览器DevTools调试还原
2026-11-04 20:26:01
怎么在浏览器中运行JavaScript代码并查看输出结果
2026-11-04 09:07:33
JS 获取屏幕分辨率:window.screen.width 与 height 用法
2026-11-04 08:52:48
为什么推荐用DOMContentLoaded代替window.onload初始化JS
2026-11-04 08:42:33








