☰
原生JavaScript手写TodoMVC:从数据状态到DOM操作的完整实战
2026/10/18 16:20:16 网站建设 项目流程

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 class="todoapp"> <header class="header"> <h1>todos</h1> <input class="new-todo" placeholder="接下来要做什么?" autocomplete="off" /> </header> <section class="main"> <input id="toggle-all" class="toggle-all" type="checkbox" /> <label for="toggle-all">全部完成</label> <ul class="todo-list"><!-- 待办列表动态渲染 --></ul> </section> <footer class="footer"> <span class="todo-count"><strong>0</strong> 项待办</span> <ul class="filters"> <li><a href="#/" class="selected">全部</a></li> <li><a href="#/active">进行中</a></li> <li><a href="#/completed">已完成</a></li> </ul> <button class="clear-completed">清除已完成</button> </footer> </section>

注意几个细节:.new-todo的autocomplete="off"是为了避免浏览器弹出历史输入建议,影响交互。label for="toggle-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 id="app" class="todoapp"> <header class="header"> <h1>todos</h1> <input class="new-todo" v-model="newTodo" @keyup.enter="addTodo" placeholder="接下来要做什么?" /> </header> <section class="main" v-show="todos.length"> <input id="toggle-all" class="toggle-all" type="checkbox" v-model="allDone" /> <label for="toggle-all">全部完成</label> <ul class="todo-list"> <li v-for="todo in filteredTodos" :key="todo.id" :class="{ completed: todo.completed, editing: editingId === todo.id }"> <div class="view"> <input class="toggle" type="checkbox" v-model="todo.completed" /> <label @dblclick="startEdit(todo)">{{ todo.title }}</label> <button class="destroy" @click="removeTodo(todo)"></button> </div> <input class="edit" v-model="editText" @keyup.enter="finishEdit(todo)" @keyup.escape="cancelEdit(todo)" @blur="finishEdit(todo)" v-focus="editingId === todo.id" /> </li> </ul> </section> <footer class="footer" v-show="todos.length"> <span class="todo-count"><strong>{{ remaining }}</strong> 项待办</span> <ul class="filters"> <li><a href="#/all" :class="{ selected: filter === 'all' }" @click="filter = 'all'">全部</a></li> <li><a href="#/active" :class="{ selected: filter === 'active' }" @click="filter = 'active'">进行中</a></li> <li><a href="#/completed" :class="{ selected: filter === 'completed' }" @click="filter = 'completed'">已完成</a></li> </ul> <button class="clear-completed" v-show="completedCount > 0" @click="clearCompleted">清除已完成</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-model="newTodo"就完成了“输入框值 <=> 变量”的同步。@keyup.enter="addTodo"替代了原生代码里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 版本看一遍,你会发现自己对“数据驱动视图”这句话,有了完全不一样的理解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询