这周作业标题就叫“week5作业”,任务内容是用原生 JavaScript 写一个待办事项应用。说实话,刚看到题目的时候我挺不以为然的——Todo List 嘛,网上教程一抓一大把,功能也无非就是增删改查、本地存储,看起来半小时就能搞定。但真正动手写的时候我才发现,越是这种“简单”的作业,越能暴露你对基础知识掌握得扎不扎实。
我花了一整个晚上加半个周末,边写边踩坑,最后把作业做完了。这篇博客就是把整个过程记录下来:我拿到题目后是怎么拆解的、为什么选择这种技术方案、每一块代码背后的思考,以及我实际遇到的几个典型 bug 和排查思路。如果你是正在学前端、或正准备做类似作业的人,这篇文章应该能帮你省下不少时间——至少能让你少走几个我走过的弯路。
1. 动手之前,先把这个作业的真实考点拆出来
1.1 作业要求背后到底在考什么
作业描述写得不算复杂:做一个待办事项应用,支持添加、删除、标记完成、筛选(全部/未完成/已完成)、数据刷新后不丢失,界面风格不限。但仔细想想,这个作业真正想考察的远不止“能不能做出来”,而是你有没有把前几周学的东西串起来。
那时候我们已经学完了 HTML 基本语义、CSS 布局、JavaScript 的 DOM 操作、事件机制、数组高阶方法(map、filter、forEach)、对象、JSON 格式,以及 localStorage 的基本用法。这个作业就是把它们全部揉在一起:用 HTML 搭结构,用 CSS 做样式,用 JavaScript 操作 DOM 和状态,用 localStorage 做持久化。所以写完功能只是及格,真正的加分项藏在细节里——比如数据处理得规不规范、DOM 操作有没有性能浪费、刷新页面后状态能不能正确恢复。
我把任务拆成了四层:功能层(增删改查)、状态层(数据怎么存)、渲染层(数据怎么显示到页面)、持久化层(刷新后数据怎么保留)。每一个层都有对应的核心知识点,这也是为什么这种“老掉牙”的作业布置频率这么高——它真的能把前端基础能力测出来。
1.2 为什么我坚持不用框架,就用原生 JS 写
写之前我也犹豫过,要不要直接上 Vue 或者 React。毕竟项目环境和构建工具都是现成的,组件化写起来也确实爽。但后来我放弃了这个念头,原因有两个。
第一,第五周作业的核心目标是检验 JavaScript 基础,框架会把很多细节藏起来。比如 Vue 的双向绑定会自动帮你更新视图,React 的 setState 会帮你处理状态变更,但你不知道底层发生了什么,作业写完了对 DOM 操作还是一头雾水。第二,老师点评代码的时候,原生 JS 的代码是“透明”的——你写的每一行都在明面上,好与不好一眼就能看出来,这其实是查漏补缺的好机会。
所以我最终选定方案:纯 HTML + CSS + JavaScript,不引任何库和框架。JavaScript 部分用一个 IIFE(立即调用函数表达式)包起来,内部通过一个数组保存所有待办数据,并提供操作方法。这样既不会污染全局作用域,又能还原“模块化开发”的思路,再小的项目也值得养成这种习惯。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:数据设计和渲染逻辑才是重头戏
2.1 数据用数组管理,而不是一个个变量
最开始我写过一个很天真的版本:用一个 text 变量存当前事项内容,用一个 completed 变量存完成状态,再加一个 count 变量存总数。结果代码写了一百多行,逻辑越来越绕——因为添加第二个事项时,变量根本不知道该怎么存,只能靠拼接字符串硬塞进 DOM,到后面完全收不住了。
后来我把数据改成数组结构,每个待办事项是一个对象:
javascript复制let todos = [];
每添加一项,就往数组里 push 一个对象。这个对象该包含哪些字段?我一开始只设计了 text 和 completed,但后来发现这样做不够。如果只靠文本内容去区分每一件事,一旦你加了两个一模一样的“买牛奶”,删除时就会出问题——你删掉的是哪一个?所以必须给每个待办一个唯一标识 id,我选择用 Date.now() 结合随机数来生成,保证不重复。
我最终的数据结构长这样:
javascript复制{
id: 1680000000000, // 唯一标识,用时间戳生成的
text: "买牛奶", // 事项内容
completed: false, // 是否已完成
createdAt: "2025-03-30" // 创建时间(后面统计数据时能用到)
}
之所以把 completed 单独作为字段而不是直接用 class 类名去判断,是因为类名是视图层的概念,而 completed 是数据层的状态。如果只改了 class 没改数据,刷新页面后一切回到原点;反过来,数据是“唯一事实来源”,视图只是数据的映射,这才是数据驱动视图的核心思想。
2.2 渲染函数要“无脑”:数据怎么变,页面就怎么变
当我有了数据数组之后,下一步就是写渲染函数。我的设计思路很简单:任何数据变化,最终都调用同一个 render 函数,让整个列表重新渲染。这是整个作业中最重要的一步——没有它,你会陷入“每次操作都要手动改 DOM”的泥潭。
javascript复制function render() {
const todoList = document.getElementById("todo-list");
todoList.innerHTML = "";
// 根据 activeFilter 决定显示哪些数据
let visibleTodos = todos;
if (activeFilter === "active") {
visibleTodos = todos.filter(todo => !todo.completed);
} else if (activeFilter === "completed") {
visibleTodos = todos.filter(todo => todo.completed);
}
// 遍历生成列表
visibleTodos.forEach(todo => {
const li = document.createElement("li");
li.className = "todo-item" + (todo.completed ? " completed" : "");
li.dataset.id = todo.id;
// ... 构建子节点
});
}
我第一次就直接用了 innerHTML += 拼接字符串的方式,结果发现一个问题:旧内容没清空,列表越加越长。这是因为 += 是在原来的 HTML 基础上追加,而不是替换。所以我每次先 innerHTML = "" 清空列表再重新生成。这里还有个安全隐患我需要特别说明:如果直接把用户输入的内容拼到 innerHTML 里,一旦用户输入 <img src=x onerror=alert(1)> 这种字符串,浏览器就会执行它,这就是典型的 XSS 攻击。所以最稳妥的做法是创建 DOM 节点、用 textContent 赋值,而不是拼字符串。
2.3 事件绑定用事件委托,避免“新增后绑定失效”
一开始我是在创建 li 的时候顺便给删除按钮绑定 addEventListener。但这样有两个问题:第一,如果新增一个待办,新生成的事件又要重新绑定,代码重复;第二,如果列表里有几百项,每个按钮绑一个监听器,内存开销不小。
更合理的做法是用事件委托——在 ul 上监听点击事件,然后通过 event.target 判断用户点了什么:
javascript复制todoList.addEventListener("click", function (e) {
const target = e.target;
if (target.classList.contains("delete-btn")) {
const id = target.closest("li").dataset.id;
deleteTodo(id);
} else if (target.classList.contains("toggle-checkbox")) {
const id = target.closest("li").dataset.id;
toggleTodo(id);
}
});
只添加了一个监听器,却能处理所有现有和未来的元素。这是我这次作业里觉得最有收获的部分之一,也推荐大家养成这个习惯。
3. 实操过程:从空白文件到完整功能
3.1 先搭 HTML 骨架,把结构和语义理清楚
我先写 HTML 结构。页面上需要这几个部分:一个输入框加一个“添加”按钮,一个列表容器,三个筛选按钮(全部/未完成/已完成),以及一个剩余任务数统计。因为以后要操作 DOM,我给每个关键元素都加了 id 或 class。
html复制<div class="app">
<h1>待办事项</h1>
<form id="add-form">
<input type="text" id="todo-input" placeholder="输入待办事项..." autocomplete="off" />
<button type="submit" id="add-btn">添加</button>
</form>
<div class="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>
<ul id="todo-list"></ul>
<p id="todo-count">共 0 项未完成</p>
</div>
用 <form> 而不是给按钮单独绑定点击事件,是因为表单支持回车提交。用户输入完按 Enter 就可以添加,体验会好很多。这一点大家写的时候很容易忽略——一开始我在按钮上绑 click,用户回车没反应,后来改成 form 的 submit 事件就顺了。注意输入框 加了 autocomplete="off",防止浏览器弹出自动补全干扰。
3.2 实现 localStorage 封装:数据刷新后不能丢
待办数据要存到 localStorage,但 localStorage 有一个很坑的点:它只能存字符串。如果你直接把对象数组塞进去,得到的是 "[object Object]"。所以写入前必须转成 JSON 字符串,读取时再解析回来。
我封装了一个简单的小对象来管理存取:
javascript复制const store = {
get() {
const raw = localStorage.getItem("todos");
try {
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
},
set(todos) {
localStorage.setItem("todos", JSON.stringify(todos));
},
clear() {
localStorage.removeItem("todos");
}
};
为什么要单独封装?原因很简单:如果哪一天你想把存储从 localStorage 换成 sessionStorage,或者从本地换成服务端,只需要改 store 里的三个方法,其他代码完全不用动。设计模式里的“依赖倒置”思想在小项目里也能体现,这也是架构思维的一种训练。
另外,JSON.parse 是有可能抛异常的。比如 localStorage 里的数据被手动改坏,或者之前存的是其他格式,一解析就报错。所以我在 get 方法里加了 try...catch,解析失败时返回一个空数组。为了防止数据坏了导致整个应用崩溃,这个保护很重要。
3.3 核心交互逻辑:添加、删除、切换、筛选
接下来是主要功能函数的实现,这里我逐一说明关键点。
添加待办的逻辑:
javascript复制function addTodo(text) {
const trimmed = text.trim();
if (trimmed === "") {
alert("待办内容不能为空!");
return;
}
const newTodo = {
id: Date.now() + Math.random(),
text: trimmed,
completed: false,
createdAt: new Date().toLocaleDateString()
};
todos.push(newTodo);
store.set(todos);
render();
}
注意两点。第一,text.trim() 是必须的——用户如果只输入空格,这条待办没有任何意义。如果用户输入“ 买菜 ”,应该存去掉首尾空格后的版本。第二,添加完成后要调用 store.set(todos) 再 render(),顺序不能反:先持久化,再更新视图。因为 render 是幂等操作,调用多次也不会有副作用,但持久化如果漏了,刷新页面数据就丢了。
删除待办用 filter:
javascript复制function deleteTodo(id) {
todos = todos.filter(todo => todo.id !== id);
store.set(todos);
render();
}
这里 filter 返回的是一个新数组,不会修改原数组。好处是原数组不会被意外改变,符合函数式编程里“不可变数据”的理念。我最初用 splice 找索引删除,但 splice 会原地改数组,而且要写 indexOf 找位置,代码更啰嗦。filter 一行搞定,语义也更清晰。
切换完成状态用 map:
javascript复制function toggleTodo(id) {
todos = todos.map(todo => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed };
}
return todo;
});
store.set(todos);
render();
}
这里我用展开运算符创建了一个新对象而不是直接改原对象 todo.completed = !todo.completed,就是为了保持数据不可变。虽然这个项目里不改原对象问题也不大,但在 React、Vue 等框架中,直接改原对象会导致页面不更新,养成不修改原数据的习惯是你在第五周就能埋下的好种子。
筛选功能用一个 activeFilter 变量控制:
javascript复制let activeFilter = "all";
function setFilter(filter) {
activeFilter = filter;
// 给筛选按钮加高亮 class
document.querySelectorAll(".filter-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.filter === filter);
});
render();
}
筛选按钮的事件绑定同样用事件委托,在父容器上监听。按钮的 data-filter 属性存了筛选类型,这样以后想加新筛选类型,比如“仅看今天创建”,只需要加一个按钮,不需要改 JS 逻辑。
3.4 样式细节与剩余条数统计
功能做完之后,我开始补样式。这里分享两个我觉得很重要的细节。
第一,已完成事项需要明显的视觉反馈。我给 li 加了一个 .completed 类,里面把文字颜色调浅,并加上删除线:
css复制.todo-item.completed .todo-text {
text-decoration: line-through;
color: #aaa;
}
这样用户一眼就能看出来哪些已完成,不用盯着复选框看半天。
第二,剩余条数的统计数字要实时更新。我直接在 render 函数里调用更新:
javascript复制const remaining = todos.filter(todo => !todo.completed).length;
document.getElementById("todo-count").textContent = `共 ${remaining} 项未完成`;
注意这里用的是 textContent 而不是 innerHTML,虽然这个场景没有用户输入的内容,但养成用 textContent 的习惯能避免大部分 XSS 风险。
还有一个界面细节我比较满意:当列表为空时,显示一个“暂无待办事项”的提示;当筛选后列表为空时,显示“没有未完成的事项”。这个小处理让用户不会面对一片空白列表发愣。实现方式是在 render 里判断 visibleTodos.length === 0,然后创建一个空状态的 p 节点插入列表。
4. 常见问题与排查技巧实录
4.1 数据变了,页面却没刷新
这是我遇到的第一个问题。功能写完后,我发现添加一条待办后,列表没有变化,但控制台打印数据明明已经 push 进去了。排查了一下,原来是我在 addTodo 里忘了调用 render。
这类问题的本质是:数据层和视图层脱节了。后面我总结出一条硬性规则:所有修改数据的操作,最后必须紧跟 store.set() 和 render()。我甚至写了一个统一的 update 函数来封装这两步:
javascript复制function update() {
store.set(todos);
render();
}
之后 addTodo、deleteTodo 等函数里都只写一行 update(),再也不怕漏调了。
4.2 localStorage 里存的是 [object Object]
这个问题很典型,很多同学应该都踩过。出现的原因是没有 JSON.stringify,直接 localStorage.setItem("todos", todos),于是对象被自动转成了字符串。要知道 localStorage 只能存字符串,对象会被强制调用 toString(),而对普通对象来说 toString() 返回的就是 [object Object]。
修复方式就是我前面说的,写入时转一下 JSON 字符串:
javascript复制localStorage.setItem("todos", JSON.stringify(todos));
读取时再转回来:
javascript复制const todos = JSON.parse(localStorage.getItem("todos"));
还有一个小细节:用 JSON.parse 解析空字符串会报错,所以读取时一定要先判断是不是空值,我前面 store 对象里已经处理了这种情况。
4.3 事件委托时 e.target 拿不到 li
做删除功能的时候,我一开始是监听 li 上某个按钮的点击,然后通过 e.target.parentNode 来找到对应的 li。但有个情况让我很奇怪:明明点击的是按钮,有时候 e.target 却是一个 <span> 或 <img>——因为按钮内部还有子元素,点击的实际是子元素。
解决方式是使用 closest() 方法:
javascript复制const li = e.target.closest("li");
closest 方法会从当前元素开始逐级向上查找匹配的祖先元素,不管点击的是按钮还是按钮里的 span,都能找到 li。这个方法在做事件委托时非常常用,强烈推荐掌握。
4.4 筛选状态切换后,再做添加操作显示不正确
这个问题比较隐蔽。场景是这样的:我先把筛选切到“已完成”,列表只显示已完成项。然后我在输入框里添加了一条新待办,按理说新待办应该出现在列表里,但页面却什么都没显示。
原因在于 render 函数会根据 activeFilter 过滤数据,而新待办的 completed 是 false,在“已完成”视图下被 filter 掉了。这其实不是 bug,是我一开始没有想清楚视图逻辑。解决办法很简单:新添加的待办不跟随当前筛选,而是切回“全部”视图展示,或者保持当前筛选并提示用户“新待办已添加,可在全部/未完成视图中查看”。
我选择了前者——添加后自动把筛选重置为“全部”,避免用户找不到刚添加的内容。
4.5 代码自查清单:交作业前最后过一遍
作业做完后我整理了一个自查清单,每次写前端代码都会过一遍,这里也分享出来:
| 检查项 | 说明 |
|---|---|
| 是否有全局变量泄露 | 所有变量和函数应在一个 IIFE 或用模块封装 |
| 数据处理是否有 trim 和空值判断 | 用户可能输入空格或空字符串 |
| 是否直接拼接 innerHTML | 涉及用户输入时,绝对禁止 |
| 刷新后数据是否保留 | 确认 store.set 和 store.get 都正常 |
| 是否漏调 render | 每次数据变更后都要同步视图 |
| 筛选切换后新增是否合理 | 明确新数据在当前筛选下是否可见 |
| 按钮是否有无障碍支持 | 加 aria-label 或 role 属性 |
每一项背后都是我踩过的坑。表格里这几条看起来简单,但它们正是作业评分的分水岭——功能大家都能做出来,差的就是这些细节的完整度。
5. 这次作业让我想明白的一些事
写完这个 week5 作业,我最大的感触是:一个看似简单的项目,如果能按照“数据驱动视图”的思路去做,代码会变得格外清晰。以前我写交互就是“点一下改一下 DOM”,到后面自己都看不下去;这次用数组做数据源、操作数据再渲染视图,整个逻辑链条非常顺,排查问题时也只需要关心数据变没变对,不需要去翻 DOM 结构。
另外一个收获是事件委托。以前我总觉得它只是性能优化的技巧,这次真正用到才明白,它更大的价值是让代码逻辑集中在一个地方,新增元素时不用反复绑定和解绑事件。这种从“操作元素”到“委托处理”的思路转变,对后面接触框架非常有帮助。
最后再分享一个小技巧:写作业时每完成一个功能就顺手 commit 一次,不要等全写完再提交。因为中途很可能改崩一个功能,有版本记录可以随时回退。我这次就是因为“筛选状态切换后添加待办不显示”的问题差点改乱,幸好有之前的提交记录可以对照。
这个项目虽然只花了一个周末,但包含的知识点密度比之前的作业高了一个级别。如果你也刚好在做类似的作业,希望这篇博客能帮你少踩几个坑。
