TodoMVC作为前端开发领域的"Hello World",是检验开发者基本功的绝佳试金石。这个经典项目看似简单,却涵盖了数据管理、DOM操作、状态更新等前端核心能力。我选择用纯原生JavaScript实现它,原因很简单:当你能够不依赖任何框架完成这个项目时,才能真正理解现代前端框架的价值所在。
在近十年的前端开发生涯中,我发现很多开发者存在"框架依赖症"——离开Vue/React就不知道如何组织代码。通过这个教程,你将掌握:
code复制src/
├── core/ # 核心逻辑层
│ ├── store.js # 数据管理
│ └── observer.js # 响应式系统
├── dom/ # 视图层
│ ├── render.js # DOM渲染
│ └── events.js # 事件处理
└── app.js # 应用入口
这种分层架构模拟了现代前端框架的核心理念。store.js负责维护todo列表的状态,observer.js实现简单的发布订阅模式,render.js则处理DOM更新。虽然代码量比框架版本多,但每个部分都清晰可见。
原生实现最关键的挑战是建立数据到视图的绑定。我采用了一种简单的观察者模式:
javascript复制// observer.js
class Observer {
constructor() {
this.subscribers = [];
}
subscribe(callback) {
this.subscribers.push(callback);
}
notify(data) {
this.subscribers.forEach(cb => cb(data));
}
}
当store中的todo数据变化时,通过notify方法触发所有订阅者的更新。这与Vue的响应式原理异曲同工,只是实现更加原始。
javascript复制// store.js
class TodoStore {
constructor() {
this.todos = [];
this.observer = new Observer();
}
addTodo(text) {
const newTodo = {
id: Date.now(),
text,
completed: false
};
this.todos = [...this.todos, newTodo];
this.observer.notify(this.todos);
}
}
对应的DOM操作:
javascript复制// render.js
function renderTodoList(todos) {
const listEl = document.getElementById('todo-list');
listEl.innerHTML = todos.map(todo => `
<li data-id="${todo.id}" class="${todo.completed ? 'completed' : ''}">
<div class="view">
<input class="toggle" type="checkbox" ${todo.completed ? 'checked' : ''}>
<label>${todo.text}</label>
<button class="destroy"></button>
</div>
</li>
`).join('');
}
javascript复制// store.js
toggleTodo(id) {
this.todos = this.todos.map(todo =>
todo.id === id ? {...todo, completed: !todo.completed} : todo
);
this.observer.notify(this.todos);
}
对应的DOM事件绑定:
javascript复制// events.js
document.addEventListener('change', e => {
if (e.target.matches('.toggle')) {
const id = parseInt(e.target.closest('li').dataset.id);
store.toggleTodo(id);
}
});
原生实现最大的性能瓶颈在于全量更新DOM。我采用简单的脏检查机制来优化:
javascript复制// render.js
let prevTodos = [];
function shouldUpdate(todos) {
if (todos.length !== prevTodos.length) return true;
return todos.some((todo, i) =>
todo.text !== prevTodos[i].text ||
todo.completed !== prevTodos[i].completed
);
}
function renderTodoList(todos) {
if (!shouldUpdate(todos)) return;
// 实际渲染逻辑
prevTodos = [...todos];
}
避免为每个todo项绑定独立的事件监听器:
javascript复制// events.js
document.getElementById('todo-list').addEventListener('click', e => {
if (e.target.matches('.destroy')) {
const id = parseInt(e.target.closest('li').dataset.id);
store.removeTodo(id);
}
});
| 功能 | 原生JS行数 | Vue行数 | 差异 |
|---|---|---|---|
| 新增Todo | 45 | 12 | -73% |
| 状态切换 | 38 | 8 | -79% |
| 筛选功能 | 62 | 15 | -76% |
Vue版本的优势主要体现在:
Vue的模板部分:
html复制<template>
<li
v-for="todo in filteredTodos"
:key="todo.id"
:class="{ completed: todo.completed }"
>
<div class="view">
<input
class="toggle"
type="checkbox"
v-model="todo.completed"
>
<label>{{ todo.text }}</label>
<button class="destroy" @click="removeTodo(todo.id)"></button>
</div>
</li>
</template>
对应的脚本部分:
javascript复制export default {
data() {
return {
todos: [],
filter: 'all'
}
},
computed: {
filteredTodos() {
// 筛选逻辑
}
},
methods: {
addTodo(text) {
this.todos.push({ id: Date.now(), text, completed: false });
},
removeTodo(id) {
this.todos = this.todos.filter(todo => todo.id !== id);
}
}
}
在原生实现中,容易忘记清理事件监听器:
javascript复制// 错误示例
function setup() {
document.getElementById('add-btn').addEventListener('click', handleAdd);
}
// 正确做法
let cleanupFns = [];
function setup() {
const addBtn = document.getElementById('add-btn');
const handler = () => handleAdd();
addBtn.addEventListener('click', handler);
cleanupFns.push(() => {
addBtn.removeEventListener('click', handler);
});
}
当多个操作同时修改状态时,可能出现竞态条件:
javascript复制// store.js
let pending = false;
async function fetchAndUpdate() {
if (pending) return;
pending = true;
try {
const data = await fetchTodos();
this.todos = data;
this.observer.notify(this.todos);
} finally {
pending = false;
}
}
先实现后优化:初期不要过度设计,先让功能跑起来,再考虑性能优化
测试驱动开发:为store层编写单元测试,确保核心逻辑正确性
javascript复制// store.test.js
test('toggleTodo should reverse completed status', () => {
const store = new TodoStore();
store.addTodo('Test');
store.toggleTodo(store.todos[0].id);
expect(store.todos[0].completed).toBe(true);
});
渐进式增强:可以尝试先加入虚拟DOM库如snabbdom,体验从原生到框架的过渡
性能监控:使用Chrome DevTools的Performance面板分析渲染耗时
在实际项目中,我建议: