说实话,待办事项(Todo List)这类项目,前端圈子里是经典中的经典,随便一搜就是几十篇教程。但我还是建议你有空真正用 HTML + TailwindCSS + 原生 JS 独立从零写一遍,别直接拿现成组件库拼。前几天我花了小半天时间,从头搭了一个高颜值的待办事项管理系统,写完最大的感受是:这种看似人畜无害的小项目,一旦你真把它当成产品来打磨,能练到的硬本事远比想象中多。
这篇文章就把我的完整思路、代码结构、踩坑记录全部分享出来。适合两类人:一类是刚学完 HTML/CSS/JS 基础,想找一个有深度但又不至于劝退的练手项目的初学者;另一类是平时用惯了 Vue/React,想回归原生 JS 写点干净东西的熟手。这个项目的核心价值不在于“能跑”,而在于把数据持久化、渲染更新、事件绑定这些前端基础功扎扎实实过一遍,最后交付一个看得上眼、拿得出手的成品。
1. 这个项目到底解决什么问题
1.1 为什么看起来简单,却值得认真做
待办工具大家都用过,手机上装过滴答清单、番茄Todo,电脑上可能用过微软 To Do。但你会发现,现成工具总有那么几个点不合心意:要么界面太重、动画太多,要么数据必须登录云端、离线就是个空壳,要么连“回车快速新增”这种基础交互都要找半天设置。自己写一个的好处,就是每个细节都能按自己的习惯来。
从技术角度讲,待办事项管理系统覆盖了前端最核心的能力闭环:数据模型设计(任务对象长什么样)、数据持久化(刷新不丢数据)、状态切换(完成/未完成/编辑中)、条件渲染(筛选和搜索)、用户交互反馈(动画、空状态、快捷键)。这些能力在真实业务开发中天天都在用,把一个 Todo 项目写透,比刷一遍文档有用得多。
还有一个容易被忽略的点:这类工具属于“小而完整”的产品。麻雀虽小五脏俱全,你能在这几百行代码里感受到信息架构、视觉层级、交互细节三者如何协作。这就是为什么我坚持使用原生 JS而不是直接上 Vue——当你亲手操作 DOM、亲手处理事件委托、亲手维护状态数组的时候,那些框架替你做的事情,你才算真正看懂了。
1.2 功能清单和最终效果预览
我这次做的是“高颜值管理系统”定位,所以功能上不是简单的新增/删除,而是拆成了三个层次:
| 层次 | 功能 | 说明 |
|---|---|---|
| 基础功能 | 新增任务、删除任务、完成/取消完成 | 这是 Todo 的底线,必须顺手 |
| 查询功能 | 关键词搜索、状态筛选(全部/进行中/已完成) | 任务一多,筛选就是刚需 |
| 统计功能 | 总数统计、完成数、进度条展示 | 让用户对整体节奏有感知 |
| 体验功能 | 双击编辑、回车提交、暗黑模式、空状态提示、移动端适配 | “高颜值”主要靠这些细节撑起来 |
最终界面是单列居中卡片式布局:最顶部是标题和当前日期;往下是输入框,回车即可添加;再往下是统计信息卡,用进度条直观显示完成比例;然后是筛选标签栏;接着是任务列表;底部还有一个“清除已完成”的操作入口。整体视觉风格是浅色背景 + 白色卡片 + 靛蓝色主色,暗黑模式下自动切换成深灰背景。
2. 技术选型的真实逻辑
2.1 为什么不用框架,也不用组件库
先用一个表把各种方案的取舍摆清楚:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Vue/React + UI组件库 | 开发快、生态成熟 | 构建链复杂,对新手不友好;很多逻辑被框架隐藏,基础不牢 |
| Vue/React(不用组件库) | 状态管理方便 | 为一个 Todo 引入虚拟DOM,杀鸡用牛刀,心智负担重 |
| 原生 JS + TailwindCSS | 无构建依赖、逻辑完全可控、锻炼DOM编程能力 | 复杂项目代码组织成本高,但本项目规模完全驾驭得住 |
这里的关键判断是:这个项目的状态流并不复杂,本质上就是“一个任务数组 + 几个筛选条件”。不存在跨组件通信、不存在路由、不存在服务端状态。原生 JS 用一个全局数组就能维护得清清楚楚,引入框架反倒是给自己加戏。
而且从学习角度,原生 JS 是最能暴露问题的方式。比如刷新后数据丢失,你会去研究 localStorage;点击删除无效,你会去排查事件绑定;渲染后发现输入框失焦,你会去理解 DOM 重绘机制。这些坑,用框架写大概率碰不到,但一辈子都用得上的排查思路,恰恰是在这些坑里练出来的。
2.2 TailwindCSS 如何让“高颜值”落地
很多人对 TailwindCSS 的印象停留在“工具类 CSS”,但真正用上手之后,你会发现它解决了一个很实际的问题:不用费劲给每个类起名字。写原生 CSS 的时候,面对一个卡片组件,你要想.card、.card-header、.card-body,写到后面命名就是个大麻烦。TailwindCSS 直接用bg-white rounded-2xl shadow-sm p-6就把卡片样式定义完了,思路完全不中断。
另外一个优势是设计约束。TailwindCSS 的间距、字号、颜色都是基于预设的数值体系,色板里取颜色、间距表里取距离,出来的界面天然协调。这对于没有专职设计师的个人开发者来说,能有效避免“红一块绿一块”的灾难现场。
我这次开发调试用的是 Tailwind 的 Play CDN,在 HTML 里加一行<script src="https://cdn.tailwindcss.com"></script>就能直接用,零构建、改完刷新就有样式,做原型非常爽。不过要提醒一句,这只是开发阶段的手段,生产环境千万别用 CDN 版本,它会在浏览器里实时编译,性能损耗明显,真要上线还是得走 Tailwind CLI 或 PostCSS 编译成静态 CSS。
2.3 项目文件组织与目录规划
为了演示方便,我最终把代码组织成了单 HTML 文件 + 模块化注释,这对初学者最友好,双击就能打开运行。但如果你打算继续扩展这个项目,我建议按下面这个结构拆:
todo-app/ index.html # 页面骨架 css/style.css # Tailwind 编译产物 + 少量自定义样式 js/ app.js # 入口:初始化、事件注册 store.js # 数据层:localStorage 读写、CRUD 操作 render.js # 渲染层:把数据变成 DOM utils.js # 工具函数:时间格式化、HTML转义等这样拆的好处是职责分明:store.js里不碰 DOM,render.js里不碰数据存储。以后想加一个“按优先级排序”的功能,只需要改store.js里的排序逻辑;想改列表展示样式,只动render.js。项目小,但架构思维可以从一开始就建立。
3. 界面与视觉系统搭建
3.1 配色体系和风格基调
高颜值的核心不是堆特效,而是克制。我定下的调色板是这样的:
- 页面背景:
slate-100(浅灰蓝),暗黑模式下用slate-950 - 卡片:
white,暗黑模式下用slate-800,配rounded-2xl和shadow-sm - 主操作色:
indigo-500,新增按钮、选中态、进度条都用它 - 完成状态色:
emerald-500,完成的任务标题变绿加删除线 - 危险操作色:
rose-500,删除按钮悬停时出现 - 文字主色:
slate-800,次要文字slate-400
整套颜色控制在 5 个色相以内,视觉自然统一。TailwindCSS 的温度感在这里体现得很好,rounded-2xl让卡片不那么锋利,shadow-sm比默认 shadow 更轻盈,space-y-3控制垂直间距,每一处都用得恰到好处。
字体方面,我直接用了系统字体栈,没有额外引入 web 字体。中文字体加载成本高,为了几个字重引入整套字体文件,不值得。系统默认字体在各类设备上的表现本身就足够顺眼。
3.2 布局结构与响应式适配
整体布局走“单栏居中”路线,大屏上不会显得内容太散,小屏上也不会拥挤。核心容器宽度我选max-w-xl(36rem),这个宽度对任务列表来说刚刚好,既不会窄到挤压文字,也不会宽到需要频繁扫视。
页面从上到下分成五个区块:头部标题栏、输入区、统计卡、筛选标签 + 任务列表、底部操作栏。在移动端做了两个关键调整:一是输入区的加号按钮在窄屏下保持 44x44px 的可点击面积,保证手指好点;二是任务列表的右滑删除按钮换成显示在卡片内部,避免误触。
3.3 几个让人“哇”的交互细节
“高颜值”往往不在大框架上,而在小细节里。我做了四个自认为最提气的交互:
自定义复选框。原生 checkbox 在不同浏览器里长得完全不一样,还很呆板。我用了 Tailwind 的peer机制,把真实 input 隐藏起来,配上一个大圆角的自定义勾选样式。点击时会有轻微的 scale 变化,完成状态切换时打勾图标有 150ms 的过渡动画,手感非常跟手。
空状态提示。当列表为空时,不是直接白屏,而是显示一个居中的 SVG 小图标加一句话:“暂无任务,先记下一件小事吧”。这个空状态在用户体验里特别重要,它避免了用户面对空白页面的茫然感。
进度条动效。统计卡里的进度条宽度是动态计算的,完成比例越高,进度条颜色从slate-300渐变成emerald-500。宽度变化用transition-all duration-500做了平滑动画,每次完成任务都能看到进度条“长”了一截。
回车快捷添加。输入框里键入内容后按回车即时添加,Add 按钮反而成了辅助。这个交互和原生 Todo 工具保持一致,用惯了效率工具的人会觉得很亲切。
4. 核心实现:数据层与渲染层
4.1 数据模型设计
任务对象我设计了五个字段:
{ id: "c3f9a8e2-6d1b-4f7a-9e5d-2b8a1c9f4e6d", title: "整理项目周报", completed: false, createdAt: 1740000000000, // 时间戳,用于排序 updatedAt: 1740000000000 // 记录最后修改时间 }id 我用了crypto.randomUUID()生成,这是现代浏览器内置的 API,不需要依赖任何库,也不用担心拼接时间戳可能出现的碰撞问题。createdAt 作为默认排序依据,新任务永远排在前面。updatedAt 为将来做“最近编辑”排序预留了扩展位。
4.2 store.js:完整的 localStorage 读写封装
数据持久化是整个系统的基础。刷新页面任务还在,这是待办工具最基本的信任感来源。我封装了一个 store 模块:
const STORAGE_KEY = "todo-app-tasks"; let tasks = loadTasks(); function loadTasks() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { console.warn("localStorage 读取失败,使用空数据", e); return []; } } function saveTasks() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); } catch (e) { console.error("保存失败,可能超出存储配额", e); } }对外暴露的操作都围绕tasks数组展开,每修改一次就调用saveTasks()落盘:
function addTask(title) { const task = { id: crypto.randomUUID(), title, completed: false, createdAt: Date.now(), updatedAt: Date.now() }; tasks.unshift(task); // 新任务插到最前面 saveTasks(); return task; } function toggleTask(id) { const task = tasks.find(t => t.id === id); if (task) { task.completed = !task.completed; task.updatedAt = Date.now(); saveTasks(); } } function deleteTask(id) { tasks = tasks.filter(t => t.id !== id); saveTasks(); } function clearCompleted() { tasks = tasks.filter(t => !t.completed); saveTasks(); }这里有一个新手容易踩的坑:tasks用const声明后,如果直接执行tasks = tasks.filter(...),JS 引擎会直接报错。正确做法是把数组声明改成let,或者用tasks.splice()、tasks.length = 0等方式原地修改。我上面deleteTask和clearCompleted都重新赋值了,所以声明用的是let。这个细节虽然小,但很容易让人卡住。
4.3 渲染逻辑:从数据到 DOM
渲染层我采用了一个最直白、也最适合小项目的策略:每次数据变化,整体重新渲染列表。代码大致长这样:
function render() { const filteredTasks = getFilteredTasks(); const listEl = document.querySelector("#task-list"); listEl.innerHTML = filteredTasks.map(taskTemplate).join(""); renderStats(); }taskTemplate是一个纯函数,输入任务对象,输出 HTML 字符串:
function taskTemplate(task) { return ` <li class="group flex items-center gap-3 rounded-xl bg-white px-4 py-3 shadow-sm transition hover:shadow-md dark:bg-slate-800" >document.querySelector("#task-list").addEventListener("click", (e) => { const actionBtn = e.target.closest("[data-action]"); if (!actionBtn) return; const li = actionBtn.closest("li"); const id = li.dataset.id; if (actionBtn.dataset.action === "delete") { deleteTask(id); render(); } });closest方法在这里是主角:它能向上查找最近的匹配祖先元素。因为删除按钮可能包了一层 icon,用closest("[data-action]")就能不管点击的是字还是图标,都能正确找到操作按钮,再通过li.dataset.id拿到任务 ID。这套模式理解透了,以后写任何列表类组件都能直接复用。
4.5 新增、编辑、清除的操作细节
新增任务的交互是输入框监听keydown,按下回车且内容非空时才创建:
inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter") { const title = inputEl.value.trim(); if (!title) { inputEl.classList.add("border-rose-400"); // 空内容给个红框提示 return; } addTask(title); inputEl.value = ""; inputEl.classList.remove("border-rose-400"); render(); } });trim()在这里很关键,它能把输入内容两端的空格去掉,避免创建出 “ ” 这种看不见的任务。空内容时我加了一个红框提示,1.5 秒后自动移除,这个交互比单纯“没反应”要友好得多。
编辑功能我放在双击事件里:双击任务标题后,把标题替换成一个输入框,按回车或失焦时保存。实现时最需要注意的是保存前要拿到当前编辑框里的新值,然后去 store 里更新对应任务,最后重新渲染。如果直接在旧 DOM 上改,不更新数据源,下次筛选时就会看到“好数据又被改回去了”的诡异现象。
5. 进阶功能:搜索、统计与暗黑模式
5.1 关键词搜索与忽略大小写实现
任务一多,筛起来就得靠搜索。我做的搜索框逻辑比较简单:监听input事件,实时更新关键词,然后渲染层过滤数据。
function getFilteredTasks() { const keyword = currentKeyword.trim().toLowerCase(); let result = tasks.slice(); if (keyword) { result = result.filter(t => t.title.toLowerCase().includes(keyword)); } if (currentFilter === "active") { result = result.filter(t => !t.completed); } else if (currentFilter === "completed") { result = result.filter(t => t.completed); } return result; }这里有个搜索相关的细节值得展开:忽略大小写。如果用户搜索“project”,而实际任务标题是“Project 周报”,直接用includes("project")是匹配不上的。所以我会把标题和关键词都先.toLowerCase()再比较,这比用正则的i标志更直观,性能也足够好。另外String.prototype.includes做子串匹配时是连续匹配,搜索“周报”能命中 “整理项目周报”,这个体验很符合直觉。
5.2 统计面板和进度条实现
统计面板放在列表上方,实时显示三项数据:总任务数、已完成数、完成百分比。进度条是一个动态宽度的 div:
function renderStats() { const total = tasks.length; const completed = tasks.filter(t => t.completed).length; const percent = total === 0 ? 0 : Math.round((completed / total) * 100); document.querySelector("#stat-total").textContent = total; document.querySelector("#stat-completed").textContent = completed; document.querySelector("#stat-percent").textContent = percent + "%"; const bar = document.querySelector("#progress-bar"); bar.style.width = percent + "%"; }这里有一个除以零的边界问题:当总任务数为 0 时,直接用completed / total会得到Infinity。所以我加了total === 0 ? 0的判断。这个场景在真实项目里太常见了,空数据是用户最早就可能遇到的场景,不处理的话进度条会变成一条诡异的无限宽。
5.3 暗黑模式完整实现
暗黑模式是“高颜值”的重头戏。TailwindCSS 默认的暗黑模式基于prefers-color-scheme媒体查询,但我想要一个可手动切换、且能记住用户选择的方案。做法分三步:
第一步,在 HTML 里内联配置 Tailwind,把暗黑模式切换方式改成 class 控制:
<script> tailwind.config = { darkMode: "class" }; </script>第二步,写一个切换按钮,点击时在document.documentElement上切换dark类:
const themeToggleBtn = document.querySelector("#theme-toggle"); themeToggleBtn.addEventListener("click", () => { const isDark = document.documentElement.classList.toggle("dark"); localStorage.setItem("todo-theme", isDark ? "dark" : "light"); });第三步,页面加载时初始化主题。这里有个优先级逻辑:用户手动选择过主题,就用用户的选择;没选过,就用系统偏好:
function initTheme() { const saved = localStorage.getItem("todo-theme"); if (saved) { document.documentElement.classList.toggle("dark", saved === "dark"); } else { const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; document.documentElement.classList.toggle("dark", prefersDark); } }最后,渲染模板里的容器、卡片、文字都加上dark:前缀的样式类。比如卡片从bg-white变成dark:bg-slate-800,文字从text-slate-700变成dark:text-slate-200。这套方案在以后做真实项目时也能直接搬。
6. 实操中的常见问题与排坑实录
6.1 localStorage 的典型坑
localStorage 操作很简单,但坑一点都不少。第一个坑是JSON 解析异常。数据是字符串,任何一次手滑写入的损坏数据,都可能导致JSON.parse抛出异常,整个应用白屏。所以我在loadTasks里用 try-catch 包住了解析过程,出错时返回空数组,至少保证页面能用。
第二个坑是存储配额。localStorage 每个域名通常只有 5MB 左右,存满了再setItem会抛QuotaExceededError。虽然待办数据到不了这个量级,但养成 try-catch 的习惯没坏处。
第三个坑是隐私模式下的写入失败。在 Safari 的无痕模式或某些浏览器隐私设置下,localStorage.setItem可能直接抛异常。同样需要 try-catch 兜底,否则用户能添加任务但一保存就报错,体验非常糟糕。
第四个坑是同步阻塞。localStorage 的读写是同步操作,写入大对象时主线程会卡顿。待办系统数据量小,感知不到,但如果你以后拿这套模式去写数据量大的应用,建议加个简单的防抖,或者直接用 IndexedDB 替代。
6.2 innerHTML 的 XSS 隐患
如果用户输入的任务标题是<img src=x onerror=alert(1)>,直接拼进 innerHTML 再渲染,这段恶意脚本就会被浏览器执行。虽然是自己用的小工具,但养成了坏习惯,以后做博客评论、留言板这类功能时迟早出事。
正确做法是写一个转义函数:
function escapeHTML(str) { return str .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }渲染任务标题时套一层escapeHTML(task.title),把<、>、&这些字符转成安全的实体。这里我强烈建议你不要用innerText来做展示,因为老版本 innerText 的刷新机制可能触发额外的重排,而且行为不一致。字符串模板 + 手工转义是最可控的方式。
6.3 整体重绘导致的输入框失焦
这是做“实时搜索”时最容易踩的坑。最初我的搜索框一有输入,就调用整体render()重新渲染列表。如果搜索框正好也在这次渲染范围内,DOM 被替换,输入框会立刻失焦,搜着搜着光标就没了,根本没法连续输入。
解决办法有两个:一是把搜索框放在列表容器外面,只重绘列表区域;二是如果搜索框确实在容器内部,就改成仅更新列表项,不动外层结构。我选了方案一,结构清晰,不会误伤。这个问题的本质是“渲染范围控制”,理解了之后,遇到类似问题就能举一反三。
6.4 移动端适配的三个细节
移动端有三个容易忽略的细节。第一个是安全视口高度:手机浏览器地址栏会动态伸缩,100vh会导致底部被盖住,改成100dvh(动态视口高度)才能真正稳住底部。第二个是触摸目标尺寸:iOS 人机交互指南建议点击目标不小于 44x44px,所以删除按钮、复选框都要给足内边距,别为了“精致”把按钮缩得太小。第三个是横向滚动:任务标题一长,中文还好,如果是连续的英文或链接,默认会在单词边界处溢出,需要在标题上加break-all,强制换行。
6.5 一键返回顶部与空状态的小彩蛋
任务列表长了以后,从底部回到顶部输入新任务确实有点烦。我在任务列表超过一定高度后显示一个“返回顶部”按钮,点击后平滑滚动:
const backTopBtn = document.querySelector("#back-top"); backTopBtn.addEventListener("click", () => { window.scrollTo({ top: 0, behavior: "smooth" }); });配合behavior: "smooth",整个滚动是渐进式带动画的,视觉效果比直接scrollTo(0, 0)好太多。另外空状态我给了一个带表情的 SVG 图标加一句“暂时没有任务”,而不是直接白屏。这个小细节,直接决定了这个项目能不能称得上“高颜值”。
7. 最终代码结构与后续可扩展方向
到这里,整个待办事项管理系统的核心功能就全部落地了。我把最终的文件结构固定下来,便于你直接参考:
todo-app/ index.html # 页面骨架 + Tailwind 配置 js/ store.js # 数据模型 + localStorage 封装 render.js # DOM 渲染 + 事件委托注册 app.js # 初始化、键盘监听、主题切换在实际动手过程中,我最大的一个体会是:这种小型工具类项目,不要急于照搬框架或组件库,先用原生代码把它完整写一遍。你会被迫思考数据从哪来、渲染怎么更新、用户操作怎么映射到数据变化。这套思考模型想清楚了,以后学 Vue 的响应式、学 React 的不可变数据,都能一秒理解它们在解决什么问题。
下一步如果想继续扩展,我给几个方向:给任务加截止日期和优先级排序;引入 IndexedDB 做离线数据同步;把编辑改成弹窗形式并支持 Markdown;或者把统计面板做成按日期维度的完成率图表。每一步都能在这个项目骨架上平滑生长,不会推倒重来。