有时候,一个想法的起点只是工作间隙里的一句话:

“最近太忙了,写的任务都乱糟糟的。”

我一边咕哝着,一边看着桌面上贴满便利贴的显示器。身为一名前端工程师,我对这种“混乱美学”实在无福消受,于是下定决心——我要自己做一个精致的、够现代的、功能完善的 Todo List。

一、为什么我不用现成的 Todo 应用?

市面上优秀的任务管理工具一抓一大把,像 Notion、TickTick、Todoist……可问题是,我不想被「工具」限制我的使用习惯,我想要一个纯粹的、小巧的、可自己掌控的任务清单工具。再说了,谁说练手项目就得丑陋、粗糙?我偏要把这个小项目打磨得像作品一样。

于是,这就成了一个目标明确的练手项目:

  • 不用任何前端框架,纯粹原生 HTML + CSS + JS
  • 支持任务添加、删除、完成标记
  • 任务本地持久化保存
  • 根据优先级渲染不同颜色
  • UI 必须简洁美观,最好能接近产品级体验

二、项目设计:我如何构建这套 Todo 系统?

在正式开写代码之前,我拿出纸笔(其实是 Figma),梳理了一下整个应用的流程和逻辑。任务管理虽然是个简单的功能,但如果结构混乱,就很容易在后续扩展中“跪掉”。

于是我画出了第一个流程图:

写了一个 Todo_拖拽

这张图囊括了数据的流转路径,也为我后续编码提供了逻辑依据。

三、页面结构搭建:从零构建现代 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>
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.

整体布局简洁明了,页面加载时,任务列表根据 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;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.
  • 86.
  • 87.
  • 88.
  • 89.
  • 90.
  • 91.
  • 92.
  • 93.
  • 94.
  • 95.
  • 96.
  • 97.
  • 98.
  • 99.
  • 100.

在优先级的颜色区分上,我借用了 Material Color 的色彩理念:绿色代表低优先,橙黄代表中优先,红色代表高优先——一眼看过去就知道什么是紧急任务。

五、JavaScript 核心:从思路到落地

当我在写 HTML 和 CSS 时,虽然外观已经初具雏形,但功能才是这个小项目的灵魂:我们要实现添加、删除、标记完成、优先级渲染,以及最关键的数据持久化。为此,我先在代码中定义了一个 tasks 数组来承载当前所有的任务对象,每个对象包含 idcontentprioritycompleted 四个属性。

let tasks = [];
  • 1.

5.1 页面初始化:从 LocalStorage “复活”任务列表

打开页面的第一件事,就是检查浏览器 localStorage 里有没有我们的 “todoData”:

window.addEventListener('DOMContentLoaded', () => {
  const saved = localStorage.getItem('todoData');
  tasks = saved ? JSON.parse(saved) : [];
  renderTasks();
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

一开始,我忘记考虑当用户清空浏览器数据时 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);
  });
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.

这里有两点我想特别说明:

  1. 我把按钮的文字用符号来代替文字,不仅节省了空间,还兼顾了国际化——“✔” 和 “🗑” 是所有人都能直观理解的图标。
  2. 每次渲染前都先清空列表,避免了重复渲染问题,这是我最开始忘记做的,导致每次添加任务之后,列表会无限增长,后面我才想起来要先 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'; // 恢复默认优先级
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

这里我用了 Date.now() 作为 id,既能保证每个任务的唯一性,也给之后可能的排序、拖拽等扩展功能留下了线索。调试时我发现,若不对 contenttrim(),用户在输入空格时也会把一个“空白任务”加进来,非常糟糕,于是我加了这个小小的输入校验。

5.4 保存与更新:LocalStorage 的“魔法”

每次对 tasks 数组做了增删改,都要同步 localStorage,否则刷新后就没了。于是我把它封装成一个“小方法”:

function saveAndRender() {
  localStorage.setItem('todoData', JSON.stringify(tasks));
  renderTasks();
}
  • 1.
  • 2.
  • 3.
  • 4.

这样,在添加、删除、标记完成的回调中都只需调用这一行,就能保证数据与界面一致。最开始我没封装,结果到处写这两行,既臃肿又容易出错,后来想到 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();
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

注意这里我用了 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();
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

有趣的是,我一开始直接在数组上 splice,但后台调试工具里发现 tasks 数组没变,于是我意识到 filter 不会改变原数组,而是返回新数组,用它来替换 tasks 更直观、更可靠。


六、迭代优化:怎样让这个小工具更“高级”?

完成了上面基础功能后,我在自测时发现几个小痛点,于是进行了针对性的优化。

6.1 键盘回车添加:提升交互流畅度

不喜欢每次都去点按钮?那就让输入框支持回车触发添加。只要在 taskInput 上监听 keypress 事件即可:

