有时候,一个想法的起点只是工作间隙里的一句话:
“最近太忙了,写的任务都乱糟糟的。”
我一边咕哝着,一边看着桌面上贴满便利贴的显示器。身为一名前端工程师,我对这种“混乱美学”实在无福消受,于是下定决心——我要自己做一个精致的、够现代的、功能完善的 Todo List。
一、为什么我不用现成的 Todo 应用?
市面上优秀的任务管理工具一抓一大把,像 Notion、TickTick、Todoist……可问题是,我不想被「工具」限制我的使用习惯,我想要一个纯粹的、小巧的、可自己掌控的任务清单工具。再说了,谁说练手项目就得丑陋、粗糙?我偏要把这个小项目打磨得像作品一样。
于是,这就成了一个目标明确的练手项目:
- 不用任何前端框架,纯粹原生 HTML + CSS + JS
- 支持任务添加、删除、完成标记
- 任务本地持久化保存
- 根据优先级渲染不同颜色
- UI 必须简洁美观,最好能接近产品级体验
二、项目设计:我如何构建这套 Todo 系统?
在正式开写代码之前,我拿出纸笔(其实是 Figma),梳理了一下整个应用的流程和逻辑。任务管理虽然是个简单的功能,但如果结构混乱,就很容易在后续扩展中“跪掉”。
于是我画出了第一个流程图:
这张图囊括了数据的流转路径,也为我后续编码提供了逻辑依据。
三、页面结构搭建:从零构建现代 UI
我决定采用卡片式设计,主色调选择了柔和的浅灰与天蓝,字体使用 Inter
,按钮采用圆角与阴影过渡,整体风格轻盈、现代。下面是 HTML 的结构骨架:
整体布局简洁明了,页面加载时,任务列表根据 LocalStorage 渲染,输入框和选择框用于添加任务,按钮点击触发任务添加事件。
四、CSS 美化:让每个细节都精致一点
接下来是我最享受的部分之一——设计 UI 样式。为了让界面不那么「学生作品」,我使用了柔和的颜色、卡片式任务块、现代字体和合理的留白。
在优先级的颜色区分上,我借用了 Material Color 的色彩理念:绿色代表低优先,橙黄代表中优先,红色代表高优先——一眼看过去就知道什么是紧急任务。
五、JavaScript 核心:从思路到落地
当我在写 HTML 和 CSS 时,虽然外观已经初具雏形,但功能才是这个小项目的灵魂:我们要实现添加、删除、标记完成、优先级渲染,以及最关键的数据持久化。为此,我先在代码中定义了一个 tasks
数组来承载当前所有的任务对象,每个对象包含 id
、content
、priority
、completed
四个属性。
5.1 页面初始化:从 LocalStorage “复活”任务列表
打开页面的第一件事,就是检查浏览器 localStorage
里有没有我们的 “todoData”:
一开始,我忘记考虑当用户清空浏览器数据时 saved
为空的情况,直接 JSON.parse(null)
会抛错,经过调试,我加入了三元判断,这样即使没有任何数据,tasks
也能正确地初始化为空数组。
5.2 渲染函数:把数据搬到 HTML 上
拿到最新的 tasks
数组后,我们就要把它渲染到页面里。renderTasks()
的职责,就是清空当前的任务列表 DOM,然后根据每个任务的状态和优先级,动态生成对应的列表项:
这里有两点我想特别说明:
- 我把按钮的文字用符号来代替文字,不仅节省了空间,还兼顾了国际化——“✔” 和 “🗑” 是所有人都能直观理解的图标。
- 每次渲染前都先清空列表,避免了重复渲染问题,这是我最开始忘记做的,导致每次添加任务之后,列表会无限增长,后面我才想起来要先
list.innerHTML = ''
。
5.3 绑定添加任务事件:让输入框成为生产力工具
接下来,我们要响应用户点击 “添加任务” 的操作。要注意两件事:一是要获取输入框的内容并清空它;二是要读取当前选择的优先级,然后组装成新任务并推入 tasks
数组,最后更新到 localStorage
并重新渲染。
这里我用了 Date.now()
作为 id
,既能保证每个任务的唯一性,也给之后可能的排序、拖拽等扩展功能留下了线索。调试时我发现,若不对 content
做 trim()
,用户在输入空格时也会把一个“空白任务”加进来,非常糟糕,于是我加了这个小小的输入校验。
5.4 保存与更新:LocalStorage 的“魔法”
每次对 tasks
数组做了增删改,都要同步 localStorage
,否则刷新后就没了。于是我把它封装成一个“小方法”:
这样,在添加、删除、标记完成的回调中都只需调用这一行,就能保证数据与界面一致。最开始我没封装,结果到处写这两行,既臃肿又容易出错,后来想到 DRY 原则(Don't Repeat Yourself),便一举封装,代码简洁了不少。
5.5 标记完成与删除:微妙的用户体验设计
当用户点击 “✔” 时,其实是要切换任务的完成状态。需要做两件事:一是修改数组中对应任务的 completed
属性;二是更新界面。我们给按钮绑定的回调 toggleComplete
这样写:
注意这里我用了 closest('li')
来拾取带有 data-id
的父元素,这是因为按钮里没有直接存储 id
的数据,只能沿着 DOM 树向上查。这一招在后来实现拖拽排序时也派上了用场。
删除操作则更加直接:
有趣的是,我一开始直接在数组上 splice
,但后台调试工具里发现 tasks
数组没变,于是我意识到 filter
不会改变原数组,而是返回新数组,用它来替换 tasks
更直观、更可靠。
六、迭代优化:怎样让这个小工具更“高级”?
完成了上面基础功能后,我在自测时发现几个小痛点,于是进行了针对性的优化。
6.1 键盘回车添加:提升交互流畅度
不喜欢每次都去点按钮?那就让输入框支持回车触发添加。只要在 taskInput
上监听 keypress
事件即可:
这样在输入完任务内容后,一敲回车就添加,使用起来更顺手了。
6.2 优先级默认色块:让用户更直观
原本优先级是下拉框,在视觉上有些平淡。我想,如果能在选项左侧加个小色块,在下拉列表里更直观就好了。纯 CSS 做不到,但 JS 可以轻松搞定:在渲染任务时,把 select
换成了自定义的下拉组件,用一段小 CSS 加上 JS 插入的内联样式就能呈现色块。
这里我就不贴完整代码,总结思路:监听 select
的 change
事件,更新输入区左侧的小色块背景色;在渲染列表项时,把颜色信息写到列表元素的 style
属性上。这样,添加任务前,用户就能一眼判断任务的优先级颜色。
七、拖拽排序:让任务随心所欲地排列
7.1 为什么要拖拽?
最早我用按钮或上下箭头来调整任务顺序,体验很糟糕:不仅要精确点击一个又小又密的按钮,而且一旦任务多了,找来找去很麻烦。直到有一天我想:既然浏览器支持拖拽 API,何不让用户直接按住某个任务,拖到想要的位置?
7.2 设计拖拽流程
在正式动手代码前,我又画了一张流程图,帮我理清拖拽的各个事件如何协作:
这张图明确了三个关键事件:dragstart
、dragover
、drop
,它们分别是拖拽开始、拖拽过程、释放放下。
7.3 代码落地:一点点调试到流畅体验
- 给列表项添加可拖拽属性 我在渲染函数里把
li
的draggable
属性打开,并绑定dragstart
事件:
- 允许目标任务接收释放 在
dragover
事件里必须preventDefault()
,否则释放时无效:
- 处理放下逻辑 当用户松开鼠标时,获取拖拽源
id
和目标id
,然后在tasks
数组中重新排序:
- CSS 小贴士 为了让拖拽时视觉更友好,我给被拖拽的元素加了半透明效果,在
.drag-over
下增加了虚线边框:
我还在 dragstart
里手动给 li
加上 dragging
类,在 dragend
里移除。这样,拖动时就有种“抓住了”并在移动的感觉。
经过几次打断点、打印 tasks
数组下标的细节,我终于让拖拽排序稳定了。现在无论把任务从头往尾拖,或者尾往中间拖,都能准确插入到目标位置。
八、支持子任务:让项目层级化
8.1 为什么要子任务?
有时候一个大任务里有好几个小步骤,比如 “写博客” 这个任务,可能包含 “画流程图”“写 HTML”“写 CSS”“写 JS”……如果都堆在最外层,列表就很难一目了然。子任务可以让我们把任务拆解,父子关系清晰。
8.2 数据模型调整
原先每个任务对象只有 id/content/priority/completed
,现在我给它加上 children
数组,用来存放子任务:
8.3 渲染嵌套列表
渲染时,我把每个父任务下的 children
递归渲染成嵌套的 <ul>
:
CSS 方面,我给 .sub-task-list
加了左侧缩进和更淡的背景色,让子任务层次分明且不抢眼。
8.4 子任务增删改
用户在父任务上点击“添加子任务”按钮,就会弹出一个小输入行(其实就是克隆了一份 .input-section
,然后插到父元素里)。监听输入后的添加事件时,我用 findTaskById
这样的实用函数先找到对应父任务对象,再往它的 children
里 push
新任务,最后 saveAndRender()
。
tip:
findTaskById
可以用递归搞定:
当时我写递归的时候没考虑好终止条件,导致栈溢出,后来给 find
加了 list = tasks
默认参数,并在找到了就立刻 return
,问题就解决了。
九、接入简单后端:用户登录与多设备同步
到这里,这个前端小应用基本够日常用了。但如果用户在不同电脑或手机上访问,就无法同步他们的任务。我想:要不我们给它加个登录?于是我用最简单的 Node.js + Express 搭了一个后端,负责用户注册、登录,以及存储每个用户的任务数据。
9.1 后端架构概览
我画了最后一张架构图,把前后端以及数据库的关系都标注出来:
9.2 用户登录流程
- 注册与登录
- 我在后端用 bcrypt 对密码做了哈希,再存到 MongoDB。
- 登录时,校验密码无误,就发一个签名了用户 ID 的 JWT。
- 前端存储 Token
- 登录成功后,前端把 JWT 存到
localStorage
,然后每次请求都带上Authorization
头。 - 如果检测到无 Token 或者请求被返回 401,就自动跳转到登录页。
- 同步任务数据
- 页面加载时,前端先看本地有没有
todoData
,如果有,询问用户“要不要合并本地待办和云端待办?”,给个“合并/覆盖”选项; - 最后,确定后把合并或覆盖后的数组
POST
给后端存储,并从后端拉取最新数据,同步覆盖本地,再渲染。
9.3 CORS 与安全
因为前后端分离,域名不在一个域下,我在 Express 里加了 cors()
中间件,只允许我自己开发时的 http://localhost:3000
。同时,所有需要鉴权的接口都加了一个 JWT 验证中间件,确保请求头里的 Token 合法且未过期。