资讯动态

原生JavaScript手写TodoMVC:从数据状态到DOM操作的完整实战

发布时间:2026/10/18 16:20:17 来源:尧图企业网站定制
TodoMVC 这个名字前端圈但凡待过一阵的基本都听过。它不是一个产品而是一套统一的待办事项示例规范同样的功能用各个框架各实现一遍然后摆在一起对比学习。我当年刷完各种框架的官网 Demo 之后反而在纯 JavaScript 版本上花的时间最多原因很简单框架隐藏了太多细节而原生版本把“数据、状态、DOM、事件”这几者之间的关系暴露得清清楚楚。这篇文章就用纯原生 JavaScript 完整实现一遍 TodoMVC覆盖添加、编辑、删除、筛选、持久化这些核心环节每一步代码都给到可直接运行的程度最后附 Vue 对比版本。适合刚学完 JS 基础、想系统做一次综合练习的同学也适合想弄清楚“框架到底替我们做了什么事”的前端开发者。1. 项目拆解TodoMVC 到底在练什么1.1 为什么每次学前端都绕不开它我见过不少同学HTML CSS 学完、JavaScript 基础语法学完然后突然就不知道下一步该做什么了。写个轮播图吧有点简单写个完整项目吧又不知道怎么起步。TodoMVC 恰好卡在中间——它足够小一个下午能写完又足够完整把一个前端应用会遇到的常规问题几乎都覆盖到了。拆开看它至少包含这几类典型场景表单输入与提交新待办的输入、回车添加、列表渲染与条件渲染根据数据渲染列表根据完成状态切换样式、单项操作与批量操作删除单个、清空已完成、全选/全不选、视图筛选全部/进行中/已完成三种状态切换、数据持久化刷新页面之后数据还在、动态样式切换编辑态、完成态的视觉区分。这些场景今天你在任何一个管理后台里都能遇到。所以 TodoMVC 不是一个玩具它是一个浓缩版的前端基础训练场。而且 TodoMVC 还有一个额外的好处它有统一的视觉规范和交互约定。也就是说你用原生 JS 写完一版再去看 Vue 版、React 版、Angular 版界面是一模一样的不同的只是代码组织方式。这种“同一道题不同解法”的对比是学习框架思维最直观的素材。1.2 完整功能清单动手之前先对齐需求。我做项目的习惯是不管多小的项目先把功能清单列出来避免写着写着就跑偏。这次 TodoMVC 的功能点如下表所示功能模块具体行为添加待办输入框内回车创建一条未完成待办空内容不创建列表展示展示所有待办已完成的带删除线样式单项删除鼠标移入待办行显示删除按钮点击删除该条编辑待办双击待办文字进入编辑态修改后可回车保存、失焦保存取消编辑编辑状态按 Esc 取消修改恢复原内容标记完成点击复选框切换完成/未完成状态批量操作点击底部全选按钮一键全部完成或全部恢复视图筛选底部切换“全部 / 进行中 / 已完成”列表只显示对应条目清除已完成点击“清除已完成”按钮批量删除已完成条目剩余计数底部实时显示未完成条目的数量数据持久化数据存入 localStorage刷新页面后自动恢复空内容防御输入全空格时视为无效不产生待办总共 12 个功能点说多不多说少不少。真正写起来你会发现每个功能点背后都牵扯到“数据放哪里、UI 怎么变、边界情况怎么处理”这三个问题。1.3 技术选型为什么刻意不用框架有人可能会说现在谁还用原生 JavaScript 写页面框架不香吗我的观点是框架香但前提是你得先用原生把底层逻辑走一遍。TodoMVC 用框架写非常舒服但这恰恰是问题——框架会把“数据和视图如何同步”这件事隐藏起来你调用了todos.push()页面就变了中间发生了什么初学者是没有体感的。换到原生 JavaScript每次数据变化视图都需要你手动更新。你被迫思考数据存在哪里、变更之后怎么让 DOM 反映最新状态、事件怎么绑定到动态生成的元素上。这些思考恰恰是前端最重要的基本功。而且当你真正经历过原生方案里那些繁琐的 DOM 操作之后再去看 Vue 的“数据驱动”你会发自内心地觉得声明式真方便而不是一上来就感觉像魔法。这也是我为什么坚持在本文里先花大篇幅写原生实现再拿 Vue 做对比的原因。2. 页面骨架与样式先把“房子”搭起来2.1 HTML 结构设计TodoMVC 有一个约定俗成的类名规范无论你用 React、Vue 还是原生 JS页面结构基本保持一致外层.todoapp容器里面拆成头部输入区、中间列表区、底部操作区三块。section classtodoapp header classheader h1todos/h1 input classnew-todo placeholder接下来要做什么 autocompleteoff / /header section classmain input idtoggle-all classtoggle-all typecheckbox / label fortoggle-all全部完成/label ul classtodo-list!-- 待办列表动态渲染 --/ul /section footer classfooter span classtodo-countstrong0/strong 项待办/span ul classfilters lia href#/ classselected全部/a/li lia href#/active进行中/a/li lia href#/completed已完成/a/li /ul button classclear-completed清除已完成/button /footer /section注意几个细节.new-todo的autocompleteoff是为了避免浏览器弹出历史输入建议影响交互。label fortoggle-all是为了让点击文字也能触发复选框这个对无障碍访问很重要。底部筛选链接用的是href#/这样的哈希地址后面配合hashchange事件做视图切换。这个结构里ul.todo-list是空的待办列表完全由 JavaScript 动态生成。这是原生实现的核心思路HTML 只是一个空壳真正的内容由数据和渲染函数决定。2.2 样式方案与视觉细节完整的 TodoMVC 官方样式比较长我在实战里通常保留核心规则去掉花哨的部分。主要关注三个关键交互第一完成态样式。.todo-list li.completed label需要加删除线并置灰。第二编辑态切换。默认.edit输入框隐藏、.view显示当li加上editing类时反过来。第三全选按钮的样式。原生 checkbox 比较丑我用appearance: none重新画一个箭头样式的指示器。/* 基础布局 */ .todoapp { max-width: 550px; margin: 60px auto 0; background: #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .header h1 { font-size: 72px; font-weight: 200; text-align: center; color: rgba(175, 47, 47, 0.6); margin: 20px 0; } .new-todo { width: 100%; padding: 16px; font-size: 20px; border: none; border-bottom: 1px solid #eee; box-sizing: border-box; } /* 列表行 */ .todo-list li { display: flex; align-items: center; padding: 12px 16px; border-bottom: 1px solid #f0f0f0; position: relative; } .todo-list li.completed label { text-decoration: line-through; color: #aaa; } .todo-list label { flex: 1; word-break: break-word; cursor: pointer; } .destroy { display: none; background: none; border: none; color: #cc9a9a; font-size: 24px; cursor: pointer; } .todo-list li:hover .destroy { display: block; } /* 编辑态切换 */ .todo-list .edit { display: none; flex: 1; padding: 6px 10px; font-size: 18px; } .todo-list li.editing .view { display: none; } .todo-list li.editing .edit { display: block; }样式这块我没有过分追求像素级还原够用就行。实际开发中样式调优往往是最后才做的事先把结构和逻辑跑通再回头打磨视觉效率会高很多。3. 原生 JavaScript 核心实现3.1 状态管理一个数组就够了TodoMVC 的数据模型很简单一个待办数组每个待办有三个字段id唯一标识、title内容、completed是否完成。再加上一个当前筛选状态的filter字段。我把它们集中放在一个state对象里。const state { todos: [], // [{ id: string, title: string, completed: boolean }] filter: all // all | active | completed };这里有一个重要的设计决策id 不能用数组下标。因为删除和排序之后下标会变编辑时靠下标找数据很容易出错。我用时间戳加随机字符串的方式生成 ID保证同一时刻不会重复。function generateId() { return Date.now().toString(36) Math.random().toString(36).slice(2, 8); }为什么不直接用Math.random()因为浏览器环境里随机数存在碰撞可能加上时间戳前缀能大幅降低冲突概率。这个方案不是绝对唯一但在这个场景下完全够用。3.2 渲染机制数据到视图的单向流程原生 JavaScript 没有“响应式”能力所以我的策略很朴素状态一变就把整个列表重新渲染一遍。也就是“数据 - 模板字符串 - innerHTML”的单向流程。function getFilteredTodos() { switch (state.filter) { case active: return state.todos.filter(t !t.completed); case completed: return state.todos.filter(t t.completed); default: return state.todos; } } function escapeHtml(str) { const div document.createElement(div); div.textContent str; return div.innerHTML; } function render() { const listEl document.querySelector(.todo-list); const filteredTodos getFilteredTodos(); listEl.innerHTML filteredTodos.map(todo li>document.querySelector(.todo-list).addEventListener(click, (e) { const li e.target.closest(li); if (!li) return; const id li.dataset.id; if (!id) return; if (e.target.classList.contains(toggle)) { toggleTodo(id); } else if (e.target.classList.contains(destroy)) { removeTodo(id); } });e.target.closest(li)的作用是即使点到了 checkbox 内部或者按钮内部也能向上找到所属的列表项。>document.querySelector(.new-todo).addEventListener(keydown, (e) { if (e.key ! Enter) return; const title e.target.value.trim(); if (!title) return; state.todos.push({ id: generateId(), title, completed: false }); e.target.value ; render(); });删除待办。根据 id 过滤数组重新赋值。在 JavaScript 里filter不会修改原数组所以state.todos需要重新指向过滤后的新数组。function removeTodo(id) { state.todos state.todos.filter(t t.id ! id); render(); }标记完成。修改对应条目的completed字段然后重渲染。这里注意复选框的状态完全由数据决定渲染时根据todo.completed写入checked属性所以只需要改数据再调render()就能保证 UI 同步。function toggleTodo(id) { const todo state.todos.find(t t.id id); if (todo) { todo.completed !todo.completed; } render(); }编辑待办。这部分细节最多我单独拆开讲。编辑流程分两步进入编辑态和保存结果。双击 label 进入编辑态这时给 li 加上editing类把编辑框的值填充为当前标题并聚焦。document.querySelector(.todo-list).addEventListener(dblclick, (e) { const li e.target.closest(li); if (!li) return; const id li.dataset.id; const todo state.todos.find(t t.id id); if (!todo) return; li.classList.add(editing); const editInput li.querySelector(.edit); editInput.value todo.title; editInput.focus(); });进入编辑态后需要监听两个事件回车保存和失焦保存。这里有个经典的坑用户按回车保存时输入框会失焦于是 blur 事件也会触发导致保存逻辑执行两次。解决办法是在commitEdit函数入口判断 li 是否还处于编辑态执行完先移除editing类后面进来的 blur 回调发现类没了直接返回。function commitEdit(li) { if (!li.classList.contains(editing)) return; const id li.dataset.id; const editInput li.querySelector(.edit); const newTitle editInput.value.trim(); if (newTitle) { const todo state.todos.find(t t.id id); if (todo) { todo.title newTitle; } } else { removeTodo(id); } li.classList.remove(editing); render(); } document.querySelector(.todo-list).addEventListener(keydown, (e) { if (e.key Enter) { const li e.target.closest(li); if (li li.classList.contains(editing)) { commitEdit(li); } } else if (e.key Escape) { const li e.target.closest(li); if (li li.classList.contains(editing)) { const id li.dataset.id; const todo state.todos.find(t t.id id); if (todo) { li.querySelector(.edit).value todo.title; } li.classList.remove(editing); } } }); document.querySelector(.todo-list).addEventListener(focusout, (e) { if (!e.target.classList.contains(edit)) return; const li e.target.closest(li); if (li) { commitEdit(li); } });还有一个边界情况编辑时把内容删空了再保存语义上应该删除这条待办。上面的commitEdit里newTitle为空时调用removeTodo(id)正好覆盖了这个场景。批量操作。全选复选框改变时把所有待办的completed统一设成复选框状态。渲染时需要同步复选框的显示状态如果所有待办都完成了就勾上否则不勾。document.querySelector(.toggle-all).addEventListener(change, (e) { const checked e.target.checked; state.todos.forEach(todo todo.completed checked); render(); });清除已完成。过滤出未完成的数组替换原数组。document.querySelector(.clear-completed).addEventListener(click, () { state.todos state.todos.filter(t !t.completed); render(); });到这里核心功能都已经实现。你会发现所有操作的模式完全一致修改数据 - 调用 render() - UI 自动同步。这个模式虽然简单但它是所有前端状态管理的雏形。3.4 筛选视图与 hashchange筛选功能我用了 URL 哈希来管理#/全部、#/active进行中、#/completed已完成。这样做的优点是支持浏览器前进后退也可以直接复制链接分享当前视图。function parseHash() { const hash location.hash.replace(#/, ) || all; return [all, active, completed].includes(hash) ? hash : all; } window.addEventListener(hashchange, () { state.filter parseHash(); render(); }); // 初始化时读取一次 state.filter parseHash();渲染函数里根据state.filter调用getFilteredTodos()决定显示哪些条目。同时要更新底部筛选链接的选中态function updateFilterLinks() { document.querySelectorAll(.filters a).forEach(link { const href link.getAttribute(href); const target href #/ ? all : href.replace(#/, ); link.classList.toggle(selected, target state.filter); }); }3.5 数据持久化localStorage 使用细节localStorage 只能存字符串所以保存时要序列化读取时要反序列化。这中间有几个细节必须注意。第一读取时一定要 try/catch。localStorage 里的数据可能被用户手动改坏也可能是上一个版本的格式更可能是不小心存了个空字符串。不处理的话整个应用直接白屏。第二要做数据校验。解析出来的结果必须是一个数组数组里的每一项要满足基本结构。不满足的就过滤掉保证后续代码不会在todo.completed为 undefined 时出问题。const STORAGE_KEY todos-native-js; function save() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.todos)); } function load() { const raw localStorage.getItem(STORAGE_KEY); if (!raw) return; try { const data JSON.parse(raw); if (Array.isArray(data)) { state.todos data.filter(item item typeof item.title string typeof item.completed boolean ); } } catch (e) { console.warn(本地数据解析失败已重置, e); state.todos []; } }存储键名我用了todos-native-js这种带项目标识的命名避免和其他项目冲突。Vue 版本我会用todos-vue这样两个版本同时跑也不会互相覆盖数据。4. Vue 对比版本同样的功能另一种思路4.1 Vue 版本核心代码这里用 Vue 3 的组合式 API 来写通过 CDN 引入全局构建版本方便你直接在 HTML 里跑。先看模板部分section idapp classtodoapp header classheader h1todos/h1 input classnew-todo v-modelnewTodo keyup.enteraddTodo placeholder接下来要做什么 / /header section classmain v-showtodos.length input idtoggle-all classtoggle-all typecheckbox v-modelallDone / label fortoggle-all全部完成/label ul classtodo-list li v-fortodo in filteredTodos :keytodo.id :class{ completed: todo.completed, editing: editingId todo.id } div classview input classtoggle typecheckbox v-modeltodo.completed / label dblclickstartEdit(todo){{ todo.title }}/label button classdestroy clickremoveTodo(todo)/button /div input classedit v-modeleditText keyup.enterfinishEdit(todo) keyup.escapecancelEdit(todo) blurfinishEdit(todo) v-focuseditingId todo.id / /li /ul /section footer classfooter v-showtodos.length span classtodo-countstrong{{ remaining }}/strong 项待办/span ul classfilters lia href#/all :class{ selected: filter all } clickfilter all全部/a/li lia href#/active :class{ selected: filter active } clickfilter active进行中/a/li lia href#/completed :class{ selected: filter completed } clickfilter completed已完成/a/li /ul button classclear-completed v-showcompletedCount 0 clickclearCompleted清除已完成/button /footer /section再看脚本部分const { createApp, ref, computed, watch } Vue; const app createApp({ setup() { const newTodo ref(); const todos ref([]); const filter ref(all); const editingId ref(null); const editText ref(); const filteredTodos computed(() { if (filter.value active) { return todos.value.filter(t !t.completed); } if (filter.value completed) { return todos.value.filter(t t.completed); } return todos.value; }); const remaining computed(() todos.value.filter(t !t.completed).length); const completedCount computed(() todos.value.length - remaining.value); const allDone computed({ get() { return todos.value.length 0 todos.value.every(t t.completed); }, set(value) { todos.value.forEach(t t.completed value); } }); function addTodo() { const title newTodo.value.trim(); if (!title) return; todos.value.push({ id: Date.now().toString(36) Math.random().toString(36).slice(2, 6), title, completed: false }); newTodo.value ; } function removeTodo(todo) { todos.value todos.value.filter(t t ! todo); } function startEdit(todo) { editingId.value todo.id; editText.value todo.title; } function finishEdit(todo) { const title editText.value.trim(); if (title) { todo.title title; } else { removeTodo(todo); } editingId.value null; } function cancelEdit(todo) { editText.value todo.title; editingId.value null; } function clearCompleted() { todos.value todos.value.filter(t !t.completed); } watch(todos, (newVal) { localStorage.setItem(todos-vue, JSON.stringify(newVal)); }, { deep: true }); const saved localStorage.getItem(todos-vue); if (saved) { try { todos.value JSON.parse(saved); } catch (e) { console.warn(恢复失败, e); } } return { newTodo, todos, filter, editingId, editText, filteredTodos, remaining, completedCount, allDone, addTodo, removeTodo, startEdit, finishEdit, cancelEdit, clearCompleted }; } }); app.directive(focus, { mounted(el, binding) { if (binding.value) el.focus(); }, updated(el, binding) { if (binding.value) el.focus(); } }); app.mount(#app);这段代码有几个值得注意的设计。v-model是双向绑定。原生版本里输入框的值变化要靠keydown事件手动读取e.target.value然后手动存到变量里Vue 里直接v-modelnewTodo就完成了“输入框值 变量”的同步。keyup.enteraddTodo替代了原生代码里if (e.key ! Enter) return的判断。computed是派生数据。filteredTodos根据filter和todos自动计算filter变了它自动更新不需要像原生版那样在render()里调用getFilteredTodos()。remaining、completedCount同理。watch监听数据变化做持久化。todos一变化自动写入 localStorage不需要每次操作后手动调用save()。deep: true是因为数组内部的对象的completed字段变化也需要触发监听。v-focus是自定义指令。进入编辑态时editingId todo.id变成 true指令的updated钩子触发自动聚焦编辑框。这个功能在原生版里需要手动写editInput.focus()在 Vue 里变成了一个声明式的指令。4.2 命令式 vs 声明式本质认知差异把两个版本摆在一起差别就非常明显了。对比维度原生 JS 版Vue 版视图更新手动调用 render() 重绘改数据后自动重新渲染状态修改改 state 后必须手动同步 UI修改 ref 数据即完成DOM 操作事件委托、innerHTML 全在自己手里通过指令和模板声明框架内部处理事件绑定手动监听 DOM 事件判断 target模板里 click / keyup 声明数据持久化每次操作后手动 save()watch 自动检测并保存代码组织全局函数 状态对象按 setup 内逻辑分组学习成本需要理解 DOM 机制需要理解响应式原理原生版从头到尾都是“命令式”你去告诉浏览器每一步要做什么先查 DOM再改属性再更新样式。Vue 版是“声明式”你只描述“数据是什么样”剩下怎么更新 DOM 是框架的事。这里我想说一个自己的体会声明式并不是“不用理解 DOM”而是把 DOM 操作封装成了一套可预测的机制。如果你在原生版里能想明白“数据变了哪些 UI 要跟着变”学 Vue 的响应式原理就会非常轻松。反过来说一上来就学 Vue、没写过原生的人碰到性能问题、动态渲染 bug 时常常会一脸茫然因为根本不知道框架在背后做了什么。4.3 什么时候选原生、什么时候选框架写代码这么多年我的选择标准很简单。纯练习项目尤其是练基本功的时候用原生。你现在每一步都知道浏览器发生什么这个积累是框架给不了的。快速迭代的产品页面用框架。Vue 的数据驱动和组件化能省掉大量样板代码一个中后台页面同样的功能Vue 版本比原生版本大概少写三分之一到一半的代码量。性能敏感的场景再考虑原生。比如某些嵌入式平台、低端移动设备几千行的框架 runtime 本身就是负担。但说实话大部分业务场景的性能瓶颈根本不在框架本身。5. 常见问题与踩坑实录5.1 动态添加的元素事件不生效新手最容易踩的坑直接在 li 上绑定 click 事件然后新增的 li 自然没有绑过事件于是发现“新添加的待办点删除没反应”。解决方式就是事件委托把监听挂到稳定的 ul 父元素上通过e.target判断。这是原生 DOM 编程里最重要的思维之一面向稳定的容器监听而不是面向动态变化的子元素监听。5.2 编辑框失焦与回车冲突回车保存和失焦保存同时存在时会触发两次保存。我之前花了很长时间调这个问题最后发现关键是“编辑态标记”。commitEdit函数入口判断 li 是否还带着editing类第一次保存执行完就移除这个类第二次进来直接 return。记住事件处理函数要设计成可重入安全的尤其是在多个事件可能触达同一逻辑的时候。注意处理这类“多入口触发同一操作”的场景最稳妥的方法是给操作加一个状态守卫而不是靠事件触发顺序。事件触发顺序在不同浏览器里可能有差异但状态判断是绝对可靠的。5.3 localStorage 数据格式不合法localStorage 是公共空间用户清缓存、开隐身模式、其他脚本误写入都会导致读出来的数据不是预期格式。我的建议是永远假设读出来的数据是坏的。解析必须包 try/catch解析完必须校验结构校验不过就重置为空数组。不要觉得这是小题大做数据恢复失败导致白屏是这个项目最常见的 bug 之一。5.4 innerHTML 全量重绘的体验问题全量重绘有两个体验问题。一是编辑中的输入框可能会被重绘导致焦点丢失。我在 3.3 节里通过只在commitEdit之后才调render()规避了这个问题但如果你在编辑过程中因为其他操作触发了渲染焦点就会崩。二是列表项很多时会有明显的闪烁感。对于 TodoMVC 这种小数据量场景全量重绘没问题如果条目成百上千就应该考虑只更新变化的那一行或者用 DocumentFragment 批量更新。另外要提一下escapeHtml不是万能的。它只能防“内容插入 HTML 时”的 XSS如果将来你改用innerText或textContent赋值就不需要这个函数了。但用模板字符串拼 HTML 时这一步绝对不能省。6. 项目之外的思考下一步可以练什么写完了 TodoMVC我觉得你可以把这个项目继续往前推一步。我列几个方向按难度排序给 TodoMVC 加“撤销”功能。操作历史用数组存储撤销就是 pop 出上一个状态。这能让你更深入理解“状态快照”和“不可变数据”。支持拖拽排序。HTML5 拖放事件或者 Pointer Events 都行能练到原生拖拽 API。排序后的位置要持久化到 localStorage这就需要在数据模型里加一个order字段。把数据导出成 JSON 文件再从文件导入。练习 File API 和 Blob做一个“导出/导入”按钮。用 TypeScript 重写一遍。给Todo定义接口给state定义类型你会发现类型不对时编辑器会直接提醒减少很多低级 bug。换一种本地存储方案比如 IndexedDB。localStorage 有 5MB 上限IndexedDB 更大而且还支持索引查询和事务。这些方向每一个都能单独写一篇教程。但前提是你先把原生版 TodoMVC 彻底吃透数据、渲染、事件、持久化这条主线捋顺了再往哪个方向扩展都不慌。最后再分享一个我自己的体会。写完这个项目再回过去看框架文档我最大的感受是框架并没有降低前端开发的难度它只是把一部分重复劳动自动化了。真正决定项目质量的还是你对数据结构、事件机制、渲染流程这些基础概念的理解深度。如果你现在正卡在“基础学完了不知道干嘛”的阶段把 TodoMVC 用原生 JS 完整写一遍绝对值得。写完之后再对照 Vue 版本看一遍你会发现自己对“数据驱动视图”这句话有了完全不一样的理解。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