待办清单 Web 应用
用原生 HTML/CSS/JS 写一个能增删改查、能记住你今天要做的事的小清单,是前端入门必做的第一个完整闭环项目。
用原生 JS 写一个能记住你今天要做的事的小清单
这是我把「学了 JS 但还没做过完整东西」的状态打通的第一个项目。一个输入框、一个列表、一个完成勾选、一个删除按钮,看似简单,但它把数据从用户输入到内存、再到屏幕、再到本地存储的完整链路都跑了一遍。做完之后,前端的基本心智模型就立起来了。
分五步把它做出来
不要一开始就想着「全部写完」,把它拆成五个能独立看到结果的小目标。
搭骨架与基础样式
先写一个居中的容器,里面放标题、输入框 + 添加按钮、空的列表 ul。CSS 用 Flexbox 把整体居中,给每条 li 一个圆角卡片样式、左侧一个 checkbox、右侧一个删除小图标。这一步不写任何 JS,先把「静止的样子」做到自己满意。
用数组渲染列表
声明一个 todos = [] 数组,写一个 render() 函数:先清空 ul,再遍历数组把每条 todo 拼成 li 字符串插进去。这一步数据写死,比如 [{text:'学完这个案例', done:false}],验证渲染逻辑能跑通再继续。
新增与删除
给添加按钮绑定点击事件,读输入框的值、trim 后判空,往数组 push 一个新对象,再调 render()。删除用事件委托:在 ul 上监听 click,通过 e.target.closest('.del') 拿到要删的那条,从数组里 splice 掉再渲染。
切换完成状态
点击 checkbox 时把对应 todo 的 done 取反。在 render 里给已完成的 li 加一个 completed class,CSS 用它做删除线 + 灰化。这一步让你第一次体会到「数据变 → 视图变」的响应式雏形。
持久化与过滤展示
每次 render 之后把数组 JSON.stringify 存进 localStorage,页面加载时先 JSON.parse 恢复。最后加三个过滤按钮「全部 / 未完成 / 已完成」,点击只是改变 render 的过滤条件,不删数据。到此一个完整闭环就跑通了。
关键代码片段
把渲染、新增、持久化三段最核心的逻辑拎出来,其他都是围绕它们的胶水代码。
// 1. 数据与渲染:单一数据源,渲染永远跟着数据走 let todos = JSON.parse(localStorage.getItem('todos')) || []; const listEl = document.querySelector('#todo-list'); function render(filter = 'all') { listEl.innerHTML = ''; const view = todos.filter(t => { if (filter === 'active') return !t.done; if (filter === 'done') return t.done; return true; }); view.forEach((t, i) => { listEl.insertAdjacentHTML('beforeend', ` <li class="${t.done ? 'completed' : ''}" data-id="${t.id}"> <label><input type="checkbox" ${t.done ? 'checked' : ''}> ${t.text}</label> <button class="del">✕</button> </li>`); }); localStorage.setItem('todos', JSON.stringify(todos)); } // 2. 新增 document.querySelector('#add-btn').addEventListener('click', () => { const input = document.querySelector('#todo-input'); const text = input.value.trim(); if (!text) return; todos.push({ id: Date.now(), text, done: false }); input.value = ''; render(); }); // 3. 事件委托:勾选 + 删除 listEl.addEventListener('click', (e) => { const li = e.target.closest('li'); const id = Number(li.dataset.id); if (e.target.matches('.del')) { todos = todos.filter(t => t.id !== id); } else if (e.target.matches('input[type=checkbox]')) { todos = todos.map(t => t.id === id ? { ...t, done: !t.done } : t); } render(); }); render(); // 首次渲染
做完这个项目最大的收获不是写出了一个能用的 Todo,而是第一次真正理解了「数据驱动视图」:用户操作的本质是改数据,渲染只是一个把数据翻译成 DOM 的函数。后面学 Vue、React 时,会发现它们的响应式不过是把这个 render() 调用自动化了而已。