document.getElementById('taskInput').addEventListener('keypress', (e) => {
  if (e.key === 'Enter') {
    document.getElementById('addTaskBtn').click();
  }
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

这样在输入完任务内容后,一敲回车就添加,使用起来更顺手了。

6.2 优先级默认色块:让用户更直观

原本优先级是下拉框,在视觉上有些平淡。我想,如果能在选项左侧加个小色块,在下拉列表里更直观就好了。纯 CSS 做不到,但 JS 可以轻松搞定:在渲染任务时,把 select 换成了自定义的下拉组件,用一段小 CSS 加上 JS 插入的内联样式就能呈现色块。

这里我就不贴完整代码,总结思路:监听 selectchange 事件,更新输入区左侧的小色块背景色;在渲染列表项时,把颜色信息写到列表元素的 style 属性上。这样,添加任务前,用户就能一眼判断任务的优先级颜色。

七、拖拽排序:让任务随心所欲地排列

7.1 为什么要拖拽?

最早我用按钮或上下箭头来调整任务顺序,体验很糟糕:不仅要精确点击一个又小又密的按钮,而且一旦任务多了,找来找去很麻烦。直到有一天我想:既然浏览器支持拖拽 API,何不让用户直接按住某个任务,拖到想要的位置?

7.2 设计拖拽流程

在正式动手代码前,我又画了一张流程图,帮我理清拖拽的各个事件如何协作:

写了一个 Todo_数组_02

这张图明确了三个关键事件:dragstartdragoverdrop,它们分别是拖拽开始、拖拽过程、释放放下。

7.3 代码落地:一点点调试到流畅体验

  1. 给列表项添加可拖拽属性 我在渲染函数里把 lidraggable 属性打开,并绑定 dragstart 事件:
li.draggable = true;
li.addEventListener('dragstart', e => {
  e.dataTransfer.setData('text/plain', task.id);
  e.dataTransfer.effectAllowed = 'move';
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  1. 允许目标任务接收释放dragover 事件里必须 preventDefault(),否则释放时无效:
li.addEventListener('dragover', e => {
  e.preventDefault();
  li.classList.add('drag-over');
});
li.addEventListener('dragleave', () => {
  li.classList.remove('drag-over');
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  1. 处理放下逻辑 当用户松开鼠标时,获取拖拽源 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();
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  1. 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;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

我还在 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: [] },
    // …
  ]
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

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));
  });
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.

CSS 方面,我给 .sub-task-list 加了左侧缩进和更淡的背景色,让子任务层次分明且不抢眼。

8.4 子任务增删改

用户在父任务上点击“添加子任务”按钮,就会弹出一个小输入行(其实就是克隆了一份 .input-section,然后插到父元素里)。监听输入后的添加事件时,我用 findTaskById 这样的实用函数先找到对应父任务对象,再往它的 childrenpush 新任务,最后 saveAndRender()

tipfindTaskById 可以用递归搞定:

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;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

当时我写递归的时候没考虑好终止条件,导致栈溢出,后来给 find 加了 list = tasks 默认参数,并在找到了就立刻 return,问题就解决了。


写了一个 Todo_前端_03

九、接入简单后端:用户登录与多设备同步

到这里,这个前端小应用基本够日常用了。但如果用户在不同电脑或手机上访问,就无法同步他们的任务。我想:要不我们给它加个登录?于是我用最简单的 Node.js + Express 搭了一个后端,负责用户注册、登录,以及存储每个用户的任务数据。

9.1 后端架构概览

我画了最后一张架构图,把前后端以及数据库的关系都标注出来:

写了一个 Todo_拖拽_04

9.2 用户登录流程

  1. 注册与登录
  • 我在后端用 bcrypt 对密码做了哈希,再存到 MongoDB。
  • 登录时,校验密码无误,就发一个签名了用户 ID 的 JWT。
  1. 前端存储 Token
  • 登录成功后,前端把 JWT 存到 localStorage,然后每次请求都带上 Authorization 头。
  • 如果检测到无 Token 或者请求被返回 401,就自动跳转到登录页。
  1. 同步任务数据
  • 页面加载时,前端先看本地有没有 todoData,如果有,询问用户“要不要合并本地待办和云端待办?”,给个“合并/覆盖”选项;
  • 最后,确定后把合并或覆盖后的数组 POST 给后端存储,并从后端拉取最新数据,同步覆盖本地,再渲染。

9.3 CORS 与安全

因为前后端分离,域名不在一个域下,我在 Express 里加了 cors() 中间件,只允许我自己开发时的 http://localhost:3000。同时,所有需要鉴权的接口都加了一个 JWT 验证中间件,确保请求头里的 Token 合法且未过期。