说实话待办事项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 srchttps://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里不碰 DOMrender.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-xl36rem这个宽度对任务列表来说刚刚好既不会窄到挤压文字也不会宽到需要频繁扫视。页面从上到下分成五个区块头部标题栏、输入区、统计卡、筛选标签 任务列表、底部操作栏。在移动端做了两个关键调整一是输入区的加号按钮在窄屏下保持 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 classgroup 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 统计面板和进度条实现统计面板放在列表上方实时显示三项数据总任务数、已完成数、完成百分比。进度条是一个动态宽度的 divfunction 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 srcx onerroralert(1)直接拼进 innerHTML 再渲染这段恶意脚本就会被浏览器执行。虽然是自己用的小工具但养成了坏习惯以后做博客评论、留言板这类功能时迟早出事。正确做法是写一个转义函数function escapeHTML(str) { return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #039;); }渲染任务标题时套一层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或者把统计面板做成按日期维度的完成率图表。每一步都能在这个项目骨架上平滑生长不会推倒重来。