待办清单加无缝轮播图,这两个功能单独看都不算新东西,但把它们放到同一个原生JavaScript项目里完整做一遍,效果完全不一样。前段时间我正好整理自己的效率工具页,顺手把这两块功能合并成了一个小项目:页面顶部是一张自动播放、支持手动切换的无缝轮播图,下方是一个带增删改查、本地存储和状态筛选的待办清单。整个项目只用原生HTML、CSS和JavaScript,没有引入任何框架,跑下来之后最大的感受是:基础语法的那些散点——事件绑定、DOM操作、数组方法、定时器、闭包、本地存储——一下就串起来了。这篇文章就把整个实现过程拆开讲清楚,从设计思路到核心代码再到常见问题,适合刚学完JavaScript基础想找实战练手的人,也适合准备拿项目当面试作品的朋友参考。
1. 项目整体设计与思路拆解
1.1 为什么把这两个功能放在同一个项目里
先说说选型的逻辑。很多人写实战项目喜欢堆功能,比如做个购物车还要加聊天室,最后页面一打开全是代码,但每个功能都没吃透。我选“待办清单+无缝轮播图”这个组合,是因为它们在技术点上正好互补,覆盖了前端最常见的两大块能力。
待办清单的核心是数据驱动视图:用户输入数据、更新数据、删除数据,界面必须跟着数据实时变化。这个过程中你会用到数组的增删改查、对象的属性操作、模板字符串渲染、事件监听、表单提交处理,再加上localStorage做持久化。做完这一块,你对“数据状态”这个概念会有一个非常直观的体感,再去看Vue或React的响应式原理,理解成本会低很多。
无缝轮播图的核心则是UI动效和边界处理:你要理解元素的位移计算、CSS过渡动画、定时器的启动与清除,还得处理首尾切换时“无缝”衔接的特殊情况。这个功能看起来简单,真正做的时候坑特别多,比如最后一张切回第一张时容易出现闪白、连续点击按钮导致动画错乱、窗口大小变化后位移定位失效等。能把轮播图写得既顺滑又稳定,说明你对布局和动画时机已经有一定把控能力了。
所以这两个功能组合在一起,一个偏“逻辑交互”、一个偏“视觉动效”,练完一遍等于把前端最核心的两条线都过了一遍。
1.2 技术选型与整体页面结构
在技术选型上我做了两个决定:第一,不使用任何框架和第三方库,纯原生JavaScript实现;第二,页面结构采用“轮播图在上、待办清单在下”的单页布局,开发环境用浏览器自带的开发者工具即可,平时写代码我用VS Code,偶尔会用HBuilderX跑一下真机模拟,因为HBuilderX对HTML、CSS、JavaScript的配置基本是零成本,新建项目直接就能预览。
页面结构大致是这样:
- 顶部的轮播图区域,宽度100%,高度固定为360px,底下配左右箭头和指示小圆点。
- 中部的待办清单区域,上方是输入框和“添加”按钮,中间是待办列表,底部是筛选按钮组和未完成数量统计。
这里有一个设计细节值得提一下:我刻意把这两个模块放在同一个页面,而不是做成两个独立页面,目的是让初学者感受到“模块化开发”的初步思路——代码按功能拆成独立的区块,互不干扰,页面组合起来又像一个整体。生产环境里确实很少把轮播图和待办清单放在一起用,但从学习角度来说,这种组合反而能逼你把每个模块想清楚:轮播图的定时器不能影响待办清单的输入,待办清单的键盘事件也不能误触轮播图的切换逻辑。
2. 待办清单模块:从数据设计到交互闭环
2.1 核心数据结构与本地存储方案
待办清单出错,八成是数据结构没想清楚就动手写了。我的建议是先把数据模型定好,再写界面代码。
每一条待办事项,我用一个对象来表示:
{ id: Date.now() + Math.random().toString(16).slice(2), text: '写一篇实战项目总结', completed: false, createdAt: '2025-01-12 10:30:00' }id字段用来唯一标识每一条待办,这里用了时间戳加随机数的方式,比简单的自增数字更稳,因为自增id在删除后再次添加时容易产生重复。text是待办内容,completed表示是否已完成,createdAt记录创建时间,方便以后按时间排序。
所有待办事项统一放在一个数组里:
let todos = [];为什么用数组而不是对象?因为数组天生保持了顺序,而且filter、map、find这些方法配合起来非常顺。比如将来要按“已完成/未完成”筛选,一行代码就能搞定。
数据持久化用localStorage。它的API很简单,只有两个核心方法:存和取。
function saveTodos() { localStorage.setItem('my_todos', JSON.stringify(todos)); } function loadTodos() { const raw = localStorage.getItem('my_todos'); return raw ? JSON.parse(raw) : []; }这里有个很多人都会踩的坑:localStorage只能存字符串,所以存的时候必须用JSON.stringify转成字符串,读取的时候再用JSON.parse转回数组。如果你直接存对象,拿到的会是一个“[object Object]”之类的奇怪字符串。
另外,key命名我用了“my_todos”而不是简单的“todos”,原因是localStorage在当前域名下是全局共享的,如果同域名下跑了多个项目,很容易互相覆盖。加个前缀是成本最低的防冲突方案,换成“app_todos”这类业务前缀也完全没问题。
2.2 渲染函数、添加与删除的完整实现
数据层搞定之后,接下来是渲染。我的做法是定义一个render函数,每次数据变化后手动调用它刷新列表。这种模式看起来笨,但对初学者来说最好理解,等以后接触Vue这类框架时你会发现,它们本质上也是在做这件事,只是帮你自动完成了。
渲染函数的核心代码如下:
const todoList = document.getElementById('todo-list'); function render() { // 先清空列表,再根据当前筛选条件重新渲染 todoList.innerHTML = ''; const filtered = getFilteredTodos(); if (filtered.length === 0) { todoList.innerHTML = '<li class="empty-tip">当前没有待办事项</li>'; return; } filtered.forEach(todo => { const li = document.createElement('li'); li.className = 'todo-item' + (todo.completed ? ' completed' : ''); li.dataset.id = todo.id; li.innerHTML = ` <input type="checkbox" ${todo.completed ? 'checked' : ''}> <span class="todo-text">${escapeHtml(todo.text)}</span> <button class="delete-btn">删除</button> `; todoList.appendChild(li); }); updateCount(); }这里有两个细节值得展开讲。
第一,为什么用innerHTML而不是createElement加appendChild一个个创建?因为清单是轻量级渲染,用模板字符串写起来更清爽,代码可读性也更高。但直接用innerHTML塞入用户输入内容会有XSS风险,所以我在拼接文本时用了一个escapeHtml函数做转义处理:
function escapeHtml(str) { const div = document.createElement('div'); div.appendChild(document.createTextNode(str)); return div.innerHTML; }这个函数能把<script>之类的字符转成纯文本,防止被浏览器解析成HTML标签。
第二,我在每个li上设置了data-id属性,通过它来定位具体操作的是哪一条待办。这样做的好处是,删除或切换状态时不需要遍历整个数组去匹配内容,直接根据id查找即可。
删除操作和切换完成状态,我统一用事件委托处理,而不是给每个按钮单独绑定事件。事件委托的原理是:利用事件冒泡,把监听器绑定到父元素上,通过判断事件源来执行不同的分支。
todoList.addEventListener('click', (e) => { const target = e.target; const li = target.closest('.todo-item'); if (!li) return; const id = Number(li.dataset.id); if (target.classList.contains('delete-btn')) { deleteTodo(id); } else if (target.classList.contains('todo-text') || target.tagName === 'SPAN') { toggleTodo(id); } });我特意提一下这里用到的closest方法,它从当前元素开始向上查找匹配选择器的祖先元素,比一层层判断parentNode要简洁得多。
对应的删除和切换函数:
function deleteTodo(id) { todos = todos.filter(todo => todo.id !== id); saveTodos(); render(); } function toggleTodo(id) { todos = todos.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo ); saveTodos(); render(); }这里用了ES6的解构和展开运算符{ ...todo }来生成新对象,而不是直接修改原对象的completed属性。这么写的好处是保持数据不可变,每次更新都产生新的对象,避免后续出现“数据改了但页面没刷新”之类的诡异问题。
2.3 添加待办与表单提交的细节处理
添加待办的逻辑放在表单的submit事件里处理。我建议用form包住输入框和按钮,而不是简单给按钮绑一个click事件。原因很简单:form天然支持用户按回车键提交,这个交互习惯是所有用户都熟悉的。
const todoForm = document.getElementById('todo-form'); const todoInput = document.getElementById('todo-input'); todoForm.addEventListener('submit', (e) => { e.preventDefault(); const text = todoInput.value.trim(); if (!text) { todoInput.focus(); return; } todos.unshift({ id: Date.now() + Math.random().toString(16).slice(2), text: text, completed: false, createdAt: new Date().toISOString() }); saveTodos(); render(); todoInput.value = ''; todoInput.focus(); });把新待办用unshift插入到数组头部,这样最新添加的事项显示在最上面,符合大多数待办APP的习惯。
这里有几个细节:
- e.preventDefault()一定要调用,否则表单会把页面刷新掉。
- trim()去掉首尾空格,避免用户只输入空格也能添加无效待办。
- 添加完成后清空输入框并重新聚焦,方便连续录入。
有一个比较隐蔽的问题:如果用户使用中文输入法,在输入拼音时按回车会触发表单提交。比如用户想输入“写代码”,输入法还没选词直接按回车预选,此时input.value还是拼音字母,就会误提交一条拼音内容。解决办法是监听compositionstart和compositionend事件,在输入法组合期间屏蔽回车提交。这个点在做批量表单、搜索框时特别常见,值得记下来。
let isComposing = false; todoInput.addEventListener('compositionstart', () => { isComposing = true; }); todoInput.addEventListener('compositionend', () => { isComposing = false; }); todoForm.addEventListener('submit', (e) => { if (isComposing) { e.preventDefault(); return; } // 正常提交逻辑 });筛选功能用三个按钮控制,维护一个filterState变量,可选值为all、active、completed。每次点击按钮时切换筛选状态并重新渲染,同时给当前激活的按钮加一个highlight样式,让用户知道现在处于哪个筛选视图下。
let filterState = 'all'; function getFilteredTodos() { if (filterState === 'active') return todos.filter(todo => !todo.completed); if (filterState === 'completed') return todos.filter(todo => todo.completed); return todos; }3. 无缝轮播图模块:从位移原理到自动播放
3.1 无缝轮播图的实现方案对比
轮播图的实现方案有很多,我不止一次在项目里遇到过不同做法,这里简单对比一下:
- 纯CSS动画:用CSS的animation自动改变translateX值实现轮播。优点是代码少,但手动切换和自动播放之间的状态同步比较麻烦,很难做“暂停”这类操作。
- 改变margin-left:通过修改容器的margin-left实现位移。这种老式做法性能差,而且容易引起整个页面的重排,基本不推荐。
- 修改translateX:只触发合成层变化,不触发布局重排,性能好,是目前的主流方案。
- 复制节点实现无缝:在轮播图首尾各复制一个节点,通过“先位移再瞬间跳回”的方式让视觉上看起来是无缝循环的。
我最终采用的是“复制节点 + 修改translateX”的组合方案。下面详细拆解一下它的无缝原理。
假设我们有4张图片,索引为0、1、2、3。为了让用户在最后一张向右滑时能平滑回到第一张,我在第一个节点前复制最后一张(复制的索引为-1),在最后一个节点后复制第一张(复制的索引为4)。这样实际DOM结构中有6个节点,视觉上图片4和图片1看起来是相邻的。
当用户从图片3向右切换到图片4(即复制品),动画结束后瞬间把容器的translateX改回图片1的起始位置,整个过程没有任何视觉跳跃,因为复制品和真品是同一个图。
这里的关键点是:位移值是索引乘以单张图片宽度,而且所有计算都以图片宽度为基准,不能写死像素值,否则窗口缩放时就废了。
3.2 轮播图核心代码与无缝切换逻辑
HTML骨架很简单:
<div class="carousel" id="carousel"> <div class="carousel-track" id="track"> <div class="slide"><img src="img4.jpg" alt=""></div> <div class="slide"><img src="img1.jpg" alt=""></div> <div class="slide"><img src="img2.jpg" alt=""></div> <div class="slide"><img src="img3.jpg" alt=""></div> <div class="slide"><img src="img4.jpg" alt=""></div> <div class="slide"><img src="img1.jpg" alt=""></div> </div> </div>注意第一位是最后一张图的复制,最后一位是第一张图的复制。这个顺序不能错,否则首尾切换会跳图。
核心JavaScript逻辑如下:
const track = document.getElementById('track'); const slides = document.querySelectorAll('.slide'); const prevBtn = document.getElementById('prev-btn'); const nextBtn = document.getElementById('next-btn'); const dots = document.querySelectorAll('.dot'); let currentIndex = 1; // 从真实的第一张开始 let slideWidth = 0; let isTransitioning = false; let timer = null; // 计算单张图片的宽度 function getSlideWidth() { return document.querySelector('.slide').offsetWidth; } // 设置位移 function setTranslate(offset, withTransition = true) { track.style.transition = withTransition ? 'transform 0.4s ease' : 'none'; track.style.transform = `translateX(-${offset}px)`; } // 初始化轮播图位置,定位到第一张真实图片 function initCarousel() { slideWidth = getSlideWidth(); currentIndex = 1; setTranslate(slideWidth * currentIndex, false); } // 切换到指定索引 function goTo(index) { if (isTransitioning) return; isTransitioning = true; currentIndex = index; setTranslate(slideWidth * currentIndex); // 更新指示圆点 let dotIndex = currentIndex - 1; if (dotIndex === slides.length - 1) dotIndex = 0; // 到最后一张(复制的第一张)时高亮第一个点 updateDots(dotIndex); } // 监听过渡结束后处理“瞬移” track.addEventListener('transitionend', () => { // 如果当前显示的是最后一张复制的第一张图,瞬移到真实的第一张 if (currentIndex === slides.length - 1) { currentIndex = 1; setTranslate(slideWidth * currentIndex, false); } // 如果当前显示的是第一张复制的最后一张图,瞬移到真实的最后一张 if (currentIndex === 0) { currentIndex = slides.length - 2; setTranslate(slideWidth * currentIndex, false); } isTransitioning = false; });transitionend事件是整个无缝逻辑的核心。当动画结束后,如果当前位于克隆节点上,就立刻关掉过渡效果并瞬移到真实对应位置。因为两次切换前后的图片内容完全一致,用户根本察觉不到这个过程。
还有一个小细节:从真实第一张向前翻到“索引0”(即复制的最后一张)时,动画方向是从第一张向左滑到“最后一张”,视觉上是正常的向前翻页,但实际此时展示的是复制节点,那么transitionend之后就要瞬移到真实的最后一张(索引为slides.length - 2)。这个判断逻辑一开始我写错了,导致向前翻时出现闪跳,后来把两个方向都测了一遍才定位到问题。
3.3 自动播放、手动切换与暂停机制
自动播放用setInterval实现:
function startAutoPlay() { stopAutoPlay(); timer = setInterval(() => { goTo(currentIndex + 1); }, 3000); } function stopAutoPlay() { if (timer) { clearInterval(timer); timer = null; } }手动切换到“上一张/下一张”:
nextBtn.addEventListener('click', () => { goTo(currentIndex + 1); startAutoPlay(); // 重置定时器 }); prevBtn.addEventListener('click', () => { goTo(currentIndex - 1); startAutoPlay(); });手动点击后要重置定时器,否则会出现“点击后3秒内又被自动切换打断”的割裂感。startAutoPlay内部先调stopAutoPlay再重新计时,就是为此。
鼠标悬停暂停、移出恢复,用mouseenter和mouseleave事件:
const carousel = document.getElementById('carousel'); carousel.addEventListener('mouseenter', stopAutoPlay); carousel.addEventListener('mouseleave', startAutoPlay);最后还有一个所有轮播图都必须处理的问题:窗口尺寸变化后宽度失效。因为slideWidth是在初始化时算好的,如果用户调整了浏览器窗口大小,所有图片宽度都会变,但位移值还是旧值,导致画面位置错乱。解决办法是监听resize事件,重新计算宽度并回到正常位置:
window.addEventListener('resize', () => { slideWidth = getSlideWidth(); setTranslate(slideWidth * currentIndex, false); });这里可以考虑加一个防抖,避免窗口缩放过程中resize事件被连续触发导致频繁计算。
let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { slideWidth = getSlideWidth(); setTranslate(slideWidth * currentIndex, false); }, 200); });4. 模块化整合与生产环境细节优化
4.1 模块化组织与代码分层
两个模块都做完之后,整份JavaScript代码已经有几百行了。如果全部塞在一个script标签里,后面想维护简直要命。我习惯把代码按功能拆成几部分,每个部分用一个大功能函数包裹,避免变量互相污染。
最稳妥的做法是用IIFE(立即执行函数)创建一个独立作用域,把待办清单和轮播图的逻辑分别封装:
const TodoApp = (function() { let todos = []; // 各种函数... function init() { ... } return { init }; })(); const CarouselModule = (function() { let currentIndex = 1; // 各种函数... function init() { ... } return { init }; })(); document.addEventListener('DOMContentLoaded', () => { TodoApp.init(); CarouselModule.init(); });这种写法把内部状态完全隔离,外部只能通过暴露出来的init方法启动模块,模块之间互不干扰。等你以后学了ES6的import/export,会发现思路是一样的,只是语法更优雅。
4.2 新增两张图片的性能与资源处理
轮播图用到了图片资源,图片加载慢会导致轮播初始化时slide宽度错误或者图片闪白。两个比较实用的优化手段:
第一,给所有轮播图img添加loading="lazy"属性。这样首屏加载只下载当前可见的几张图,后续图在需要展示时才加载,能明显提升页面打开速度。但对轮播来说,如果设置了lazy,可能位移到下一张时图片还没加载完成,出现短暂空白。所以更稳妥的做法是只对首屏资源直接加载,后面的用懒加载,或者干脆全部用preload预加载,具体取决于图片数量和体积。
第二,对图片尺寸做固定处理。在CSS里给.slide和img设置固定宽高,比如:
.slide { width: 100%; flex-shrink: 0; } .carousel-track { display: flex; } .slide img { width: 100%; height: 360px; object-fit: cover; }上下两张图高度不一致时,用object-fit: cover能保证图片填满容器且不变形。这一点在图片比例不统一时特别重要。
如果你用的是背景图方案而不是img标签,也可以直接在CSS里写background-size: cover加上固定宽度高度。
4.3 常见运行时报错与问题排查实战
最后一部分是问题排查。我在写这个项目时遇到的报错和异常场景,基本可以整理成一张速查表。
| 现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 页面刷新后待办数据丢失 | localStorage没写入或读取失败 | 检查是否调用了saveTodos;打开开发者工具Application面板查看Local Storage字段是否有值 |
| 添加待办时按回车页面刷新 | 表单submit事件没调用preventDefault | 给submit监听器第一行加上e.preventDefault() |
| 输入中文时按回车误提交拼音 | 输入法组合事件未处理 | 监听compositionstart/compositionend,组合期间拦截提交 |
| 轮播图最后一张切回第一张时闪白 | 位移计算或瞬移时机不对 | 确认首尾各复制了一个节点;确认transitionend触发时能正确瞬移 |
| 轮播图快速点击按钮时图片跳动 | 动画未结束又触发切换 | 用isTransitioning锁定,动画期间忽略新的切换指令 |
| 窗口缩放后轮播图位置错乱 | slideWidth还是旧的 | resize时重新计算宽度并setTranslate |
| 控制台报错:Cannot read property ‘offsetWidth’ | DOM还没加载完成就执行了JS | 把初始化代码放到DOMContentLoaded事件中执行 |
| 控制台报错:Unexpected token ‘{’ | 语法错误,多半是ES6写法中花括号配对有问题 | 按报错行号逐行检查;如果代码有模板字符串,检查反引号是否正确 |
| 安全扫描提示JavaScript框架库漏洞 | 项目引入了第三方JS库但版本过低 | 如果是原生项目没有第三方库,这个提示通常是扫描器误报;如果有外部库,升级到最新版本 |
这里额外说一个排查思路。很多人遇到报错时习惯把错误码复制到搜索引擎里查,但我更推荐先做三件事:第一,看报错信息中提到的文件名和行号;第二,把代码注释掉一部分,二分定位出错位置;第三,在关键计算前后用console.log打印中间变量。比如轮播图位置错乱,你先把currentIndex、slideWidth、track.style.transform打出来,看是哪个值算错了,问题一下子就清楚了。
快速点击按钮导致动画错乱这个问题,我再提供一个防御性写法。除了用isTransitioning锁,还可以直接禁用按钮或加节流:
function throttle(fn, delay) { let lastTime = 0; return function(...args) { const now = Date.now(); if (now - lastTime > delay) { lastTime = now; fn.apply(this, args); } }; } const throttledNext = throttle(() => goTo(currentIndex + 1), 500); nextBtn.addEventListener('click', throttledNext);这个函数把切换频率限制在每500毫秒最多一次,配合isTransitioning双重保障,基本不会再出现动画错乱的问题。
4.4 关于JavaScript框架库漏洞的一个说明
写原生JavaScript项目时,安全扫描报告中经常会出现“检测到目标站点存在JavaScript框架库漏洞”这一类提示。最初看到这个我还专门查过原因,后来发现绝大多数情况是因为页面引入了某个旧版本的jQuery、Bootstrap或者日期选择器插件,这些第三方库的旧版本有已知的CVE漏洞,扫描器检测到库版本号就会报警。
如果项目本身是纯原生JavaScript写的,没有引入任何第三方库,那这个提示基本可以确认是扫描器的误报,因为它可能把一些特征识别成了框架库的调用。真正需要重视的是生产环境引用的外部库版本,务必升级到官方最新稳定版,并且在引入任何第三方库之前确认它的维护状态和漏洞信息。这也是原生JavaScript项目的一个隐性优势——不依赖外部库,供应链风险天然会小很多。
5. 项目完成后还能怎么扩展
这个项目写完并不是终点,我建议你在现有基础上再自己加几个功能练手,难度递增:
第一档:给待办清单增加编辑功能。双击待办文字进入编辑态,回车保存,Esc取消。这个功能看似简单,但涉及“编辑中状态切换”和“失去焦点时的处理”,对事件处理能力要求高一个档次。
第二档:给轮播图增加触摸滑动支持。监听touchstart、touchmove、touchend,计算手指滑动的距离和方向,手势结束后根据滑动距离决定是否切换。这能让你理解移动端手势交互的基本套路。
第三档:把两个模块整合成一个完整的“工作台页面”,加入任务数量图表统计。比如用Canvas或纯DOM画一个简单的环形图,展示已完成任务和未完成任务的比例。到这里你已经能感受到数据可视化是怎么回事了。
再往后,你可以把这个项目用Vue或React重写一遍,对比一下原生写法和框架写法的差异。很多人在网上问“要不要先学原生再学框架”,我的答案始终是:如果连原生的事件绑定、DOM操作和数据变化刷新视图这些基本套路都没上手过,直接学框架会觉得很玄学;反过来,如果你已经用原生做过一个完整的实战项目,再去用框架,你会发现每一个抽象概念你都能在脑子里映射出它对应的原生实现,学起来快得多。
写这个项目时我自己印象最深的一点是:轮播图的“无缝”其实不是魔术,核心就是“复制节点+过渡结束后瞬移”这一招。很多看起来很流畅的效果,底层的思路往往非常简单,难的是把各种边界情况想全——用户点太快怎么办、窗口变了怎么办、图片加载慢怎么办。这些边界情况的处理经验,几乎只能在反复踩坑和调试中积累。如果你照着这篇文章的思路动手做了一遍,中途遇到任何问题,欢迎回来对照我整理的速查表逐项排查,大部分问题都能在里面找到答案。