TodoList 是前端新手入门的经典实战案例,它几乎覆盖了 JavaScript 基础的核心知识点 —— 数组操作、DOM 操作、事件处理,能帮你快速把零散的知识点串联起来。今天我们用不到 50 行核心 JS 代码,从零实现一个功能完整、易理解的待办清单,新手也能跟着一步步写出来。
一、最终效果预览
先直观感受下我们要做的 TodoList 长什么样、能实现什么功能,做到心中有数:
功能清单
- ✅ 输入待办事项(支持点击按钮 / 按回车键),新增任务到列表
- ✅ 点击任务文本,切换「已完成 / 未完成」状态(文字变灰 + 删除线)
- ✅ 点击任务右侧「删除」按钮,移除指定任务
- ✅ 进阶优化:刷新页面后任务不丢失(本地存储持久化)
- ✅ 细节优化:输入空内容不添加任务、任务列表样式整洁
界面预览
plaintext
┌─────────────────────────────────────┐
│ [输入框:请输入待办事项...] [添加按钮] │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ ● 学习 JS 基础 [删除] │
│ ● 买菜(已完成) [删除] │
│ ● 整理笔记 [删除] │
└─────────────────────────────────────┘
二、核心知识点预热
在写代码前,先明确本次用到的 3 个核心知识点,提前梳理清楚,避免写代码时一头雾水:
| 知识点 | 作用 | 本次用到的核心方法 / 属性 |
|---|---|---|
| 数组操作 | 存储所有待办事项,管理数据的增删改 | push()、filter()、find()、forEach() |
| DOM 操作 | 把数组里的任务渲染到页面,动态更新 UI | getElementById()、createElement()、appendChild()、innerHTML、dataset |
| 事件处理 | 监听用户操作(点击、回车),触发逻辑 | addEventListener()、事件委托、e.target |
| 本地存储(进阶) | 持久化保存数据,刷新页面不丢失 | localStorage.getItem()、localStorage.setItem()、JSON.parse()/JSON.stringify() |
提示:如果对这些知识点完全陌生也没关系,后面写代码时会逐行讲解,先有个印象即可。
三、分步实现(从 0 到 1 写代码)
我们采用「HTML 结构 → CSS 样式 → JS 逻辑」的顺序实现,所有代码可直接复制到一个 .html 文件中运行,无需额外下载任何东西,新手友好度拉满。
第一步:搭建 HTML 结构(页面骨架)
先写出页面的基础结构,确定有哪些元素需要交互:
html
预览
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>新手版 TodoList</title>
<!-- CSS 样式会写在这里 -->
</head>
<body>
<!-- 待办清单容器 -->
<div class="todo-container">
<!-- 输入区域:输入框 + 添加按钮 -->
<div class="todo-input-wrapper">
<input type="text" id="todo-input" placeholder="请输入待办事项(按回车/点击添加)...">
<button id="add-todo-btn">添加任务</button>
</div>
<!-- 任务列表容器 -->
<ul class="todo-list" id="todo-list"></ul>
</div>
<!-- JS 逻辑会写在这里 -->
<script>
// 后续写核心逻辑
</script>
</body>
</html>
第二步:添加 CSS 样式(让界面更友好)
没有样式的页面会很丑,我们添加简单的样式,重点是「清晰易读」,新手可先复制,后续再慢慢改:
css
/* 全局样式重置(简单版) */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "微软雅黑", sans-serif;
}
/* 容器样式:居中 + 固定宽度 */
.todo-container {
width: 500px;
margin: 50px auto;
padding: 20px;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* 输入区域样式 */
.todo-input-wrapper {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
#todo-input {
flex: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
outline: none;
}
#todo-input:focus {
border-color: #42b983; /* 聚焦时变绿色,提升体验 */
}
#add-todo-btn {
padding: 0 20px;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#add-todo-btn:hover {
background-color: #359469; /* 鼠标悬浮加深颜色 */
}
/* 任务列表样式 */
.todo-list {
list-style: none; /* 去掉默认列表圆点 */
}
/* 单个任务项样式 */
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #f5f5f5;
margin-bottom: 8px;
font-size: 14px;
}
/* 已完成任务样式 */
.todo-item.completed {
color: #999;
text-decoration: line-through; /* 删除线 */
}
/* 删除按钮样式 */
.delete-btn {
color: #ff4444;
border: none;
background: none;
cursor: pointer;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
}
.delete-btn:hover {
background-color: #fef0f0; /* 悬浮加背景色 */
}
把这段 CSS 代码放到 <head> 标签内即可。
第三步:编写 JS 核心逻辑(实现交互功能)
这是最核心的部分,我们分 6 个小步骤写,每一步都有明确的目标,新手能跟上:
步骤 1:获取页面元素 + 初始化数据
先拿到需要操作的 DOM 元素,再定义数组存储任务数据:
js
// 1. 获取页面元素(DOM 操作第一步:找到要操作的元素)
const todoInput = document.getElementById('todo-input'); // 输入框
const addTodoBtn = document.getElementById('add-todo-btn'); // 添加按钮
const todoList = document.getElementById('todo-list'); // 任务列表容器
// 2. 初始化任务数组:从本地存储读取,没有则为空数组(进阶优化)
let todos = JSON.parse(localStorage.getItem('todos')) || [];
步骤 2:编写「添加任务」函数
实现输入内容、点击按钮新增任务的逻辑:
js
// 3. 添加任务函数
function addTodo() {
// ① 获取输入框内容,去除首尾空格(避免添加空任务)
const todoText = todoInput.value.trim();
// ② 空内容直接返回,不执行后续逻辑
if (!todoText) {
alert('请输入待办事项内容!'); // 提示用户,更友好
return;
}
// ③ 向数组中添加新任务(对象形式:id唯一标识、text内容、completed完成状态)
todos.push({
id: Date.now(), // 用时间戳做唯一ID,避免重复
text: todoText,
completed: false // 默认未完成
});
// ④ 清空输入框,方便下次输入
todoInput.value = '';
// ⑤ 保存到本地存储(进阶优化)
saveTodosToLocalStorage();
// ⑥ 重新渲染任务列表(让新增的任务显示在页面上)
renderTodoList();
}
步骤 3:编写「渲染任务列表」函数
把数组里的任务动态渲染到页面上:
js
// 4. 渲染任务列表函数(把数组数据显示到页面)
function renderTodoList() {
// ① 先清空列表容器(避免重复渲染)
todoList.innerHTML = '';
// ② 遍历任务数组,为每个任务创建li元素
todos.forEach(todo => {
// 创建li标签
const li = document.createElement('li');
// 给li添加类名:基础样式 + 完成状态样式
li.className = `todo-item ${todo.completed ? 'completed' : ''}`;
// 把任务ID存到li的自定义属性中(方便后续查找对应任务)
li.dataset.todoId = todo.id;
// 拼接li的内容:任务文本 + 删除按钮
li.innerHTML = `
<span class="todo-text">${todo.text}</span>
<button class="delete-btn">删除</button>
`;
// 把li添加到任务列表容器中
todoList.appendChild(li);
});
}
步骤 4:编写「删除 / 切换完成状态」函数
实现点击删除按钮删除任务、点击文本切换完成状态的逻辑:
js
// 5. 处理任务点击(删除/切换完成状态):用事件委托,只给父元素绑定一次事件
function handleTodoClick(e) {
// ① 获取点击的目标元素
const target = e.target;
// ② 获取当前点击元素对应的任务li
const todoItem = target.closest('.todo-item');
if (!todoItem) return; // 不是任务项,直接返回
// ③ 获取任务ID(从自定义属性中取)
const todoId = Number(todoItem.dataset.todoId);
// 情况1:点击了删除按钮
if (target.classList.contains('delete-btn')) {
// 过滤掉要删除的任务(保留ID不等于当前ID的任务)
todos = todos.filter(todo => todo.id !== todoId);
}
// 情况2:点击了任务文本(切换完成状态)
if (target.classList.contains('todo-text')) {
// 找到对应任务,切换completed状态
const todo = todos.find(todo => todo.id === todoId);
if (todo) {
todo.completed = !todo.completed;
}
}
// ④ 保存到本地存储
saveTodosToLocalStorage();
// ⑤ 重新渲染列表
renderTodoList();
}
// 辅助函数:保存任务到本地存储
function saveTodosToLocalStorage() {
localStorage.setItem('todos', JSON.stringify(todos));
}
步骤 5:绑定事件(让函数生效)
给按钮、输入框、列表绑定事件,触发上面写的函数:
js
// 6. 绑定事件
// ① 点击添加按钮,触发添加任务
addTodoBtn.addEventListener('click', addTodo);
// ② 输入框按回车键,触发添加任务(优化体验)
todoInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
addTodo();
}
});
// ③ 任务列表绑定点击事件(事件委托)
todoList.addEventListener('click', handleTodoClick);
步骤 6:初始化渲染(页面加载时显示任务)
页面打开时,自动渲染本地存储中的任务:
js
// 7. 页面加载完成后,初始化渲染任务列表
renderTodoList();
完整 JS 代码汇总
把上面的 JS 代码整合到 <script> 标签内,完整代码如下:
js
// 1. 获取页面元素
const todoInput = document.getElementById('todo-input');
const addTodoBtn = document.getElementById('add-todo-btn');
const todoList = document.getElementById('todo-list');
// 2. 初始化任务数组
let todos = JSON.parse(localStorage.getItem('todos')) || [];
// 3. 添加任务函数
function addTodo() {
const todoText = todoInput.value.trim();
if (!todoText) {
alert('请输入待办事项内容!');
return;
}
todos.push({
id: Date.now(),
text: todoText,
completed: false
});
todoInput.value = '';
saveTodosToLocalStorage();
renderTodoList();
}
// 4. 渲染任务列表函数
function renderTodoList() {
todoList.innerHTML = '';
todos.forEach(todo => {
const li = document.createElement('li');
li.className = `todo-item ${todo.completed ? 'completed' : ''}`;
li.dataset.todoId = todo.id;
li.innerHTML = `
<span class="todo-text">${todo.text}</span>
<button class="delete-btn">删除</button>
`;
todoList.appendChild(li);
});
}
// 5. 处理任务点击函数
function handleTodoClick(e) {
const target = e.target;
const todoItem = target.closest('.todo-item');
if (!todoItem) return;
const todoId = Number(todoItem.dataset.todoId);
if (target.classList.contains('delete-btn')) {
todos = todos.filter(todo => todo.id !== todoId);
}
if (target.classList.contains('todo-text')) {
const todo = todos.find(todo => todo.id === todoId);
if (todo) {
todo.completed = !todo.completed;
}
}
saveTodosToLocalStorage();
renderTodoList();
}
// 6. 保存到本地存储辅助函数
function saveTodosToLocalStorage() {
localStorage.setItem('todos', JSON.stringify(todos));
}
// 7. 绑定事件
addTodoBtn.addEventListener('click', addTodo);
todoInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') addTodo();
});
todoList.addEventListener('click', handleTodoClick);
// 8. 初始化渲染
renderTodoList();
四、代码核心讲解(新手必看)
写完代码后,重点理解这几个关键逻辑,才算真正掌握:
1. 为什么用「事件委托」?
我们没有给每个删除按钮 / 任务文本单独绑定事件,而是给父元素 todoList 绑定一次事件,原因是:
- 减少事件绑定数量,提升性能;
- 动态创建的任务项(新增的 li)也能触发事件,不用重新绑定。
核心原理:点击子元素时,事件会冒泡到父元素,通过 e.target 判断点击的是哪个子元素,再执行对应逻辑。
2. 为什么用 Date.now() 做任务 ID?
Date.now() 返回当前时间的毫秒数(13 位数字),每一刻的数值都不同,能保证每个任务的 ID 唯一,避免删除 / 修改时搞错任务。
3. 本地存储为什么要转 JSON?
localStorage 只能存储字符串类型,而我们的任务是数组(里面是对象),所以需要:
JSON.stringify(todos):把数组转成 JSON 字符串,存入本地存储;JSON.parse(xxx):把本地存储的字符串转回数组,方便操作。
4. 为什么要先清空列表再渲染?
如果不执行 todoList.innerHTML = '',每次新增 / 修改任务时,数组会遍历并重复添加所有任务,导致页面出现重复的任务项。
五、运行测试(验证功能)
把所有代码整合到一个 .html 文件后,用浏览器打开,依次测试以下功能:
- 输入空内容,点击「添加任务」→ 弹出提示,不添加任务;
- 输入「学习 JS」,点击添加 → 列表出现该任务;
- 输入「买菜」,按回车键 → 列表新增该任务;
- 点击「学习 JS」文本 → 文字变灰加删除线(已完成);
- 再次点击「学习 JS」→ 恢复未完成状态;
- 点击「买菜」右侧的删除按钮 → 该任务消失;
- 刷新页面 → 剩余的任务还在(本地存储生效)。
六、新手拓展优化方向(可选)
如果想进一步提升,可以尝试实现这些功能,巩固知识点:
- 添加「清空所有任务」按钮,点击后清空数组并重新渲染;
- 给任务添加「编辑」功能,点击后可修改任务文本;
- 区分「已完成」和「未完成」任务,分栏显示;
- 添加任务优先级(高 / 中 / 低),不同优先级显示不同颜色;
- 优化样式,适配手机端(响应式布局)。
七、常见问题排查(新手避坑)
- 点击按钮没反应?→ 检查元素 ID 是否和 JS 中
getElementById的参数一致; - 新增任务后页面不显示?→ 检查是否调用了
renderTodoList()函数; - 刷新页面任务丢失?→ 检查是否调用了
saveTodosToLocalStorage(),且JSON.stringify/JSON.parse没有写错; - 删除任务没效果?→ 检查
todoId的类型(Number / 字符串)是否和数组中todo.id一致。
总结
- 本次 TodoList 的核心是「数据驱动视图」:所有操作先修改数组(数据),再重新渲染页面(视图),这是前端开发的核心思想;
- 事件委托、本地存储、数组常用方法(push/filter/find/forEach)是本次的核心知识点,也是 JS 基础的高频考点;
- 新手学习的关键是「先跑通,再理解」:先复制代码看到效果,再逐行拆解逻辑,最后自己从头写一遍,遇到问题再回头看讲解。
这个 TodoList 核心 JS 代码不到 50 行,覆盖了新手入门必须掌握的知识点,吃透它,你的 JS 基础会扎实很多。