原生JavaScript待办清单实战:从数组操作到数据驱动视图
发布时间:2026/10/17 4:07:52 锦皓数字建站

说实话第一次在课程目录里看到ch5_2这个文件夹名的时候我愣了一下。光看名字完全猜不出里面是什么是第五章第二个练习还是某个项目第二部分点开之后才发现这是某前端入门课程里第五章的第二个实战项目一个用原生 JavaScript 写的待办清单页面。项目不大但把它完整拆开讲一遍之后我发现这里面几乎涵盖了 JS 基础阶段最值得反复练的核心能力——数组操作、DOM 渲染、事件绑定、表单处理。如果你刚学完函数和数组、正准备找个小项目练手或者已经在带新人做前端基础训练这篇内容应该能给你省下不少弯路。ch5_2看起来只是一个低调的目录编号但它背后其实藏着一整套“从需求到实现”的完整思路。我不会只贴代码会把为什么这么写、哪些地方容易翻车、怎么调试都讲清楚。这篇文章适合刚学完 JavaScript 基础语法、想动手写第一个交互页面的初学者也适合带新人、想拿一个小项目快速评估基础是否扎实的开发者参考。1. 先别急着敲代码ch5_2 这个编号到底暗示了什么1.1 章节编号的潜规则很多人第一次看到ch5_2会下意识忽略它觉得就是个普通文件名。实际上这类命名方式在教程、教材、课程项目里非常常见ch是 chapter章节的缩写5代表第五章_2代表这一章中的第二个小节或第二个练习。也就是说它默认的前提是前面还有ch5_1后面大概率跟着ch5_3甚至下一章会是ch6_1。这种命名思路本质上是一种“教学节奏”的体现——每个章节被拆成多个可独立运行的练习每做完一个项目就能跑起来代码状态是完整且可控的。但我也得说句实在话这种命名在课程里用用没问题放在真实项目仓库里就不太合适了。ch5_2没法表达“这一节到底做了什么”三个月之后回来看大概率要想半天。真实项目里我更推荐用chapter05-todo-list这类语义化命名。课程里用编号是为了排序方便但你自己写项目时命名一定要能自解释。1.2 这个阶段你应该具备哪些基础既然这已经是第五章的第二个练习那前面章节大概率已经覆盖了这些内容变量与数据类型、函数定义与调用、数组的增删改查、基本 DOM 操作获取元素、创建元素、监听事件。如果你对上面这些概念还不熟直接做这个小项目会比较吃力尤其是在“数组状态变化后如何让页面跟着变化”这一环很容易卡住。ch5_2这个练手项目选得很有讲究。待办清单Todo List看起来简单但它实际上是一个完整的小闭环用户输入内容 → 程序读取并处理 → 渲染到页面 → 用户点击操作 → 数据更新 → 页面再次刷新。这个闭环把前端最核心的“视图与数据保持一致”这件事讲明白了。很多初学者写第一版会直接用 DOM 操作硬怼能跑但改起来非常痛苦。这个项目的意义就在于让你在还不理解框架的时候提前感受到“用数据驱动视图”这件事到底是怎么回事。我个人的判断标准是如果一个人能在不查资料的情况下独立把添加、删除、切换完成状态、筛选这四个功能写完并且没有明显 Bug那他的前端基础这一关就算真正过了。如果还需要边查边写也很正常毕竟这个阶段本来就是用来暴露问题的。2. 需求拆解与方案选型一个小页面为什么值得好好设计2.1 先把功能需求写清楚很多人拿到这个项目会立刻开写但写一半就发现逻辑乱了。我建议你先花十分钟把需求列出来不用写文档白纸黑字列清楚就行。以ch5_2这个项目为例核心功能就四条用户在输入框里输入文字点击“添加”按钮后这条内容出现在下方的待办列表中。每条待办有一个“完成”按钮点击后该条文字变为完成态通常会加删除线或改变颜色。每条待办有一个“删除”按钮点击后该条从列表中移除。页面提供筛选能力能切换查看全部、进行中、已完成三类内容。除此之外还有一些容易被忽略的细节需求输入框为空时不能添加空白内容添加成功后输入框要清空并重新聚焦列表为空时页面上要有提示文案已经完成的事项再次点击可以取消完成状态。这些内容看起来细小但每一个都是真实使用场景里会遇到的问题。提前列出来后面写代码时就不容易漏。2.2 为什么用数组驱动视图而不是直接改 DOM这个项目最关键的选型点就是“数据放哪里”。我见过很多初学版本是这样写的点击添加时先创建li元素塞进ul点击删除时找到当前li调用remove()方法把它从页面上移除。这种方式在小项目里能跑但问题在于你的界面状态分布在无数个 DOM 节点上一旦功能变多你根本记不住哪个节点对应哪条数据。ch5_2这个项目在这个阶段最值得学习的一点是它要求你单独维护一个todos数组。所有功能都以这个数组为准添加操作是push新对象进数组删除操作是filter掉目标对象切换完成状态是find到对象后修改属性。每次数据变化之后再统一调用一个render()函数把原数组重新渲染到页面上。一句话总结就是数据是唯一的“事实来源”页面只是数据的一个投影。这样做的好处是无论用户点了什么按钮你只需要关心“数组变成了什么”不需要关心“页面上那个li到底在哪”。这个思想你现在觉得多余等你后面接触框架、接触状态管理就会发现这就是最核心的底层逻辑。2.3 文件结构三件套还是全塞一个 HTMLch5_2的目录结构也很有意思。有人习惯把所有代码塞进一个index.html好处是打开就能跑坏处是一旦代码超过两百行混在一起很难维护。我更推荐拆成三个文件index.html放结构、style.css放样式、script.js放逻辑。这不仅是习惯问题也是帮助你建立“关注点分离”的意识。从实际测试来看拆文件并不影响运行难度。你只需要在index.html里通过link和script标签引用另外两个文件就行。关键在于script标签要放在body标签的结束位置之前这样能确保 JS 执行时 DOM 已经加载完毕。如果放在head里会出现拿不到元素的问题那是非常典型的初学者报错场景。3. 核心代码实现与关键环节讲解3.1 HTML 骨架与表单结构HTML 结构是这个项目的根基。我习惯把整体框架写成这样!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title待办清单 - ch5_2/title link relstylesheet hrefstyle.css /head body main classapp h1今天要做的事/h1 form idtodo-form input typetext idtodo-input placeholder输入待办事项... autocompleteoff button typesubmit添加/button /form div classfilters button>* { box-sizing: border-box; } body { font-family: system-ui, sans-serif; background: #f0f2f5; margin: 0; min-height: 100vh; display: flex; justify-content: center; align-items: flex-start; padding: 40px 16px; } .app { width: 100%; max-width: 480px; background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } h1 { font-size: 24px; margin: 0 0 16px; color: #1a1a1a; } #todo-form { display: flex; gap: 8px; margin-bottom: 16px; } #todo-input { flex: 1; padding: 10px 12px; border: 1px solid #d0d5dd; border-radius: 8px; font-size: 14px; outline: none; } #todo-input:focus { border-color: #4f8cff; box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.15); } #todo-form button { padding: 10px 16px; border: none; border-radius: 8px; background: #4f8cff; color: #fff; font-size: 14px; cursor: pointer; } .filters { display: flex; gap: 8px; margin-bottom: 16px; } .filters button { padding: 6px 12px; border: 1px solid #d0d5dd; border-radius: 999px; background: #fff; color: #555; font-size: 13px; cursor: pointer; } .filters button.active { background: #4f8cff; border-color: #4f8cff; color: #fff; } #todo-list { list-style: none; margin: 0; padding: 0; } #todo-list li { display: flex; align-items: center; gap: 8px; padding: 10px 8px; border-bottom: 1px solid #eef0f3; } #todo-list li:last-child { border-bottom: none; } .todo-text { flex: 1; font-size: 14px; color: #1a1a1a; line-height: 1.4; word-break: break-all; } li.completed .todo-text { text-decoration: line-through; color: #999; } .toggle-btn, .delete-btn { border: none; border-radius: 6px; padding: 6px 10px; font-size: 13px; cursor: pointer; } .toggle-btn { background: #e8f4fd; color: #1a73b4; } .delete-btn { background: #fdecec; color: #c0392b; } #empty-tip { text-align: center; color: #999; font-size: 14px; padding: 32px 0; margin: 0; }CSS 的要点是区分“状态类”和“行为类”。你会发现“已完成”的效果不是单独对某个按钮设置的而是通过给li添加上completed这个类名来控制的。这就是状态驱动的雏形——JS 里只需要修改类名样式的变化完全交给 CSS。理解这一点你会发现 JS 和 CSS 的分工变得非常清晰JS 负责改数据、改状态CSS 负责把状态翻译成视觉效果。3.3 JS数组、渲染函数与事件委托整个项目最核心的部分在script.js里。我的建议是先定义状态再写渲染函数最后写事件处理。完整的代码长这样const form document.getElementById(todo-form); const input document.getElementById(todo-input); const list document.getElementById(todo-list); const emptyTip document.getElementById(empty-tip); const filterButtons document.querySelectorAll(.filters button); let todos []; let currentFilter all; let nextId 1; function getVisibleTodos() { if (currentFilter active) { return todos.filter((todo) !todo.completed); } if (currentFilter completed) { return todos.filter((todo) todo.completed); } return todos; } function render() { const visibleTodos getVisibleTodos(); list.innerHTML ; visibleTodos.forEach((todo) { const li document.createElement(li); li.className todo.completed ? completed : ; li.dataset.id todo.id; const span document.createElement(span); span.textContent todo.title; span.className todo-text; const toggleBtn document.createElement(button); toggleBtn.textContent todo.completed ? 取消完成 : 完成; toggleBtn.className toggle-btn; const deleteBtn document.createElement(button); deleteBtn.textContent 删除; deleteBtn.className delete-btn; li.appendChild(span); li.appendChild(toggleBtn); li.appendChild(deleteBtn); list.appendChild(li); }); emptyTip.hidden todos.length ! 0; } function addTodo() { const title input.value.trim(); if (!title) return; todos.push({ id: nextId, title: title, completed: false }); nextId 1; input.value ; input.focus(); render(); } function toggleTodo(id) { const todo todos.find((item) item.id id); if (todo) { todo.completed !todo.completed; } render(); } function deleteTodo(id) { todos todos.filter((item) item.id ! id); render(); } form.addEventListener(submit, (event) { event.preventDefault(); addTodo(); }); filterButtons.forEach((button) { button.addEventListener(click, () { filterButtons.forEach((btn) btn.classList.remove(active)); button.classList.add(active); currentFilter button.dataset.filter; render(); }); }); list.addEventListener(click, (event) { const li event.target.closest(li); if (!li) return; const id Number(li.dataset.id); if (event.target.classList.contains(toggle-btn)) { toggleTodo(id); } else if (event.target.classList.contains(delete-btn)) { deleteTodo(id); } });这段代码里有几个设计细节值得展开说。第一每条待办是一个对象拥有id、title、completed三个字段。id是唯一标识这个字段在删除和切换状态时非常重要它让你的操作不依赖索引位置后面我会详细说为什么。第二render()函数没有做任何复杂判断它就是读取当前数组然后重新生成列表。这个方法在数据量小的时候简单粗暴且有效不用过多考虑性能。第三列表容器的事件监听挂在ul上而不是每个li上这叫事件委托。后面添加的新li不用额外绑定事件也能正常工作这是这个项目里性价比极高的一个技巧。全局变量nextId是一个很容易被忽视的点。很多人图省事会直接用Date.now()或者todos.length 1来生成 ID但前者在快速连续添加时可能出现重复后者在删除中间项后会产生相同 ID。用独立递增变量简单可靠。如果以后要支持多用户、多设备这就升级成了生成唯一标识的正式方案但在课程练习阶段递增整数已经足够。3.4 几个容易忽略的细节写这段代码时我特别想提醒一个地方input.value.trim()这一步不是可有可无的。trim()会去掉字符串首尾的空白字符用户如果只输入了一个空格然后点添加不加trim()这条内容会被正常添加进去结果页面上出现一条看起来是空白的待办非常违和。加了trim()之后空字符串会被if (!title)拦下来从根源上杜绝了空数据。另一个细节是筛选按钮的>const STORAGE_KEY ch5_2_todos; function loadTodos() { const saved localStorage.getItem(STORAGE_KEY); if (saved) { try { return JSON.parse(saved); } catch (error) { return []; } } return []; } function saveTodos() { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); }然后把let todos loadTodos()并在addTodo、toggleTodo、deleteTodo里调用saveTodos()。你会发现刷新页面后数据还在。这算是从“页面应用”到“Web 应用”迈出的第一步。注意JSON.parse要包一层try...catch否则本地数据一旦损坏整个页面都会崩溃。6.2 更接近真实项目的几个方向如果做完基础功能和存储还想继续练我建议按这几个方向扩展增加“编辑”功能点击某条待办可以修改文字内容增加“清除已完成”按钮一键移除所有已完成条目增加条目数量统计比如“还有 3 项未完成”让列表支持拖拽排序调整优先级。每一个方向都不难但都会强迫你更深入地思考数据结构和界面交互。比如编辑功能你就得考虑编辑状态应该存在哪里是存在每条待办对象里还是单独用一个变量存“当前正在编辑的 ID”两种方案各有什么代价这种问题没有标准答案但思考过程比答案本身更有价值。写代码不是背会一套模板就行真正的进步来自面对模糊需求时自己能做出合理决策。6.3 换个框架再看一遍状态驱动的初体验等这个原生版本做完我强烈建议你用 React 或 Vue 重写一遍。你会发现组件里声明一个todos列表然后用模板语法循环渲染事件触发时更新数据界面自动变化。整个过程几乎和这里写的思路一致维护一份数据界面由数据生成。区别在于框架帮你处理了“数据变化后如何高效更新 DOM”这件事你可以把精力放在“数据如何变化”上。这也是为什么我一直觉得ch5_2这个练习不该被轻视。它看起来只是个小作业但代码里包含的“数据驱动视图”思想是通往前端框架的桥梁。如果你在学 React 或 Vue 的时候觉得抽象难懂回头看看这个原生项目你可能会突然明白框架到底替你做了什么。按我自己的经验把这个项目连续做三遍会有很明显的效果。第一遍对着教程抄第二遍关上教程凭记忆写第三遍加新功能加存储重新组织代码。三遍跑下来数组操作、事件处理、渲染逻辑这些基础能力会变得非常扎实。之后再去看框架、看工具链你会发现自己理解起来比别人快一截。这个练习做到最后文件名是ch5_2还是chapter05-todo-list已经不重要了重要的是你不再害怕“从空文件夹开始写一个页面”这件事。每个项目的第一行代码都是这么起步的。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。