资讯动态

原生JS手写TodoMVC:从零实现到Vue对比,彻底搞懂数据驱动与DOM操作

发布时间:2026/10/20 4:36:21 来源:尧图企业网站定制
最近有位刚学完前端三件套的朋友问我“语法我都会了跟着教程敲也敲得出来可让我自己从一个空白文件写点东西完全不知道第一行代码该落在哪里。”这个困惑我太熟悉了。教程里的代码是别人设计好的跟着敲一遍只是输入不是输出。而TodoMVC恰好是翻过这堵墙最合适的训练场它功能不多不少既覆盖了前端日常开发里几乎所有高频操作又不需要后端配合、不需要脚手架、不需要安装任何依赖。这篇文章就用纯原生JavaScript把一个完整可用的TodoMVC从零到一实现一遍每个模块都附上可直接复制运行的代码和设计理由最后再放一份Vue实现同款需求的对比版本。不管你是刚学完基础想做第一个独立项目的新手还是已经在用框架、想回头把原生DOM操作和事件机制补扎实的同学这篇都值得你实际敲一遍。因为面试里“v-for底层到底怎么更新列表”“响应式是怎么通知视图的”这类追问答案其实都藏在这个小项目的原生版本里。1. 为什么拿TodoMVC练手界面不大技能点却覆盖了日常开发的大头1.1 一个“玩具项目”能练到的核心能力TodoMVC最初是一个用来比较前端框架的示例项目需求被刻意控制得足够小但它在任务管理这个业务域里塞进了几乎所有常见的交互形态新增一条数据、删除一条数据、修改一条数据、切换单条状态、批量清除数据、按条件过滤列表、本地持久化存储、输入框焦点管理。这些动作放到真实业务里就是复杂的订单管理、商品列表、审批流。你从TodoMVC里建立的数据驱动视图的思维可以直接平移过去。我经常跟朋友说不要小看这种“玩具项目”。很多人学完基础知识后直接挑战中后台项目结果被弹窗组件、路由鉴权、请求封装这些东西淹没最后项目烂尾。TodoMVC的体量刚好卡在“跳一跳够得着”的位置能在两三天内把完整实现跑通建立正反馈。1.2 动手前先列交互清单这是很多人跳过的关键一步写代码前先不碰代码。把TodoMVC标准里要求的功能拆一遍你会得到下面这份清单输入框添加新待办空内容不产生新数据列表展示所有待办每条可标记完成/未完成单条删除按钮移除指定待办双击条目进入编辑态可修改文字、回车保存、失焦保存、ESC取消底部展示剩余未完成数量三个筛选视图全部 / 未完成 / 已完成清除已完成按钮只清掉勾选的项数据刷新页面后不丢失把需求列出来之后你会发现项目结构已经清晰了一半。后面所有代码都围绕这张表展开不会出现写着写着突然不知道怎么组织的情况。这条经验迁移到真实业务里同样成立需求清单没理清之前写的代码大概率要重构。1.3 技术选型原生JS到底在练什么我故意没有选用任何框架和构建工具连npm init都不做一个HTML文件、一个CSS文件、一个JS文件浏览器直接打开就能跑。这样选择的原因很直接当你还没有用过框架时先用原生方式把DOM操作、事件绑定、状态持久化这条链路走一遍你才会真正理解框架替你做了什么。比如Vue的响应式系统帮你省掉了手动同步数据的代码但你得先体会过“数据变了要自己调用render函数重新生成DOM”这件事才能在面试里解释清楚“为什么框架说数据变了视图会自动更新”。后文的所有代码都是围绕以下三个文件组织的todomvc-native/ ├── index.html # 页面结构与样式挂载点 ├── app.js # 数据、渲染、事件、路由的全部逻辑 └── style.css # 视觉样式接下来从架构设计开始讲。这是整篇里最值得反复看的部分。2. 原生JS版的架构数据、视图、事件三层解耦2.1 数据层一个state对象管住所有状态不管什么框架前端项目骨架本质上都是“数据层、视图层、事件层”的三角关系。原生写法最大的挑战是这三层没有框架帮你串很容易一把梭写成一坨乱麻。我的做法是先从数据层入手。// 全局唯一的数据源 const state { todos: loadFromStorage(), filter: all // all | active | completed };state.todos是待办数组每一条的结构固定为{ id: Date.now(), title: 学习原生JavaScript, completed: false }id用来定位单条数据新增时直接用Date.now()能保证在同一毫秒内只有一个数据对TodoMVC这个规模完全够用。completed是布尔值表示完成状态。filter是当前选中的筛选视图它不属于任何一条待办而是属于“视图状态”也放进全局状态里方便统一管理。持久化用localStorage但我不直接在业务代码里到处写localStorage.setItem而是封装成一个小组件const STORAGE_KEY todomvc-native-js; function loadFromStorage() { const raw localStorage.getItem(STORAGE_KEY); if (!raw) return []; try { return JSON.parse(raw); } catch (e) { return []; } } function saveToStorage(todos) { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); }封装的理由很简单以后如果想把存储换成IndexedDB或者需要兼容旧版数据做迁移只需要改这一个文件。除此之外在所有增删改操作完成后调用saveToStorage(state.todos)数据层就算完整了。2.2 视图层一个render函数把当前状态映射成界面原生写法里没有“模板自动更新”所以视图层我用一个核心函数集中处理读取当前状态生成列表对应的HTML字符串然后填充到页面里。function render() { const todoList document.getElementById(todo-list); const filteredTodos getVisibleTodos(); todoList.innerHTML filteredTodos.map(todo { return li>function getVisibleTodos() { if (state.filter active) { return state.todos.filter(todo !todo.completed); } if (state.filter completed) { return state.todos.filter(todo todo.completed); } return state.todos; }切记不要维护一份“当前显示的todos”作为独立数据。显示列表永远从state.todos派生出这是防止数据不一致的关键习惯。第三转义escapeHTML不能省略。用户输入的待办内容通过模板字符串拼进HTML时如果里面含有script或img onerror这类内容会被浏览器当成真实标签解析执行。这是非常基础但容易被忽略的安全问题。函数实现如下function escapeHTML(str) { return str.replace(/[]/g, char ({ : amp;, : lt;, : gt;, : quot;, : #39; }[char])); }2.3 事件层挂在父容器上的事件委托不用给每个按钮绑定监听状态变了要重新渲染整块列表这带来一个问题刷新HTML之后之前绑定在按钮上的事件监听器全没了。如果每次渲染后再逐个绑定代码会繁琐且易错。解决办法是事件委托。核心思路是与其给每个button、input绑定监听器不如把监听器挂在这些元素的公共祖先上利用事件冒泡机制统一处理。const app document.getElementById(todoapp); app.addEventListener(click, (event) { const li event.target.closest(li[data-id]); if (!li) return; const id Number(li.dataset.id); if (event.target.classList.contains(toggle)) { toggleTodo(id); } else if (event.target.classList.contains(destroy)) { removeTodo(id); } else if (event.target.tagName LABEL) { startEdit(id, li); } });这段代码是原生前端里非常典型的一例事件目标通过closest向上找li拿到>header classheader h1todos/h1 form idtodo-form input idnew-todo classnew-todo placeholder接下来要做什么 autocompleteoff /form /header对应的事件处理document.getElementById(todo-form).addEventListener(submit, (event) { event.preventDefault(); const input document.getElementById(new-todo); const title input.value.trim(); if (!title) return; state.todos.unshift({ id: Date.now(), title: title, completed: false }); input.value ; saveToStorage(state.todos); render(); });title必须经过trim()否则用户输入连续空格会被当成一条有效数据点“添加”后列表表现出“无内容但出现在列表里”的诡异问题。加完之后清空输入框、保存、重新渲染。这里用unshift把新待办放到数组头部对应TodoMVC惯例新任务展示在列表最上面符合大多数人的使用直觉。3.2 修改状态与删除data-id定位click委托统一处理上一节的click委托里已经把toggleTodo和removeTodo的入口切好了。底下补上操作函数实现function toggleTodo(id) { const todo state.todos.find(item item.id id); if (todo) { todo.completed !todo.completed; saveToStorage(state.todos); render(); } } function removeTodo(id) { state.todos state.todos.filter(todo todo.id ! id); saveToStorage(state.todos); render(); }这两个函数的逻辑非常直白修改数据持久化重新渲染。我在一开始学前端时总觉得做一步就“立刻手动操作DOM”才是最快的比如点击删除按钮时直接li.remove()。但那样写会带来一个隐患如果点一次删除后还要更新底部计数你得再去手动计算一次数量如果连续交互较多界面和数据源很容易出现不一致。数据驱动之后所有视图都从数据派生操作数据就完事。删除按钮只是传达“我想删掉id为某值的待办”剩下的交给状态和渲染逻辑统一处理。这个习惯越早建立越受益。3.3 双击编辑编辑态的进入、提交与取消双击标签进入编辑态涉及“当前编辑的是哪条数据”的临时状态。这个状态不值得放进localStorage我单独用一个模块级变量管理let editingId null;双击触发后执行function startEdit(id, li) { editingId id; render(); const editInput li.querySelector(.edit); editInput.focus(); editInput.setSelectionRange(editInput.value.length, editInput.value.length); }重新渲染是为了给那个li加上editing类名让CSS把待办切换成编辑框展示。setSelectionRange把光标移到文本末尾用户不用重新点一下位置体验更顺。render()里我已经预留了编辑状态判断需要把后文的HTML结构对齐一下todoList.innerHTML filteredTodos.map(todo { const editingClass editingId todo.id ? editing : ; return li>document.addEventListener(focusout, (event) { if (event.target.classList.contains(edit) editingId ! null) { commitEdit(Number(event.target.closest(li).dataset.id), event.target.value); } }); document.addEventListener(keydown, (event) { if (editingId null) return; if (event.key Escape) { editingId null; render(); } else if (event.key Enter) { const input document.querySelector(.edit); if (input) { commitEdit(Number(input.closest(li).dataset.id), input.value); } } });commitEdit在接受新值时要先做去空格校验为空直接删除这条待办否则更新标题。function commitEdit(id, value) { const title value.trim(); editingId null; if (!title) { removeTodo(id); return; } const todo state.todos.find(item item.id id); if (todo) { todo.title title; } saveToStorage(state.todos); render(); }这里的重点是“编辑态统一由editingId管理”。进入编辑态时设置它提交或取消时清空它。如果忽略这个锁按回车提交后失焦事件又会触发一次提交导致重复保存这个问题后面还会展开。3.4 过滤器与路由用hashchange切换三个视图底部三个筛选链接不做成按钮因为它们本质上是浏览器地址变化需要和“前进/后退”按钮联动。最省事的方式是用URL里的hash。HTML里写成footer classfooter span idtodo-count0 items left/span ul classfilters lia href#/allAll/a/li lia href#/activeActive/a/li lia href#/completedCompleted/a/li /ul button idclear-completedClear completed/button /footer然后监听hashchangewindow.addEventListener(hashchange, () { const hash location.hash.replace(#/, ); if ([all, active, completed].includes(hash)) { state.filter hash; } else { state.filter all; } render(); });白名单校验不能少。如果用户手滑把URL改成了#/foo直接返回全部视图而不要让程序崩溃在某个不存在的状态上。所有外部输入都要假设它是不可信的这条规则同样适用于URL。打开页面时浏览器默认不会触发hashchange所以初始化阶段还要根据当前hash设置一次过滤状态否则刷新后筛选状态会丢失function setDefaultFilterFromHash() { const hash location.hash.replace(#/, ); state.filter [all, active, completed].includes(hash) ? hash : all; }3.5 剩余计数与清除已完成用数据派生不要第二个状态源剩余数量在底部左侧显示标准格式是“N items left”。实现时不重新计数也不维护多余变量直接对现有数据做派生function updateRemainingCount() { const count state.todos.filter(todo !todo.completed).length; document.getElementById(todo-count).textContent ${count} ${count 1 ? item : items} left; }清除已完成按钮更简单document.getElementById(clear-completed).addEventListener(click, () { state.todos state.todos.filter(todo !todo.completed); saveToStorage(state.todos); render(); });到这里原生版本的核心功能已经全部完成。把这几个函数拼在一起就是一个可运行的TodoMVC。4. 同一份需求Vue怎么写代码量下降与思维转变4.1 Vue版核心代码速览标题里说“附Vue对比版本”那我直接展示用Vue实现同一需求的代码骨架。需要说明的是Vue版依赖运行时和编译器代码无法像原生版那样在浏览器直接双击HTML打开但它能把同样的需求在模板和脚本里表达得更集中。模板部分template section classtodoapp header classheader input v-model.trimnewTitle classnew-todo placeholder接下来要做什么 keyup.enteraddTodo / /header ul classtodo-list li v-fortodo in filteredTodos :keytodo.id :class{ completed: todo.completed } div classview input classtoggle typecheckbox v-modeltodo.completed / label{{ todo.title }}/label button classdestroy clickremoveTodo(todo.id)/button /div /li /ul footer classfooter span{{ remainingCount }} items left/span button clickclearCompletedClear completed/button /footer /section /template脚本部分import { ref, computed, watch } from vue; const todos ref(JSON.parse(localStorage.getItem(todos-vue)) || []); const newTitle ref(); const filter ref(all); const filteredTodos computed(() { if (filter.value active) return todos.value.filter(todo !todo.completed); if (filter.value completed) return todos.value.filter(todo todo.completed); return todos.value; }); const remainingCount computed(() todos.value.filter(todo !todo.completed).length); function addTodo() { const title newTitle.value.trim(); if (!title) return; todos.value.unshift({ id: Date.now(), title, completed: false }); newTitle.value ; } function removeTodo(id) { todos.value todos.value.filter(todo todo.id ! id); } function clearCompleted() { todos.value todos.value.filter(todo !todo.completed); } watch(todos, value localStorage.setItem(todos-vue, JSON.stringify(value)), { deep: true });篇幅上就很直观原生版里我手动维护editingId、手动控制focusout和keydown、手动在render里拼字符串Vue版里这些几乎不可见。双击编辑在Vue里可以通过更细化的组件写法实现上面这段为了对照清晰先不给全核心变量已经足够说明问题。4.2 一张表格看懂原生版和Vue版的对应关系下面这张表格是原生实现和Vue实现的关键对应面试被问到“框架到底解决了什么”时按这张表的逻辑回答基本不会跑偏。关注点原生JavaScript版本Vue版本数据定义全局对象state.todosref/reactive包裹的响应式数据状态修改手动调用saveToStorage和render()修改.value后自动触发组件更新列表渲染innerHTML map 模板字符串v-for配合:key的虚拟DOM diff事件绑定事件委托统一挂载在父容器模板里的click/keyup.enter过滤视图每次渲染前调用getVisibleTodos()computed派生数据自动追踪依赖持久化每次操作后手动写localStoragewatch深度监听数据变化自动写入DOM更新整块替换列表HTML运行时diff出最小更新路径4.3 命令式与声明式的根本差异原生版代码的核心动作是“告诉DOM要变成什么样”手动选取元素、修改属性、拼接字符串再替换内容。这就是命令式。Vue版代码的核心动作是“告诉我数据长什么样DOM长什么样由模板声明”你只需要在模板里写清楚“当数据是X时视图呈现Y”然后把数据改了剩下的事交给框架。这是声明式。用生活类比讲命令式像你搬家时指挥每一件家具放哪、怎么摆、怎么对齐声明式像你直接用设计图告诉搬家师傅“最后长这样”中间怎么搬运不用你操心。这个差异带来一个很重要的工程变化当组件规模变大时命令式代码的难点从“业务逻辑”转移到了“如何在合适时机准确操作DOM”声明式代码把这一大块复杂度收编进框架内部开发者需要维护的只是“数据-视图”的描述本身。4.4 那什么时候该用原生什么时候该上框架我的判断标准很简单如果项目里需要维护的“状态”超过一定复杂度、交互频繁且界面会有大量动态更新上框架的性价比很高如果只是一个展示型落地页、一个工具页、一个稳定不变的老功能模块原生JavaScript反而更轻不需要引入运行时依赖和构建链路。更要紧的是决定“用不用框架”之前最好都先理解原生实现。我见过一些只有框架经验的同学遇到线上浏览器兼容性问题时连最基础的“事件委托为什么能把子元素的事件交给父容器处理”都答不清楚最后只能靠框架社区给出的hack方案硬扛。底层逻辑补上以后再看框架文档里的高级选项理解速度会快得多。5. 跑通之后这些坑我替你踩过了防XSS、焦点跟手、数据兼容代码能跑只是第一步真实项目里要过的关通常都在这些边角。我把实践中反复碰到、也在社区里看过别人翻车的问题集中列在这里。5.1 不转义用户输入标题里的HTML会被当作标签执行我最初写第一版TodoMVC时也偷懒过直接${todo.title}拼进模板字符串输入一句img srcx onerroralert(1)再添加页面弹窗立刻弹出来。这个问题在真实项目里升级一下就是存储型XSS漏洞危害远不止一个弹窗。所以标题显示和编辑框回显都必须经过escapeHTML处理。后端的转义不能替代前端的转义因为你永远不该信任任何一步输入。5.2 回车保存和失焦保存撞车editingId是保险丝按我说的方法写完编辑功能后实际使用时会有个很常见的意外编辑状态下用户回车确认原本应该只提交一次结果因为失焦事件也被触发提交了两次第二次可能把已更新的标题又处理了一遍数据不乱才怪。解决办法就是全篇反复强调的editingId。commitEdit第一行先把editingId清空后续无论焦点事件再触发多少次进来时判断editingId ! null已经不成立直接return。用状态机管理“当前操作还未结束时”的边界比事后靠防抖和标志位去猜更可靠。5.3 blur不冒泡事件委托时你可能监听不到编辑框失焦时我要保存内容一开始挂在父容器的blur事件上结果完全不触发。原因在于原生blur不冒泡事件委托到祖先容器这层就断掉了。两种解法一是监听focusout事件它和blur非常像但会冒泡二是仍然用blur但监听时把第三个参数设为true在浏览器捕获阶段处理。我的代码里选了第一种更符合后面扩展的直觉。同一个细节在移动端虚拟键盘收起时也容易踩记住blur有冒泡兄弟focusout是真的能省半天时间。5.4 localStorage里全是旧数据字段缺失和版本迁移如果TodoMVC上线运行了一阵后来需求变了给每条待办加一个createdAt字段旧用户浏览器里存储的数据就缺这个字段。直接JSON.parse后渲染界面上没有任何时间信息但代码里访问todo.createdAt可能拿到undefined。稳妥做法是在读取时做一个轻量迁移function migrateTodo(todo) { return { createdAt: todo.createdAt || Date.now(), updatedAt: todo.updatedAt || Date.now(), ...todo }; }理论上更完整的方案是给存储数据加version字段按版本号依次迁移。对TodoMVC这个体量给每条数据补默认值就够用但“外部数据永远要校验和漂白”这个习惯要养成。5.5 保存和渲染触发频率变高时用批量渲染吃点性能红利原生版里每个操作几乎都是“改数据 保存 渲染”如果未来往这个项目里加更多状态操作函数多了之后有可能会在一个事件循环里连续调用多次render()造成无意义的重复DOM更新。这个版本里完全不需要优化但要提前建立“渲染调度”的意识。后续可以这样升级把render()包进requestAnimationFrame或微任务队列多次请求合并成一次执行。let renderScheduled false; function scheduleRender() { if (renderScheduled) return; renderScheduled true; requestAnimationFrame(() { render(); renderScheduled false; }); }以后再看Vue更新机制的“异步队列批量刷新”会发现它做的事就是这个调度思路的系统化升级。这套原生版TodoMVC做下来我最大的体会是“所有视图都由数据派生所有交互都向数据收敛”这句看似空泛的话会被你在项目里一点点写进骨子里。你会随手拆分状态、随手隔离持久化、随手做数据转义不再需要文档提醒。如果你已经照着做出来了我建议你再改造两个方向一个是把过滤条件从hash改成搜索参数另一个是给每条待办加截止时间并排序。改完这两个需求再回头看Vue版的代码你会敏锐地发现哪些逻辑该归模板管哪些必须留在脚本里。到那个阶段框架对你来说不再是黑盒而是一个顺手的生产力工具。

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

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

免费获取报价 →
↑