TodoMVC作为前端开发领域的"Hello World",是检验开发者基本功的绝佳试金石。这个项目看似简单,却完整覆盖了数据管理、DOM操作、事件处理等核心技能点。我选择纯原生JavaScript实现,是因为在框架盛行的当下,回归原生能真正夯实基础。同时提供Vue对比版本,帮助开发者理解框架背后的设计思想。
我曾面试过上百名前端候选人,发现能徒手实现TodoMVC的不足20%。通过这个教程,你将掌握:
虽然纯原生项目不需要构建工具,但我仍推荐以下现代开发环境:
bash复制mkdir todo-mvc && cd todo-mvc
touch index.html style.css app.js
使用Live Server插件运行项目(VS Code可直接安装),这比直接打开HTML文件更接近真实开发场景。注意在HTML中正确引入资源:
html复制<link rel="stylesheet" href="style.css">
<script src="app.js" type="module"></script>
关键细节:使用type="module"启用ES6模块系统,这是现代JS开发的起点
典型的TodoMVC包含以下模块:
code复制src/
├── model.js # 数据状态管理
├── view.js # DOM渲染层
├── controller.js # 业务逻辑
└── store.js # 本地持久化
这种分离带来三大优势:
Model层的核心是维护todos数组和操作它的方法:
javascript复制class TodoModel {
constructor() {
this.todos = JSON.parse(localStorage.getItem('todos')) || []
}
addTodo(text) {
this.todos = [...this.todos, {
id: Date.now(),
text,
completed: false
}]
this._commit()
}
_commit() {
localStorage.setItem('todos', JSON.stringify(this.todos))
}
}
踩坑提醒:直接修改数组不会触发视图更新,必须创建新引用
避免全量DOM更新的关键技巧:
javascript复制function renderTodoList(todos) {
// 使用DocumentFragment减少重绘
const fragment = document.createDocumentFragment()
todos.forEach(todo => {
const li = document.createElement('li')
li.dataset.id = todo.id
li.innerHTML = `
<input type="checkbox" ${todo.completed ? 'checked' : ''}>
<label>${todo.text}</label>
<button class="destroy"></button>
`
fragment.appendChild(li)
})
// 一次性更新DOM
listEl.innerHTML = ''
listEl.appendChild(fragment)
}
高效处理动态元素事件的经典模式:
javascript复制document.addEventListener('click', e => {
if (e.target.matches('.destroy')) {
const id = Number(e.target.closest('li').dataset.id)
controller.removeTodo(id)
}
if (e.target.matches('input[type="checkbox"]')) {
const id = Number(e.target.closest('li').dataset.id)
controller.toggleTodo(id)
}
})
同样的功能用Vue实现:
javascript复制new Vue({
data: {
todos: JSON.parse(localStorage.getItem('todos')) || [],
newTodo: ''
},
methods: {
addTodo() {
this.todos.push({
id: Date.now(),
text: this.newTodo,
completed: false
})
this.saveTodos()
}
}
})
核心差异对比:
| 特性 | 原生JS | Vue |
|---|---|---|
| 数据绑定 | 手动更新 | 自动响应 |
| DOM操作 | 直接操作 | 虚拟DOM |
| 代码量 | 约300行 | 约150行 |
Vue的双向绑定本质上是基于:
理解这些机制后,我们可以用原生JS模拟类似行为:
javascript复制function defineReactive(obj, key) {
let value = obj[key]
Object.defineProperty(obj, key, {
get() {
return value
},
set(newVal) {
value = newVal
render() // 数据变化触发更新
}
})
}
动态创建的DOM元素必须正确清理:
javascript复制// 错误示例:直接删除父元素
listEl.innerHTML = ''
// 正确做法:移除事件监听后再删除
function clearList() {
Array.from(listEl.children).forEach(child => {
child.removeEventListener('click', handleClick)
child.remove()
})
}
Chrome DevTools的实用技巧:
monitorEvents监听DOM事件典型的内存泄漏排查流程:
针对Model层的单元测试示例:
javascript复制describe('TodoModel', () => {
let model
beforeEach(() => {
localStorage.clear()
model = new TodoModel()
})
test('addTodo should create new todo', () => {
model.addTodo('Test todo')
expect(model.todos.length).toBe(1)
})
})
即使原生项目也可以引入现代工具链:
javascript复制// vite.config.js
export default {
build: {
rollupOptions: {
input: './index.html'
}
}
}
这样能获得:
当多个视图依赖同一数据时,采用发布-订阅模式:
javascript复制class EventBus {
constructor() {
this.events = {}
}
on(event, callback) {
this.events[event] = this.events[event] || []
this.events[event].push(callback)
}
emit(event, data) {
this.events[event]?.forEach(cb => cb(data))
}
}
// 使用示例
bus.emit('todos-updated', newTodos)
触摸事件处理的特殊考量:
javascript复制// 区分点击和滑动
let startX, startY
element.addEventListener('touchstart', e => {
startX = e.touches[0].clientX
startY = e.touches[0].clientY
})
element.addEventListener('touchend', e => {
const diffX = Math.abs(e.changedTouches[0].clientX - startX)
const diffY = Math.abs(e.changedTouches[0].clientY - startY)
if (diffX < 10 && diffY < 10) {
// 视为点击事件
}
})
完成基础实现后,建议尝试:
每个扩展方向都会涉及新的技术点:
我在实际项目中发现,完整实现TodoMVC所需的知识量,已经覆盖了前端开发80%的日常工作场景。这也是为什么它成为检验开发者能力的经典考题。