第二次作业听起来像赶着交差的玩意儿但真做下来我才发现它比第一次作业的含金量高得多。第一次作业大家基本都在确认一件事我会写代码。第二次作业就是逼着你面对另一个问题能不能把一个东西从零到一做成“能用”的状态。我这次接到的题目乍一看很简单——做一个在浏览器里运行的待办清单页面支持添加任务、勾选完成、删除操作并且刷新页面后数据不能丢。就这么一个东西我从搭结构到最终调试完断断续续花了差不多一个周末中间踩的坑、想通的事儿比看一礼拜教程都值。这篇文章就是这次作业的完整复盘内容包括需求拆解、代码设计、实操过程和一堆写给新手看的避坑经验正在做类似练习或者准备开始做小项目的朋友可以直接对照着抄。1. 项目概述与需求拆解1.1 这一次的作业到底考什么表面上看待办清单是个太常见的例子教程里到处都是甚至很多人的第一个小项目就是它。但正因为常见它才适合当第二次作业。我后来想明白了这道题并不是要你做出来一个多惊艳的东西而是考察四件基础能力结构设计、样式美化、交互逻辑、数据持久化。这四件事恰好覆盖了前端开发的日常核心场景做完一个清单页相当于把普通页面开发的基础流程完整走了一遍。我把需求拆成了几个模块来理解。首先是添加任务用户在输入框里打字点按钮或按回车新任务出现在列表中。其次是任务状态的切换每一个任务前面有个复选框勾上之后文字变成完成态。再次是删除每个任务右侧需要有一个删除按钮。最后是持久化刷新页面后之前输入的任务仍然在。如果只做前三个那只是一个纯内存操作的页面刷新就回到原点所以持久化才是这个作业拉开差距的关键点。这个拆解过程本身也是作业的一部分。很多同学拿到题目就直接写代码写出来的页面基本也能跑但稍一改动就崩。我不想那么做所以在纸上画了一下页面布局和交互流程输入区在顶部列表在下方每条任务包含复选框、文本内容、删除按钮。勾选与删除都是对列表数据操作数据变化之后重新渲染页面。这个“数据驱动视图”的思路虽然我当时还不知道这叫 MVVM 还是什么但它直接决定了我后面写的代码是清晰还是绕。1.2 为什么待办清单是最合适的“第二次作业”我一直觉得练习项目最重要的是“反馈感”。学循环和数组的时候在控制台输出一百遍“hello”也是跑通了但没有任何真实场景的感觉。待办清单不一样你写完马上就能用它有明确的输入、操作、输出哪怕我奶奶都能理解这是个什么东西所以用户视角的“好不好用”就变得可以感知了。第二个原因是它的复杂度刚刚好。不需要跟后端打交道不需要搞环境配置打开一个 HTML 文件就能跑。但要把它写得舒服又必须触及事件绑定、DOM 操作、数组增删、条件渲染、存储读写这些前端基本功。换句话说它是一道“麻雀虽小五脏俱全”的题目无论你以后是写 Vue 还是 React或者干脆走原生 JS 路线这些基础永远绕不开。第三个原因是它的扩展性。做完基础版本之后我几乎不用费力就能想到怎么往上加东西给任务加日期、增加编辑功能、按状态筛选、把未完成数量统计出来。这种“做完一个立刻知道下一步能做啥”的感觉对学习的正反馈极为重要。我第一次作业做完静态页面之后整个人是蒙的不知道接下来该干什么做完这次作业我脑子里至少冒出来五个改造方案。这就是第二次作业最大的价值。2. 核心技术点拆解与设计思路2.1 页面结构先想清楚再动手写代码前我做的第一件事是确定 HTML 骨架。对待办清单这个页面来说最简单的结构就是三层一个容器用于包住整体、控制宽度、一个输入区包括输入框和按钮、一个列表区用于动态生成任务条目。我最后定下来的结构大概是下面这个样子。div classapp header classapp-header h1我的待办清单/h1 /header section classinput-area input typetext idtaskInput placeholder输入任务按回车添加 / button idaddBtn添加/button /section section classlist-area ul idtaskList/ul /section /div这个结构没什么花哨的地方但是语义清楚header 管标题input-area 管录入list-area 管展示。后面写 JavaScript 时我需要操作的 DOM 就三个输入框、按钮、列表容器。所有逻辑都围绕这三个节点展开越简单越不容易出错。为什么我要刻意把结构拆这么开因为我见过太多同学把所有东西怼到几个 div 里class 命名靠拼音硬拼最后 JS 里 getElementById 和 querySelector 满天飞自己都忘了哪块对哪块。结构清晰有一个直接好处调试的时候你一眼就能从 DOM 面板里看出问题出现在哪个区域。我这次作业做到后面凡是出了 bug先看 DOM 结构再查数据基本能定位到八九成。2.2 CSS 不是配角是影响心情的关键很多初学者有一个倾向就是觉得样式嘛随便弄弄就行能用就好。但实际做下来我发现样式直接影响你调试的心情。一个界面整齐、对齐舒服的页面你打开浏览器测试的次数都会变多反之一个字体忽大忽小、按钮忽上忽下的页面你写两行代码就不想看第二眼更别提耐心改 bug 了。我这次用的布局核心是 flex。输入区域用 flex 让输入框和按钮排成一行列表项用 flex 让复选框、文本、删除按钮左右分布。关键在于不要每次写样式都从零开始拼先想清楚哪里要“排列”哪里要“撑满”。输入框的 flex: 1 让它自动占据剩余宽度按钮固定宽度这样窗口大小变来变去都不会乱。列表区域我给每一项设置了 padding、边框、圆角和 hover 背景这样鼠标划过时有反馈页面才有一点点“产品”感。另外一个细节是完成态的样式。我一开始的想法是文字加一条删除线写起来很简单.task-item.completed .task-text { text-decoration: line-through; color: #999; }但实际效果比我想象的需要更多打磨复选框勾选后整条任务的背景颜色也变淡一点交互反馈才完整。很多教程到这就停了但真正的细节正是在这些“再进一步”上。如果只追求功能跑通作业交上去也能过但如果你想让自己满意这些细节就必须抠。2.3 功能逻辑的核心是“数据驱动”说句实话作业里最难的不是语法而是编程思维的转换。我第一版代码是“面向操作”写的用户点删除我就找这个按钮的父节点把它从 DOM 里移走。用户勾选复选框我就改这一条的 class。表面看每个功能都能跑但代码越写越多各功能之间互相干扰到最后连自己都理不清了。后来我改成“面向数据”的写法。核心做法是用一个数组来存所有任务每个任务是一个对象字段包括 id、文本内容和完成状态。页面上所有操作本质上都是对这个数组做修改完成之后重新渲染整个列表。这样看起来多了一个“渲染”的动作好像多写了很多代码但实际上所有功能都变得统一了添加就是 push 一条数据删除就是按 id 过滤掉一条数据勾选就是找到数据改它的完成字段。界面上的变化只是数据变化的结果。这个思路听起来有点抽象但写出来其实很具体。我用一个 render 函数把所有任务重新生成一遍替换到列表容器里用数组的 filter 方法实现删除用 map 方法实现状态切换。说白了就是让数据成为唯一的事实来源DOM 只是它的投影。这种思路一旦建立再回头看其他框架或者库会发现它们解决的核心问题也是这个只是换了一层更高效的手段。3. 实操过程与关键代码实现3.1 先把基础交互跑通我动手的顺序是先写一个最朴素的版本不做任何存储只实现“添加任务并显示”。这一步特别重要因为如果一开始就想着把所有功能一次写完出了问题根本不知道是哪一步引入的。我的第一个 JS 版本大概是这样const input document.getElementById(taskInput); const addBtn document.getElementById(addBtn); const list document.getElementById(taskList); function addTask() { const text input.value.trim(); if (!text) return; const li document.createElement(li); li.className task-item; li.textContent text; list.appendChild(li); input.value ; input.focus(); } addBtn.addEventListener(click, addTask); input.addEventListener(keydown, (e) { if (e.key Enter) { addTask(); } });这个版本跑通之后我立刻打开浏览器测试了几轮输入文字点添加、输入文字按回车、空内容不添加。直到这些行为都符合预期我才继续往下加删除和状态切换。很多人觉得这种“挤牙膏”式写法太慢但实际遇到 bug 时你会感谢自己当初是一步一步来的因为问题范围被限制得很小你一眼就能看到是哪儿写错了。这里有一个小细节input.value.trim() 一定要做。不加 trim用户敲几个空格也能添加任务页面上就会出现一堆空条目看起来很蠢。这个习惯也是我到后面才开始养成的任何输入型内容先消除首尾空格再说这是最基础的输入校验。3.2 升级为数据驱动版本基础交互跑通后我就开始往“数据驱动”的方向改写。这一步涉及到一个之前没有接触过的关键问题如何删掉数组里的某一条我最初想的是用 splice 加上下标但下标和 DOM 里的顺序对应起来非常容易出错。后来选择用 id 来唯一标识每一条任务给每个任务分配一个递增的 id。删除时用 filter 把不等于当前 id 的数据保留下来这样就不用担心下标错位。状态切换同理。给任务对象加一个 completed 字段勾选时把对应 id 的任务字段取反。这样整个代码的逻辑变得极其干净。渲染函数我写成下面这样let tasks []; let nextId 1; function render() { list.innerHTML ; tasks.forEach((task) { const li document.createElement(li); li.className task.completed ? task-item completed : task-item; li.dataset.id task.id; const checkbox document.createElement(input); checkbox.type checkbox; checkbox.checked task.completed; checkbox.addEventListener(change, () toggleTask(task.id)); const span document.createElement(span); span.className task-text; span.textContent task.text; const delBtn document.createElement(button); delBtn.className delete-btn; delBtn.textContent 删除; delBtn.addEventListener(click, () deleteTask(task.id)); li.appendChild(checkbox); li.appendChild(span); li.appendChild(delBtn); list.appendChild(li); }); }用 createElement 创建节点而不是用 innerHTML 拼接字符串是因为在创建的时候就能直接把事件绑定到具体的任务 id 上。如果用模板字符串拼接还得在渲染完之后重新去选中 DOM 节点、找下标、绑事件非常绕。createElement 看起来啰嗦但在这种题目里是极其稳妥的做法数据直接挂钩事件处理器反过来也让页面渲染这块更容易理解。这里其实还藏着一个很重要的前端基础概念为什么需要使用 dataset.id 存一下 id因为测试时你可能要从控制台去检查当前点击的这条到底是哪条任务dataset 能让信息透出来调试时直接查数据相当方便。3.3 补上本地存储才算真正完整功能都能跑之后我开始做持久化。这一步的核心是使用浏览器自带的 localStorage。它存储的是字符串所以我要把数组序列化成 JSON 字符串存进去读取时再解析回来。于是涉及两个 APIJSON.stringify 和 JSON.parse。我改造的思路很简单第一每次数据变化后就调用 save()把整个 tasks 数组存进 localStorage第二页面加载时从 localStorage 里读取如果读到了就用保存的数据读不到就为空数组。这个流程最大的好处是它不需要改动其他任何交互代码。因为前端结构是“所有操作都改数据数据变完就渲染”而 save 只是附着在每次改动之后的额外动作渲染函数完全不用动。const STORAGE_KEY todo_tasks_v1; function save() { localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); } function load() { const saved localStorage.getItem(STORAGE_KEY); if (saved) { tasks JSON.parse(saved); nextId tasks.reduce((max, t) Math.max(max, t.id), 0) 1; } } load();这里有一个坑我当时没注意nextId 的恢复。如果存了数据下一轮新任务的 id 还继续从 1 开始那添加的新任务就可能和之前存在的任务 id 冲突删除或勾选时会找到同 id 的另一条。所以我必须从已保存的任务中找出最大的 id然后加一作为新的起始 id。这个细节花了我不少时间才想通如果不恢复 nextId表面看不出问题但稍微多操作几次就会发生“勾选到别的任务”这种诡异现象。3.4 把代码组织到容易维护的状态全部功能做完之后我的文件分成了三块index.html、style.css、main.js。JavaScript 文件内部我按函数的功能来组织load/save 是一组负责存储addTask/toggleTask/deleteTask 是一组负责操作 tasks 数组render 是一组负责把数据绘制到页面上。事件绑定单独放在文件底部的初始化区。这种组织方式算不上什么架构设计但它是自然长出来的。当我把一个文件里的函数按“存储、操作、渲染”分类之后我发现后续要加功能非常轻松。比如想加一个“清空已完成”按钮我只需要写一个 filter 去掉所有 completed 为 true 的任务然后保存、渲染三行代码搞定。这种“脚手架搭对了后面全是顺水推舟”的感觉是这次作业带给我最大的成就。4. 常见问题与排查技巧实录4.1 新添加的任务没有绑定事件事件委托的必要性我第一版写的时候用的是给每个按钮单独 addEventListener。一开始是好的因为初始页面为空列表里一个按钮都没有。后来我发现先添加的任务能删除新添加的任务却怎么点都没反应。原因很简单事件绑定的代码在页面初始化时只跑了一次而新创建的元素是在那之后才出现的自然没有事件粘在上面。解决这个问题有两种思路。一种是每创建一个元素时就立刻给它绑事件也就是我在 3.2 里展示的 createElement 写法另一种是使用事件委托把监听器绑在列表容器上利用事件冒泡来判定点击对象。后者更高级一点代码也更简洁但原生事件委托对初学者来说更容易懵。我最后选了前一种因为它的逻辑最直白每个元素在出生时就带着自己的“行为”。两种方案都能解决思考过程比方案本身更重要。4.2 刷新后数据丢失存储没调的经典现象如果只做增删改不做持久化刷新页面数据必然消失。出现这个问题的原因就是数据只存在内存里浏览器一刷新进程就没了。我当时的排查方式是先打开浏览器的 Application 面板直接看 localStorage 里有没有内容。一看是空就说明存储环节压根没执行。再检查发现是我没在添加任务后调用 save()数据变了但根本没写进 localStorage。这种问题定位起来不难但重要的是养成“先确认数据再怀疑渲染”的排查顺序。很多人一看到页面上没东西就以为是渲染出了问题反复改 render 函数改了一晚上都没用。实际上存储就出了纰漏你渲染函数写得再好也没用。我还给存储 key 加了版本号后缀 _v1这么做是为了以后改数据结构时不至于拿旧格式的脏数据来渲染算是提前给自己埋的一个好习惯。4.3 输入框里的回车事件在中文输入法下触发这个坑可能很多人遇到但都没注意。在做“按回车添加任务”的功能时我一开始直接监听 keydown 事件判断 e.key 是否为 Enter。在英文输入法下一切正常但一旦切换成中文输入法打完拼音敲回车确认候选词这个回车键按下去也会被当作添加操作导致你正在选字任务却被添加进去了体验非常糟糕。第一次遇到时我真的以为是代码 bug改了半天的 e.keyCode 和 e.preventDefault都没彻底解决。后来查到标准的做法是同时判断 e.isComposing 的值为 true 时直接忽略这次回车。这个属性表示当前正处于输入法组合状态。我把它加进判断逻辑之后中文输入法的误触发就消失了。input.addEventListener(keydown, (e) { if (e.key Enter !e.isComposing) { addTask(); } });这个细节非常冷门教程里十有八九不会提到但它直接影响输入体验。我把这段写出来就是希望正在做类似项目的朋友不要像我一样在这个地方卡上一晚上。4.4 常见问题速查表为了方便排查我把这次作业中遇到的典型问题整理成了一个表后面再写类似练习项目时也可以直接对照着找方向。现象可能原因排查思路解决方式新元素点击无反应事件绑定未覆盖动态元素查看控制台是否有报错检查元素生成时是否绑定了事件创建元素时立刻绑定或使用事件委托刷新后数据丢失未调用存储写入打开 Application 面板查看 localStorage每次数据变更后调用 save()勾选后页面状态不对数据结构里的 completed 未更新打印 tasks 数组检查字段值确保 toggle 时找到正确的 id中文输入法敲回车误触发未判断输入法组合状态检查 keydown 事件的 isComposing 属性添加 e.isComposing 判断删除后控制台报 undefined下标与 id 混淆打印被删 id 和任务列表改用 id 定位数据避免基于下标操作页面布局随窗口大小变乱未合理使用弹性布局观察缩小窗口时元素排列输入框设置 flex: 1容器设置最大宽度这张表不是死知识而是我这次作业过程中挨个踩出来的。你大概率不会一次性全踩到但踩到哪一个对照着排查会比从头瞎改高效得多。4.5 调试工具不是写代码时才用的这里我还想多说一句关于浏览器开发者工具的使用心得。很多人把它当成“出了 bug 才打开的工具”但我这次养成了一个习惯每写完一个功能就打开 F12 的 Console 和 Elements先把刚才的交互做一遍然后看 DOM 树结构和 Console 输出。这样做的原因是界面正常不等于数据正常有时候页面看起来没问题但 tasks 数组里已经混进了空字符串或重复 id等到下一次操作时才突然爆炸。我经常用的调试方法是在代码里加一段 console.log(tasks)渲染之前先看数据长什么样。确认数据正确再去看页面如果数据本身错了那问题百分之百在操作数组的那段代码里。不要一上来就怀疑 CSS数据层面先排查干净能少走很多弯路。5. 个人经验与后续扩展方向做完这次作业我自己最大的变化是对“练习”这件事有了新的看法。以前总觉得练习就是把教程抄一遍跑通了就完事。这次我不光把功能做完了还在每一个环节问了自己一句为什么为什么用数据驱动而不是直接操作 DOM为什么用 id 而不是下标为什么存储要存 JSON 字符串。这些问题并不深奥但一个个追问下来代码里的每一步都有它存在的理由而不是我照着网文抄出来的死代码。这种感觉非常奇妙有点像从“看得懂代码”迈向了“能控制代码”。还有一个很实际的收获我给自己立了一个规矩项目里的每一段代码都要能说清楚它解决什么问题。说不清楚的代码要么是没必要的要么是思路不对。这个习惯让我把代码量精简了一小半删掉了很多冗余的变量和重复的 DOM 查询。最后再分享一个我这次做完后考虑过的扩展方向。第一是给任务增加优先级和截止日期渲染时按日期排序。第二是增加筛选按钮比如只看未完成、只看已完成。第三是加入计数信息显示还剩几项没完成。这些功能都不复杂但它们会把一个简单的练习变成看起来像真实产品的页面。我把这些列在笔记本上打算作为第三次作业的备选方向。如果你也在做类似的练习建议你也试一次“项目做完了再往回看”的方法找出那些可以顺手改掉的不顺手之处那才是第二次作业里最值钱的收获。