原生JavaScript实战:从零实现待办清单与无缝轮播图

发布时间:2026/9/15 16:53:26
原生JavaScript实战:从零实现待办清单与无缝轮播图 你光看项目名可能觉得老套——待办清单和轮播图哪个教程里没有但真正把这俩功能用纯JavaScript从零手写一遍你才会发现平时用框架用得太顺手很多底层逻辑其实是模糊的。这个综合实战项目没有用React也没有用Vue就是原生JS配合一个前端代码编辑器把DOM操作、事件机制、数组方法、状态管理和本地存储全部串起来练了一遍。我做完之后最大的感受是与其刷十遍教程不如完整写一次这种带交互状态的小项目。这篇文章我会把待办清单和无缝轮播图这两个模块的设计思路、实现细节、踩过的坑全部展开讲清楚。内容面向已经学完JavaScript基础语法、ES6常见特性想通过项目巩固实战能力的初学者也适合准备前端面试、需要补项目经验的同学参考。1. 项目整体设计与思路拆解1.1 为什么选这两个功能作为实战载体很多初学者练手喜欢做商城页面、后台管理界面结果写了大量重复的渲染代码和样式调整核心的JavaScript逻辑反而没练到。我选待办清单加无缝轮播图是因为这两个功能刚好覆盖了前端开发中最典型的两类场景一类是数据驱动的交互界面一类是定时器依赖的视图切换动画。待办清单的核心是状态管理。你不用引入任何状态管理库纯靠JavaScript的数组、对象和localStorage就能实现数据的增删改查再通过函数把数据渲染到页面。这里面会用到数组方法、事件委托、表单处理、数据持久化几乎把JavaScript基础语法中最重要的部分全部覆盖了。无缝轮播图的核心是动画与时间控制。你操作的元素其实很少只有一张图片列表但你要处理index增长、图片复制、过渡动画开关、定时器生命周期这些细节。一个看起来简单的轮播图实际写下来你会发现很多边界情况比如连续点击、窗口切换导致的定时器错乱每一个都是实战中真实会遇到的问题。1.2 技术选型原生JavaScript加现代ES6语法这个项目我没有选TypeScript也没有用任何第三方库连jQuery都不用。原因很简单这两个功能本身就是DOM操作的最佳练习环境如果引了库底层逻辑被封装掉了学习的意义就少了一半。但我不反对在项目中用ES6语法class、箭头函数、模板字符串、解构赋值这些该用就用。开发环境方面我一开始就在代码编辑器里新建项目写HTML、CSS、JavaScript三个文件然后直接打开浏览器运行。建议你不要在沙盒网站里写完就跑尽量在本地维护一个真实的项目目录这对后面理解文件的引用关系、路径问题、部署发布都有帮助。项目整体的目录结构很简单但非常接近真实项目的雏形todo-carousel-project/ ├── index.html // 页面入口包含两个模块的HTML骨架 ├── css/ │ ├── style.css // 全局样式和待办清单样式 │ └── carousel.css // 轮播图样式 ├── js/ │ ├── todo.js // 待办清单逻辑 │ ├── carousel.js // 轮播图逻辑 │ └── utils.js // 公共工具函数把一个页面拆成多个CSS和JS文件目的不是为了炫技而是从第一天就培养模块化开发的习惯。后面你项目越来越大会感谢自己这个决定。2. 待办清单从数据设计到渲染逻辑的完整实现2.1 数据模型设计先想清楚再写代码我见过很多初学者写待办清单上来就先写一个输入框然后把值推入数组再用循环拼HTML字符串。这样当然能跑但代码写得很乱后续加功能的时候无从下手。我的做法是先定义数据模型。每一条待办事项其实是一个对象包含三个核心字段// 一条待办事项的结构 const todoItem { id: Date.now(), // 唯一标识用时间戳作为id简单可靠 text: 学习JavaScript数组方法, completed: false, // 是否已完成 createdAt: 2024-11-20 14:30:00 // 创建时间方便以后排序或统计 };使用时间戳作为id是一个常见的技巧因为它天然唯一而且不需要额外引入自增计数器。当然如果你需要更严格的唯一性可以用crypto.randomUUID()不过在本地小项目中时间戳完全够用。数据存储方面我把所有的待办事项放在一个数组里然后统一通过一个render()函数渲染到页面。所有操作不管是添加、删除还是切换完成状态最终都是修改这个数组然后再调用渲染函数。这就是最简单版本的“单向数据流”数据变了视图就跟着变。2.2 添加待办表单提交与事件监听的正确处理添加待办是最基础的功能但里面有一个非常经典的坑用click事件还是用submit事件。很多人会直接在按钮上绑定click事件然后在处理函数里读取输入框的值。这样也能实现效果但问题在于用户如果在输入框里按了回车页面会刷新——因为表单的默认提交行为没有被阻止。正确的方式是在form元素上监听submit事件然后调用e.preventDefault()阻止默认行为。form idtodoForm classtodo-form input typetext idtodoInput placeholder请输入待办事项... autocompleteoff button typesubmit添加/button /formconst todoForm document.getElementById(todoForm); const todoInput document.getElementById(todoInput); todoForm.addEventListener(submit, function(e) { e.preventDefault(); // 阻止表单默认提交刷新页面 const text todoInput.value.trim(); if (!text) { alert(内容不能为空); return; } todos.push({ id: Date.now(), text: text, completed: false }); todoInput.value ; todoInput.focus(); // 添加完成后让焦点回到输入框提升体验 render(); saveToLocalStorage(); });这里特别要注意trim()如果不处理首尾空格用户多按一下空格就会出现一条看似为空的数据。还有空值判断最好用if (!text)而不是if (text.length 0)因为前者把null、undefined、空字符串的情况全部包括了代码更健壮。2.3 渲染列表事件委托解决动态元素的监听难题渲染列表的逻辑本身不复杂就是把数组遍历拼接成HTML然后塞进容器里。但有一个关键问题列表里的删除按钮和勾选框是动态生成的如果直接给每个按钮绑定事件不仅繁琐而且每次重绘后再新增的数据绑不上事件。解决方案是事件委托。事件冒泡机制保证了你给父容器绑定的事件可以捕获到其内部所有子元素触发的事件只要通过e.target判断用户点击的是哪个元素即可。todoList.addEventListener(click, function(e) { const target e.target; const item target.closest(.todo-item); if (!item) return; const id Number(item.dataset.id); if (target.classList.contains(delete-btn)) { deleteTodo(id); } else if (target.classList.contains(toggle-checkbox)) { toggleTodo(id); } });我还用到了closest方法。为什么要用closest而不是直接判断target因为你的删除按钮里面可能还包着一个图标元素用户点击的不一定是按钮本身而是里面的图标。closest会向上查找最近的匹配元素这样不管用户点到按钮还是图标都能正确找到对应的事项目。数据绑定方面我通过dataset属性把任务的id存到每个列表项的>function deleteTodo(id) { // filter 返回一个新数组原数组不会被修改 todos todos.filter(todo todo.id ! id); render(); saveToLocalStorage(); } function toggleTodo(id) { // map 遍历并返回新数组只更新匹配项的 completed 字段 todos todos.map(todo { if (todo.id id) { return { ...todo, completed: !todo.completed }; } return todo; }); render(); saveToLocalStorage(); }这里的{ ...todo, completed: !todo.completed }是ES6的对象展开写法好处是显式表达了“我复制了这个对象只改了completed这一个字段”代码意图非常清晰。2.4 本地存储持久化localStorage的正确打开方式如果不做持久化每次刷新页面数据就清空了这个待办清单没什么实用价值。localStorage是浏览器提供的一个简单的键值对存储接口非常适合这种轻量级数据。存入的时候要用JSON.stringify把数组转成字符串读取的时候用JSON.parse把字符串转回数组。这两个步骤是本地存储最常见的坑很多人忘记转换结果存进去是[object Object]读出来是个字符串渲染的时候直接报错。// 保存数据到本地存储 function saveToLocalStorage() { localStorage.setItem(todos, JSON.stringify(todos)); } // 页面加载时从本地存储读取数据 function loadFromLocalStorage() { const stored localStorage.getItem(todos); if (stored) { try { todos JSON.parse(stored); } catch (e) { console.error(本地存储数据解析失败, e); todos []; } } }我在读取时用try...catch包了一层因为本地存储的数据是不可信的如果之前存的数据格式被破坏或者被用户手动改过JSON.parse会直接抛异常导致整个页面的JavaScript全部失效。加上异常处理至少能保证页面还能正常运行只是数据为空而已。还有一个细节使用localStorage时要养成检查浏览器兼容和数据大小限制的习惯。localStorage一般限制在5MB左右对待办清单这种数据量来说完全够用但如果你后面想存图片或大文本就要考虑IndexedDB了。2.5 分类筛选与状态统计让你的清单更好用基础的增删改查完成之后我还加了三个筛选视图全部、进行中、已完成。这个功能的实现思路很简单就是用一个filter变量记录当前视图渲染的时候先按视图过滤数组再拼接HTML。let currentFilter all; // 可选值: all / active / completed function render() { let filteredTodos todos; if (currentFilter active) { filteredTodos todos.filter(todo !todo.completed); } else if (currentFilter completed) { filteredTodos todos.filter(todo todo.completed); } // 渲染过滤后的数据 const html filteredTodos.map(todo { const doneClass todo.completed ? completed : ; return li classtodo-item ${doneClass}>filterButtons.forEach(btn { btn.addEventListener(click, function() { currentFilter this.dataset.filter; // 切换按钮的高亮样式 filterButtons.forEach(b b.classList.remove(active)); this.classList.add(active); render(); }); });统计信息也很简单一个是待办总数一个是未完成数量。这实际上就是一次普通的数组遍历你可以用reduce来做也可以用filter加length。两种写法都可以但reduce只需要一次遍历性能上更好那么一点点代码也更函数式。注意上面渲染的HTML中我在每个待办文本处用了escapeHtml函数。这个细节很多人忽略但它是个实实在在的安全性问题。如果用户输入的是script标签直接拼接进HTML时浏览器会执行它这就是最基本的XSS攻击。虽然本地项目谈不上什么攻击场景但好习惯应该从第一天养成。3. 无缝轮播图从布局到无缝滚动的完整原理3.1 轮播图布局为什么用flex加transform而不是直接切换图片无缝轮播图的HTML结构其实很简单位。你需要一个视口容器作为可视窗口一个列表容器承载所有的图片列表容器里再放一个个单独的图片项。视口容器的宽度决定了用户能看到的区域列表容器则通过水平位移展示不同的图片。div classcarousel-container div classcarousel-track div classcarousel-slideimg srcimages/1.jpg alt图片1/div div classcarousel-slideimg srcimages/2.jpg alt图片2/div div classcarousel-slideimg srcimages/3.jpg alt图片3/div div classcarousel-slideimg srcimages/4.jpg alt图片4/div div classcarousel-slideimg srcimages/5.jpg alt图片5/div !-- 注意第一张图片的复制品由 JavaScript 动态生成 -- /div /div关键点在于如何移动图片。我的实现方式是控制列表容器的transform: translateX(-index * offset)而不是直接修改图片的display或src属性。原因很简单transform的位移由GPU加速处理动画流畅度高而且我们只需要操作容器元素一个节点的样式变化性能消耗非常小。这里有一个方便调试的经验轮播图中所有图片最好保持相同尺寸用CSS约束容器和图片的宽高。如果图片大小不一容器尺寸变化会导致offset计算不准确动画就会跳动。我在样式里统一对每个slide使用width: 100%让每个幻灯片占满容器宽度这样offset就等于容器的宽度。.carousel-container { width: 800px; height: 400px; overflow: hidden; /* 关键裁掉视口外的内容 */ position: relative; margin: 0 auto; } .carousel-track { display: flex; height: 100%; transition: transform 0.4s ease-in-out; /* 过渡动画让位移平滑 */ }3.2 无缝滚动的核心逻辑复制首图加瞬间归位初学者最容易卡住的地方就是“无缝”二字。如果只是让index从0增加到4再从4回到0你会发现从最后一张跳到第一张时图片会快速滑回来非常突兀视觉上明显断开了。无缝处理的核心技巧就一句话把第一张图复制一份放在列表的最后。当index指向最后一张复制图时用户看到的内容和第一张图一模一样这时你悄悄把transition动画关掉瞬间把列表容器挪回到第一张图的位置然后再重新打开动画。整个过程发生在用户视觉上的同一张画面中用户完全不会察觉到变化。我来详细拆解这个过程的代码const track document.querySelector(.carousel-track); const slides Array.from(document.querySelectorAll(.carousel-slide)); const slideCount slides.length; // 原始图片数量 let currentIndex 0; let timer null; // 第一步复制第一张图片并追加到列表末尾 const firstClone slides[0].cloneNode(true); track.appendChild(firstClone); // 此时 track 中的 slide 数量是 slideCount 1 const totalSlides track.children.length; const slideWidth track.clientWidth / slideCount; // 每个slide的宽度 // 更新位置 function updateTrack(animate true) { track.style.transition animate ? transform 0.4s ease-in-out : none; track.style.transform translateX(${-currentIndex * slideWidth}px); }当点击下一张按钮时首先让currentIndex然后正常更新位置。但这里需要判断一个关键情况如果currentIndex增加到了slideCount说明现在指向的是最后那张复制品。function nextSlide() { currentIndex; updateTrack(true); // 关键判断如果已经到达复制品的位置 if (currentIndex slideCount) { // 等待动画结束后瞬间跳回真正的第一张图 setTimeout(() { currentIndex 0; updateTrack(false); // 关闭动画瞬间位移 }, 400); // 400ms 是过渡动画的时长要和CSS的一致 } }这里的时间设置非常讲究setTimeout的延时必须和CSS中transition: transform 0.4s的时长保持一致。如果延时长于动画用户会看到短暂的停顿如果短于动画还没等动画结束就跳走了同样会穿帮。上一张的按钮逻辑就更有意思了。当currentIndex是0时用户点击上一张应该跳到真实的最后一张。但因为我们在列表末尾放了一个复制品所以另一种做法是让currentIndex直接跳到slideCount - 1也就是列表中的倒数第二张这个位置对应的正好是真实的最后一张图。function prevSlide() { if (currentIndex 0) { // 先瞬间跳到复制品位置视觉效果等同第一张 currentIndex slideCount; updateTrack(false); // 关闭动画直接定位 // 然后下一帧再开始动画从复制品滑到倒数第二张 requestAnimationFrame(() { currentIndex slideCount - 1; updateTrack(true); }); } else { currentIndex--; updateTrack(true); } }这个方法的巧妙之处在于当用户在第一张点上一张时视觉上先看到第一张画面然后动画向左滑动到真正的最后一张整个体验非常自然。requestAnimationFrame的作用是确保第一次瞬间位移已经在渲染管线中执行完毕再执行后续动画。3.3 自动播放与定时器管理不只是setInterval那么简单自动播放是整个模块坑最多的地方。很多人写了一个setInterval就不管了结果会出现多个定时器叠加执行的情况轮播速度越来越快最后直接错乱。问题出在哪里如果用户快速点击了按钮然后又触发了自动播放的逻辑而你没有先清理之前的定时器就会同时有两个interval在跑每次nextSlide()被调用两次画面直接跳两格。而且这种问题很难复现有时候要连续操作很久才会出现排查起来很痛苦。我的解决方案非常明确每次开启定时器之前先clearInterval(timer)再重新设置。function startAutoPlay() { clearInterval(timer); // 先清理再开启防止多个定时器叠加 timer setInterval(nextSlide, 3000); } function stopAutoPlay() { clearInterval(timer); timer null; } // 鼠标悬停暂停鼠标移开继续 container.addEventListener(mouseenter, stopAutoPlay); container.addEventListener(mouseleave, startAutoPlay);还有一个隐藏很深的bug我排查了很久才找到当浏览器窗口切换到后台标签页时为了保证性能浏览器会暂停定时器。等用户切回页面时定时器继续执行但底层的requestAnimationFrame已经和页面的渲染周期错位了。在某些极端情况下图片会闪烁一下。最稳妥的处理方法是监听visibilitychange事件在页面隐藏时停止动画页面重新可见时再启动。document.addEventListener(visibilitychange, function() { if (document.hidden) { stopAutoPlay(); } else { startAutoPlay(); } });3.4 指示器与小圆点索引和当前状态的同步指示器小圆点的本质是一个同步问题小圆点的高亮状态必须跟随currentIndex变化而且当currentIndex处于复制品位置时高亮的应该是第一个小圆点。function updateDots() { // 当 currentIndex 等于 slideCount 时实际显示的是第一张图 const activeIndex currentIndex % slideCount; dots.forEach((dot, index) { dot.classList.toggle(active, index activeIndex); }); }使用取模运算是一个很优雅的解法一行代码就把复制品位置的索引映射回真实图片的索引。指示器的点击跳转同样有一个细节需要注意。用户点击了小圆点你不能直接把currentIndex设置成目标索引就完了因为如果当前正在最后一张复制品的位置直接跳到别的索引中间过渡的动画会从错误的位置开始。我这里采用的方式是先关闭动画瞬间定位到当前真正的图片位置然后再打开动画执行跳转。这个思路和前面处理prevSlide时是一样的核心就是“定位”和“动画”要分离控制。3.5 图片资源与性能图片预加载和尺寸自查轮播图涉及大量图片资源这里有两个常见的性能问题要提前预防。第一个是图片加载顺序问题。如果图片没有设置固定尺寸在加载完成之前高度是0加载完成后突然撑开会导致轮播图容器尺寸发生变化整个布局跳一下。解决办法是在CSS中给图片设置固定宽高或者把容器尺寸固定好图片使用object-fit: cover来裁剪。.carousel-slide img { width: 100%; height: 400px; object-fit: cover; }第二个是图片预加载问题。如果你有5张图片页面加载时浏览器只会加载HTML中存在的图片资源。当轮播图切换时浏览器才会去请求对应的图片这样第一次滑动到某张图片时会出现短暂的白屏闪烁。解决方案是提前用JavaScript创建Image对象触发浏览器缓存图片资源。function preloadImages(imageUrls) { imageUrls.forEach(url { const img new Image(); img.src url; }); } // 页面加载完成后调用 window.addEventListener(load, () { const imageUrls slides.map(slide slide.querySelector(img).src); preloadImages(imageUrls); });这里用window.addEventListener(load, ...)而不是DOMContentLoaded是因为load事件会等到所有资源加载完成才触发包括图片。而DOMContentLoaded只等HTML解析完成就触发了那时候图片资源还没有全部下载完预加载的效果就会打折。4. 开发环境配置与调试技巧4.1 编辑器新建项目与运行环境准备这个项目不用构建工具也不需要安装node_modules所以环境准备非常轻量。你在代码编辑器里新建一个项目文件夹把HTML文件、CSS文件夹、JS文件夹建好即可然后用浏览器直接打开HTML文件就能运行。很多初学JavaScript的朋友会纠结用哪个编辑器。其实这个阶段的编辑器选择不重要我更推荐你在写代码的时候开启自动保存并养成先在浏览器里刷新看效果的习惯。这里有一个小技巧打开浏览器的开发者工具切到Console面板让控制台常驻在页面下方这样代码里有什么报错能第一时间看到不用每次手动打开。前端开发中最高频的错误无非是类型错误和语法错误。如果你的控制台报的是红色错误用ChatGPT搜索报错信息往往很难解决因为报错堆栈指向的位置是针对压缩或转译后代码的并不对应你源码里的行列。一个更有用的排查方法是逐步注释代码把JS代码一分为二先注释掉一部分刷新页面看是否还报错用二分法缩小问题范围。这个方法看起来很笨实际效率很高。4.2 浏览器开发者工具到底怎么用现在的浏览器开发者工具功能非常强大但对初学者来说只需要重点掌握四个面板就够了。元素面板用来查看和实时修改HTML和CSS。你在元素面板里可以直接改某个节点的样式页面上会立即生效。我排查轮播图位置偏移问题时就经常在这个面板里手动修改transform的值看看不同偏移量下的视觉效果然后再回到源码里去改逻辑。控制台面板是所有日志和错误信息的集中地。在JavaScript代码里加上console.log在控制台里就能看到变量的实时值和代码执行顺序。我建议你在排查问题时多打印关键变量的值比如轮播图的currentIndex、待办清单的todos.length这些数据能帮你快速判断问题出在数据层还是视图层。网络面板能看到页面的每一个资源请求情况包括图片、脚本、样式。如果一个请求是红色说明资源加载失败你就要检查路径是否正确。我之前遇到过轮播图图片不显示的问题打开网络面板才发现是图片路径写错了服务器返回了404。源代码面板允许你在JavaScript代码里设置断点。断点调试比console.log更强大代码执行到断点处会暂停你可以逐行查看变量的变化过程。当轮播图的index计算逻辑我怎么都看不出问题的时候就是通过断点单步执行才定位到错误的原因。4.3 数据绑定与运行时错误排查思路这里我重点说一下运行时错误。JS是一门解释型语言语法错误在解析阶段就会被发现但运行时错误要等代码执行到某一行才会抛出。最常见的运行时错误是TypeError: Cannot read properties of null意思是你在一个不存在的DOM元素上尝试读取属性。这个错误通常有三个原因一是执行JavaScript的时机太早DOM还没有解析完成你就在document.getElementById(app)上取元素取到的是null二是元素的id写错了和代码里不一致三是整个HTML结构里根本没有那个元素。解决办法是把你操作DOM元素的代码放在DOMContentLoaded事件回调里或者直接把script标签放在/body之前。现在的浏览器支持在script标签上使用defer属性它会让脚本在DOM解析完之后再执行这也是我很推荐的方式。script srcjs/utils.js defer/script script srcjs/todo.js defer/script script srcjs/carousel.js defer/script还有一个调试经验当你发现页面空白敢肯定自己的代码逻辑没问题时先用控制台输入document.body.innerHTML看页面body里面到底有没有内容。如果返回空字符串说明HTML根本没有渲染成功如果有内容但看不到样式那就是CSS的问题如果内容有了但交互没反应再去查JS逻辑。5. 常见问题与排查技巧实录5.1 经典报错对照表我把这两个模块里最容易踩的坑整理成了一张表格方便你对照排查。几乎每一个都是我实际遇到过并花时间解决过的希望能帮你避开这些坑。问题现象核心原因解决方案浏览器页面刷新后待办清单数据消失没有调用localStorage保存逻辑或保存时忘记JSON.stringify每次增删改后调用saveToLocalStorage读取时用JSON.parse输入框回车后页面自动刷新form表单默认提交行为未被阻止e.preventDefault()阻止默认事件点击动态新增的待办删除按钮没反应直接给动态元素绑定了事件而不是事件委托改为在父容器上监听click用e.target.closest()判断连续点击轮播下一张图片有时候跳两格每次创建了独立的计时器多个按钮事件叠加每次开启定时器前先clearInterval轮播图从最后一张回第一张时闪一下没有复制首图索引直接跳转导致视觉断层复制第一张图放到末尾用“瞬间关闭动画归位”技巧轮播图图片首次切换时白屏图片资源未预加载切换时才发HTTP请求用new Image()在页面加载后预加载所有图片小圆点高亮和当前图片不一致取模运算使用错误或者复制品位置的index未映射回真实索引计算currentIndex % slideCount浏览器的控制台报null读取属性的错误DOM元素未加载或id拼写错误使用defer加载脚本或把script放到body末尾5.2 轮播图跳变与动画穿帮的深入排查轮播图的无缝切换看似是一行代码的问题实际上涉及很多前端的底层知识。如果你发现动画有跳变我建议按照下面的顺序排查。先检查CSS过渡时长和JavaScript定时器延时是否一致。我在代码里CSS设置的是transition: transform 0.4s ease-in-out所以setTimeout的延时也必须写400ms。如果你的CSS写了0.6s但JS里还是400ms动画还没走完就被强制跳转了画面就会闪。再检查requestAnimationFrame的使用是否正确。在prevSlide中我先关闭动画瞬间定位到复制品再在下一帧执行动画。如果你合并成了两行连续代码浏览器可能会把两次样式修改合并成一次绘制导致瞬间位移失效。还有一点容易被忽略就是连续快速点击按钮导致的动画堆积。比如用户快速点了三次下一张而每次动画的延时都是400ms前一个动画还没结束后一个动画就把translateX改成了新的值视觉上表现为元素跳变或闪烁。解决办法是加一个“动画进行中”的锁let isAnimating false; function nextSlide() { if (isAnimating) return; // 动画未完成时忽略新的触发 isAnimating true; currentIndex; updateTrack(true); setTimeout(() { if (currentIndex slideCount) { currentIndex 0; updateTrack(false); } isAnimating false; }, 400); }这个锁的实现很简单但很实用。我在最终版本里保留了这个逻辑哪怕用户鼠标连点轮播图也只会一个一个慢慢切换。5.3 待办清单数据异常与XSS基础防范待办清单里最容易出现的数据异常是重复id。如果你用Date.now()生成id在极短时间内连续添加两条理论上可能生成相同的id导致删除时两条都被删掉。虽然不是高概率事件但严谨起见我会用一个计数器结合时间戳let idCounter 0; function generateId() { idCounter; return Date.now() * 1000 idCounter; }这样即使同一毫秒内添加多条数据id也是唯一的。XSS问题我之前提过这里再展开一下。当用户输入的内容包含HTML标签时如果你直接通过innerHTML拼接渲染浏览器会把它解析成真实的DOM元素。如果用户输入的是img srcx onerroralert(1)就会执行页面里的JavaScript代码。对本地项目来说问题不大但你以后写在线应用时这个问题会被攻击者利用。一个简单有效的转义函数是这样的function escapeHtml(unsafe) { return unsafe .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #039;); }在渲染待办文本时用escapeHtml(todo.text)替换原来的${todo.text}。这样浏览器会把显示成文本而不是解析成标签。5.4 代码组织与可维护性的小建议最后聊一下代码组织。这个项目虽然小但如果你把所有逻辑都塞进一个JS文件到后面找一个问题要滚动很久。我当时拆成了三个文件utils.js放公共函数、todo.js放待办清单逻辑、carousel.js放轮播图逻辑。每个文件各司其职定位问题的时候一目了然。在函数设计上我也建议你遵循“单一职责”原则。比如render函数只负责渲染saveToLocalStorage只负责存储nextSlide只负责改变索引和更新位置。不要写一个三百行的“万能函数”后面你会后悔的。另外给函数取名字时动词加名词的格式最清晰比如deleteTodo、updateDots、startAutoPlay读到函数名就知道它做什么。我做完这个项目之后最大的体会就是JavaScript基础扎实与否不是看你背了多少API而是看你在真实场景中能不能灵活组合这些API。待办清单和轮播图虽然简单但它们把事件、数据、渲染、定时器、存储这些核心概念全部串起来了。如果你能把这两个模块吃透后面再去学框架理解起来会快很多因为你会发现框架里那些复杂的概念底层逻辑其实就是你手写过的这些东西。