从零学JavaScript:用待办清单项目串起数组、DOM与事件委托
连着写了八天学习笔记说实话到第五天的时候差点想放弃。不是学不会是越学越觉得知识点散今天记这个明天记那个好像什么都碰过又好像什么都拿不出手。DAY9这天我想明白了一件事与其继续往下赶进度不如停下来用一个小项目把前面学过的东西串一遍。这篇文章就是DAY9的完整记录包括当时的思路、写出来的代码、碰到的坑和排查过程。写给正在自学编程、尤其是刷完基础部分却不知道怎么用的人。如果你也卡在“学完了但不会做东西”这个阶段这篇应该能给你一些参考。1. 第九天开始前我手头的进度长什么样先说清楚第九天之前的积累。前八天基本是在打地基第一二天过了一遍HTML的常用标签和CSS的布局基础第三天开始接触JavaScript的变量、数据类型和函数第四五天练了循环和条件判断第六七天专门啃数组和对象第八天学了一点DOM操作知道怎么通过document.querySelector拿到页面上的元素也能改改文本和样式。这个进度不算快但每一步都是动手敲过的不是光看视频。到了第八天晚上我突然有一种隐约的感觉那些语法都能看懂但真让我独立做一个带交互的页面我大概率不知道从哪下手。比如我想做一个最简单的待办清单脑子里浮现的只有“要用querySelector拿输入框”“给按钮加onclick”这样的碎片拼不出一整条链。所以DAY9那天的目标很明确不是学新语法而是用待办清单这个经典小项目把数据、数组方法、DOM操作和事件处理串成一条完整的线。选待办清单做练习是有讲究的。第一它的功能边界特别清楚能添加一条待办、能勾选已完成、能删除、能统计还剩几件没做。每个功能拆出来都是一个明确的小任务适合用来练手。第二它天然需要“数据”和“页面”两边配合正好可以逼自己去想清楚数据到底怎么流动。第三它的扩展空间大今天做基础版后续想加编辑、筛选、本地存储都有地方可去。当时给DAY9定的具体目标是第一理解为什么操作数据比直接操作DOM更靠谱第二把map、filter、reduce这三个数组方法在项目里各用一遍第三学会用事件委托来管理动态生成的按钮第四写一个至少能自己跑通全部交互的小页面。目标不多但每一个都对应着具体的代码实现。2. 今天真正要啃的三块硬骨头这一天名义上是在做项目其实是在补几个以前没吃透的关键点。表面上是写待办清单实际核心就三块数组三大方法怎么选、事件委托到底是怎么回事、什么叫让数据决定页面。2.1 数组三大方法的区别什么时候用map、filter、reduce这三兄弟经常一起出现很多教程喜欢放在一节课里讲。但真到了自己做东西容易犯迷糊我到底是该用map还是该用forEachfilter是筛选什么reduce为什么都说它厉害用大白话说map是“一对一变形”数组里有几个元素处理完还是几个元素只是每个元素换了个样子。比如我有三个对象想把它们的name字段取出来组成一个新数组这就是map的活。filter是“筛选后保留部分”它不会改变元素本身只决定哪些元素能留下。比如把done为true的挑出来就是filter。reduce则是“把整个数组合并成一个值”比如求和、求最大、统计总数都可以用它。在待办清单里的用途非常明确。渲染列表时要把每个todo对象变成一段HTML字符串这是典型的map场景。删除一条数据后要把剩下的重新组成一个数组这是filter。统计还有几件没完成需要在整个数组上做累加这是reduce。这三个方法各管一件事刚好把项目需要的数据操作全覆盖了。有一个细节值得单独说forEach和map看起来很像但forEach只负责“遍历一遍”不生成新数组。如果你写的是const html arr.forEach(...)得到的html是undefined因为forEach没有返回值。刚才说的“一对一变形”必须用map才能拿到新数组。// 错误写法forEach没有返回值 const html todos.forEach(function (todo) { return li${todo.text}/li; // 这段代码跑完 html 是 undefined }); // 正确写法map会返回一个新数组 const html todos .map(function (todo) { return li${todo.text}/li; }) .join();2.2 事件委托一个监听器处理所有按钮写页面的时候难免碰到这种情况列表里几十条数据每条都带一个删除按钮。新手最容易想到的办法是给每个按钮单独加onclick但这样有两个问题。第一每次重新渲染列表都要重新给新生成的按钮绑定事件代码写起来很累。第二按钮多了之后每个按钮都对应一个监听器内存占用和调试成本都会变大。事件委托的思路完全不一样不把监听器绑在按钮上而是绑在它们的共同父级上。因为事件默认会从触发元素一路冒泡到父元素所以只要在父级上监听一次就能捕获到所有子孙元素触发的事件。实现时需要用e.target来判断到底是哪个元素被点了。打个比方以前的做法是给小区每户派一个保安户数越多保安越多事件委托是只在小区门口设一个总登记处所有人都从门口进出登记处统一接待。在待办清单里我只需要给整个列表容器绑定一次点击事件然后在回调里判断点击的是不是“删除”按钮或者“勾选”框其他什么都不用管。这样将来动态添加新条目不需要额外绑定任何事件天然就能用。listContainer.addEventListener(click, function (e) { // e.target 是实际被点击的元素 const btn e.target.closest(button); if (!btn) return; if (btn.dataset.action delete) { // 在这里处理删除 } });closest方法也是个实用技巧它的作用是“从当前元素往上找找到最近的一个匹配选择器的祖先元素”。为什么不用e.target直接判断因为用户可能点的是按钮里的文字或者按钮里的某个小图标e.target会变成那个文字节点或图标节点直接判断容易失手。用closest(button)就能确保无论点中按钮内部哪一块都能认到按钮本身。2.3 从“操作DOM”到“操作数据”让页面自己跟着变这一个观念转变我觉得才是DAY9最大的收获。前八天写代码习惯是“我想让页面上某个地方变成什么样就直接去找那个DOM元素改它的内容”。在待办清单这种小项目里直接操作DOM看起来没什么问题。但逻辑一多就会乱添加任务要改输入框、要往列表里插一项、要更新计数删除任务要删掉列表项、要更新计数勾选完成要改文字的划线样式、还要更新计数。散落的DOM操作会让人疲于奔命改一处漏一处。换个思路就好很多页面不直接和用户操作对话而是先改数据再让数据重新渲染页面。用户点了删除按钮我要做的第一件事不是去删DOM元素而是从数组里过滤掉那一条数据然后调用一次渲染函数让整个列表按新的数据重新生成。此时DOM是数据的影子数据长什么样页面就长什么样。这样带来的直接好处是逻辑集中在数据层。添加、删除、切换完成状态本质都是在修改todos这个数组渲染函数永远只做一件事把数组变成页面。如果页面显示不对优先检查数据是不是对的排查范围一下子缩小了。当然全量重新渲染在小数据量下没有任何性能问题。几十条待办列表重新生成一遍不过毫秒级的事。等以后数据量大了再考虑用虚拟DOM、局部更新这些手段。现阶段最怕的不是性能不够而是逻辑把自己绕晕。3. 实操过程待办清单一步步搭出来前面的理论想清楚了写代码就是顺水推舟的事。以下是DAY9实际写出来的核心代码每一步都配了说明。项目非常小没有任何框架就是纯JavaScript加一个简单的页面结构。3.1 先把数据设计好动手写页面之前先想清楚数据长什么样。这是以前经常被我跳过的一步。那时候拿到需求就直接写HTML写完了发现要加功能又回头补数据结构特别被动。待办清单的核心数据就是一个数组数组里每个对象代表一条待办。一个对象需要有三个字段id用来唯一标识这条数据text存待办文字done存布尔值表示是否已完成。为什么要id因为删除或者切换状态时需要精确指出“我要处理的到底是哪一条”。如果只靠文本内容来认会出事两条完全相同的待办文字到底删哪一条所以id是必须的。初始化数据时手动给几条示例数据这样页面一打开就有内容可以渲染方便调试。let todos [ { id: 1, text: 复习数组方法, done: true }, { id: 2, text: 写待办清单页面, done: false }, { id: 3, text: 整理 DAY9 学习笔记, done: false }, ]; let nextId 4; // 新数据从这里生成 id用一个单独的nextId变量来维护自增是为了避免新添加的数据和已有数据id冲突。每次新增时取当前nextId然后把它加一。这个方法简单可靠在当前小项目里完全够用。3.2 渲染函数把数组变成页面设计好数据结构后最核心的就是渲染函数。这个函数负责把todos数组变成一段HTML字符串填进列表容器里。我给它起名render思路纯粹到不行不管数据变成了什么样函数执行一次页面就和数据保持一致。实现上我用map把每个todo对象转换成字符串模板然后用join()把数组拼接成一大段HTML文本。这种写法比一条一条createElement要短很多更重要的是它非常直观地体现了“数据到视图”的映射关系。每次看到map里的那行模板就能知道页面上一个条目长什么样。const listContainer document.querySelector(.todo-list); const countEl document.querySelector(.todo-count); function render() { const html todos .map(function (todo) { const doneClass todo.done ? done : ; return li classtodo-item ${doneClass}>const inputBox document.querySelector(.todo-input); const addBtn document.querySelector(.add-btn); addBtn.addEventListener(click, function () { const text inputBox.value.trim(); if (text ) return; todos.unshift({ id: nextId, text: text, done: false }); nextId; inputBox.value ; inputBox.focus(); render(); });切换完成状态和删除就都放在事件委托里处理。因为li是渲染时动态生成的如果给每个li单独绑事件每次render后都要重新绑麻烦还不一定绑得全。所以我只在列表容器上绑定一次点击事件。切换完成状态时用>listContainer.addEventListener(click, function (e) { const item e.target.closest(.todo-item); if (!item) return; const id Number(item.dataset.id); if (e.target.classList.contains(todo-input-check)) { // 勾选框变化可能走 change 更适合这里展示 click 场景 } if (e.target.classList.contains(toggle-checkbox)) { const todo todos.find(function (t) { return t.id id; }); if (todo) { todo.done !todo.done; render(); } } if (e.target.dataset.action delete) { todos todos.filter(function (t) { return t.id ! id; }); render(); } });Number(item.dataset.id)这个转换容易被忽略。dataset取出来的值都是字符串而我在数据里存的是数字。如果直接用字符串和数字比较会一直找不到对应数据。转成数字之后再比较才能保证逻辑正确。3.4 统计信息reduce的实战用法统计还剩几件事没做用reduce最顺手。它遍历数组按你指定的方式累加出一个结果。这里累加的规则是每遇到一条done为false的记录就在计数器上加一。function updateCount() { const leftCount todos.reduce(function (total, todo) { return total (todo.done ? 0 : 1); }, 0); countEl.textContent 剩余 leftCount 件待办; }初始值0很重要。reduce如果不传初始值数组为空时会直接报错而且第一个元素会充当初始值逻辑容易出问题。养成总是传初始值的习惯能省很多坑。其实这里也可以改用filter的写法todos.filter((item) !item.done).length。也能得到同样的结果两种都可以。我最后用reduce主要是因为想练习一下这个平时用得最少的方法实际项目里怎么顺手怎么来。4. 今晚踩过的坑和排查实录这一天的代码量不算大但踩的坑一个都不少。很多坑乍看是“代码不行”多查几步会发现全是基础不牢导致的。4.1 按回车就刷新页面输入框的内容全没了做添加功能时我一开始把输入框和按钮包在了一个form里。在浏览器中form里的输入框按回车会触发表单提交默认行为是刷新当前页面。由于没有后端处理请求页面一刷新所有JavaScript变量都重置了数据当然全没了。当时的第一反应是“为什么我的数组被清空了”排查了好一会儿才发现不是数组问题而是页面真的刷新了。解决办法有两种。第一种是在表单的submit事件里调用preventDefault()阻止默认提交。第二种更省事别用form直接用div装着输入框和按钮按钮的type也不要写成submit保持button。我最后选了第二种因为现在没有表单提交的需求没必要留着默认行为给自己添堵。4.2 列表被重复渲染点一次按钮多出来一大片改代码时遇到过一种更诡异的状况点击添加按钮页面上原本的3条待办突然变成了6条、9条每点一次就翻一倍。初看以为是render()执行了多次后来打日志一看数组的长度其实是对的问题出在HTML结构里旧内容没有被清掉。原因回溯到实现。有一版写法里渲染函数用的是listContainer.innerHTML html而不是listContainer.innerHTML html。意思是在原有内容后面追加而旧列表还挂在DOM上新渲染的内容又追加在后面于是越积越多。看起来像是重复渲染实际上是“没有先清空再填充”。解决也简单改回直接赋值让innerHTML的内容整体替换掉旧内容。这条教训后来一直记着做全量刷新的时候先想清楚是“覆盖”还是“追加”别让旧数据一直在页面上残留。4.3 事件委托里识别错按钮点删除没反应第一次写事件委托时我在回调里用e.target.tagName BUTTON来判断是否点到了删除按钮。单看好像没问题一个li里只有一个按钮但测试时发现怎么点都不触发。打日志才发现用户真正点到的位置经常是按钮里的文字。按钮的文本内容在DOM里是文字节点e.target可能指向那个文本节点它的tagName是undefined自然不等于BUTTON判断直接失败。解决的办法就是之前提过的closest(button)不管点的是文字还是子元素都往上找到按钮本身。同理要判断是哪个按钮也不要用按钮文字判断最好使用>