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