TodoMVC作为前端框架的经典实践项目,其核心价值在于通过标准化的需求场景(任务的增删改查、状态管理、本地存储等)展示框架特性。本章节将基于Vue.js 3.5与TypeScript,采用Pinia进行状态管理,结合响应式布局与主题切换功能,构建一个功能完整、体验优良的任务管理应用。
项目创建与依赖安装
首先通过Vite初始化项目,选择vue-ts模板以支持TypeScript类型校验:
npm create vite@latest todo-mvc -- --template vue-ts
cd todo-mvc
npm install
安装核心依赖:Pinia用于状态管理,pinia-plugin-persistedstate实现本地存储持久化:
npm install pinia pinia-plugin-persistedstate
目录结构调整
为保证项目可维护性,调整目录结构如下:
src/
├── components/ # 公共组件目录
│ ├── TodoInput.vue
│ ├── TodoList.vue
│ ├── TodoItem.vue
│ └── TodoFooter.vue
├── stores/ # Pinia状态管理目录
│ ├── todoStore.ts
│ └── themeStore.ts
├── types/ # TypeScript类型定义
│ └── todo.ts
├── App.vue
├── main.ts
└── vite.config.ts
Vite配置优化
修改vite.config.ts,配置@别名指向src目录,简化模块导入路径:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src') // 配置@别名
}
}
})
同时在tsconfig.json中添加路径映射,确保TypeScript识别别名:
{
"compilerOptions": {
"paths": {
"@/*": [[27](src/*)]
}
}
}
初始化注意事项:
package.json,确认pinia版本≥2.1.0以支持最新API pinia-plugin-persistedstate需与Pinia版本匹配,建议使用最新稳定版基于单一职责原则,将应用拆分为4个核心组件,通过Pinia实现跨组件通信,形成清晰的数据流架构。
组件树设计
应用整体组件结构如下:
App
├── TodoInput # 任务输入框与添加按钮
├── TodoList # 任务列表容器,遍历渲染TodoItem
│ └── TodoItem # 单个任务项(复选框+文本+删除按钮)
└── TodoFooter # 底部工具栏(全选+统计+筛选)
核心组件功能与实现
TodoInput组件
负责接收用户输入并添加新任务。通过v-model绑定输入框内容,监听回车键或点击事件触发添加逻辑:
<!-- components/TodoInput.vue -->
<template>
<input
v-model="newTodoText"
@keyup.enter="handleAddTodo"
placeholder="输入待办事项并按回车添加"
class="todo-input"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useTodoStore } from '@/stores/todoStore'
const newTodoText = ref('')
const todoStore = useTodoStore()
const handleAddTodo = () => {
if (newTodoText.value.trim()) {
todoStore.addTodo(newTodoText.value.trim())
newTodoText.value = '' // 清空输入框
}
}
</script>
TodoItem组件
展示单个任务项,包含复选框(切换完成状态)、任务文本(根据完成状态添加样式)、删除按钮:
<!-- components/TodoItem.vue -->
<template>
<li :class="{ completed: todo.completed }" class="todo-item">
<input
type="checkbox"
v-model="todo.completed"
@change="handleToggle"
/>
<span class="todo-text">{{ todo.text }}</span>
<button @click="handleDelete" class="delete-btn">×</button>
</li>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
import { useTodoStore } from '@/stores/todoStore'
import type { Todo } from '@/types/todo'
const props = defineProps<{ todo: Todo }>()
const todoStore = useTodoStore()
const handleToggle = () => {
todoStore.toggleTodo(props.todo.id)
}
const handleDelete = () => {
todoStore.deleteTodo(props.todo.id)
}
</script>
TodoList组件
作为任务列表容器,根据当前筛选条件(通过Pinia获取)渲染符合条件的TodoItem:
<!-- components/TodoList.vue -->
<template>
<ul class="todo-list">
<TodoItem
v-for="todo in filteredTodos"
:key="todo.id"
:todo="todo"
/>
</ul>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTodoStore } from '@/stores/todoStore'
import TodoItem from './TodoItem.vue'
const todoStore = useTodoStore()
// 根据当前筛选条件计算显示的任务
const filteredTodos = computed(() => {
switch (todoStore.filter) {
case 'active':
return todoStore.todos.filter(todo => !todo.completed)
case 'completed':
return todoStore.todos.filter(todo => todo.completed)
default: // 'all'
return todoStore.todos
}
})
</script>
TodoFooter组件
整合底部工具栏功能:全选复选框(切换所有任务状态)、已完成统计、筛选按钮组(全部/活跃/已完成):
<!-- components/TodoFooter.vue -->
<template>
<footer class="todo-footer">
<span class="todo-count">剩余 {{ todoStore.activeCount }} 项</span>
<div class="filters">
<button
:class="{ active: todoStore.filter === 'all' }"
@click="todoStore.setFilter('all')"
>
全部
</button>
<button
:class="{ active: todoStore.filter === 'active' }"
@click="todoStore.setFilter('active')"
>
活跃
</button>
<button
:class="{ active: todoStore.filter === 'completed' }"
@click="todoStore.setFilter('completed')"
>
已完成
</button>
</div>
<button
@click="todoStore.clearCompleted"
class="clear-btn"
v-if="todoStore.todos.some(t => t.completed)"
>
清除已完成
</button>
</footer>
</template>
<script setup lang="ts">
import { useTodoStore } from '@/stores/todoStore'
const todoStore = useTodoStore()
</script>
组件通信方式
通过Pinia实现组件间状态共享,避免props drilling:
todoStore.addTodo将新任务添加到全局状态 todoStore.toggleTodo和deleteTodo修改任务状态 todoStore.setFilter更新全局筛选条件 filteredTodos从Pinia获取筛选后的任务列表采用Pinia进行状态管理,定义强类型的Store结构,实现任务数据的CRUD操作与筛选逻辑。
Todo类型定义
首先在types/todo.ts中定义任务数据结构接口:
// types/todo.ts
export interface Todo {
id: string // 任务唯一标识,使用UUID或时间戳生成
text: string // 任务描述文本
completed: boolean // 任务完成状态
}
export type FilterType = 'all' | 'active' | 'completed' // 筛选条件类型
todoStore实现
创建stores/todoStore.ts,定义包含状态、计算属性和操作的Store:
// stores/todoStore.ts
import { defineStore } from 'pinia'
import type { Todo, FilterType } from '@/types/todo'
// 使用defineStore定义Store,第一个参数为唯一ID,第二个参数为配置对象
export const useTodoStore = defineStore('todo', {
// 状态定义,使用函数返回初始状态(确保SSR兼容性)
state: () => ({
todos: [] as Todo[], // 任务列表数组,初始为空
filter: 'all' as FilterType // 当前筛选条件,默认显示全部
}),
// 计算属性,基于state派生出新状态
getters: {
// 计算活跃任务数量(未完成的任务)
activeCount: (state) => {
return state.todos.filter(todo => !todo.completed).length
},
// 计算已完成任务数量
completedCount: (state) => {
return state.todos.filter(todo => todo.completed).length
}
},
// 操作方法,用于修改state(支持异步操作)
actions: {
/**
* 添加新任务
* @param text 任务描述文本
*/
addTodo(text: string) {
const newTodo: Todo = {
id: Date.now().toString(), // 使用时间戳作为简单ID(生产环境建议用UUID)
text,
completed: false
}
this.todos.push(newTodo) // 直接修改state(Pinia内部自动处理响应式)
},
/**
* 切换任务完成状态
* @param id 任务ID
*/
toggleTodo(id: string) {
const todo = this.todos.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed // 反转completed状态
}
},
/**
* 删除指定任务
* @param id 任务ID
*/
deleteTodo(id: string) {
this.todos = this.todos.filter(todo => todo.id !== id) // 过滤掉指定ID的任务
},
/**
* 清除所有已完成任务
*/
clearCompleted() {
this.todos = this.todos.filter(todo => !todo.completed)
},
/**
* 更新筛选条件
* @param filter 新的筛选条件
*/
setFilter(filter: FilterType) {
this.filter = filter
}
},
// 开启本地存储持久化(后续章节详细配置)
persist: true
})
TypeScript类型安全保障:
defineStore自动推断state、getters和actions的类型 Todo和FilterType接口,确保任务数据结构和筛选条件的合法性 text: string、id: string),避免运行时类型错误Store使用方式
在组件中通过useTodoStore函数获取Store实例,调用其方法或访问状态:
// 在组件中使用todoStore
import { useTodoStore } from '@/stores/todoStore'
const todoStore = useTodoStore()
// 访问状态
console.log('当前任务列表:', todoStore.todos)
console.log('活跃任务数:', todoStore.activeCount)
// 调用操作
todoStore.addTodo('学习Pinia状态管理')
todoStore.setFilter('active')
Pinia优势体现
相比Vuex,Pinia在本项目中展现出显著优势:
modules和mutations,直接通过actions修改状态 mapState或mapActions 通过pinia-plugin-persistedstate插件实现状态的本地存储持久化,确保页面刷新后任务数据不丢失。
插件安装与配置
初始化Pinia时注册插件
在main.ts中导入并使用持久化插件:
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'
import App from './App.vue'
// 创建Pinia实例
const pinia = createPinia()
// 注册持久化插件
pinia.use(createPersistedState({
// 可选配置:指定存储方式(默认localStorage)、键名等
storage: localStorage, // 可改为sessionStorage
key: (id) => `__pinia__${id}`, // 存储键名前缀
}))
createApp(App)
.use(pinia)
.mount('#app')
在Store中启用持久化
在todoStore的配置中添加persist: true(已在前面代码中包含):
export const useTodoStore = defineStore('todo', {
state: () => ({ /* ... */ }),
getters: { /* ... */ },
actions: { /* ... */ },
persist: true // 启用持久化
})
工作原理与验证
localStorage中(键名为__pinia__todo,由插件自动生成) localStorage读取数据并反序列化为state,覆盖初始状态 注意事项:
persist配置中使用paths指定需要持久化的state字段,如:persist: { paths: ['todos'] } // 仅持久化todos字段,忽略filter字段持久化效果验证
通过浏览器开发者工具的Application > Local Storage面板,可查看存储的原始数据:
{
"todos": [
{"id":"1712345678901","text":"学习Vue3","completed":false},
{"id":"1712345678902","text":"实现TodoMVC","completed":true}
],
"filter":"all"
}
刷新页面后,Pinia会自动从localStorage恢复上述数据,实现无缝的数据持久化体验。
采用Flexbox布局实现响应式设计,适配不同屏幕尺寸,并通过CSS变量与Pinia结合实现深色/浅色主题切换。
响应式布局实现
使用Flexbox和CSS Grid构建灵活布局,结合媒体查询适配移动设备。
基础布局样式
在App.vue中设置全局布局样式:
<!-- App.vue -->
<template>
<div class="app-container">
<h1 class="app-title">todos</h1>
<div class="todo-app">
<TodoInput />
<TodoList />
<TodoFooter v-if="todoStore.todos.length" />
</div>
</div>
</template>
<style scoped>
.app-container {
max-width: 550px;
margin: 0 auto;
padding: 20px;
}
.todo-app {
background: var(--bg-color);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 4px;
overflow: hidden;
}
/* 响应式调整 */
@media (max-width: 768px) {
.app-container {
padding: 10px;
}
.app-title {
font-size: 2rem; /* 移动端缩小标题字体 */
}
}
</style>
组件级响应式样式
以TodoItem为例,使用Flexbox布局并适配小屏幕:
.todo-item {
display: flex;
align-items: center;
gap: 12px; /* 使用gap替代margin,更现代的布局方式 */
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
@media (max-width: 768px) {
.todo-item {
padding: 10px 12px; /* 移动端减小内边距 */
gap: 8px;
}
}
主题切换功能实现
通过themeStore管理主题状态,结合CSS变量实现深色/浅色模式切换。
themeStore定义
创建stores/themeStore.ts:
// stores/themeStore.ts
import { defineStore } from 'pinia'
export const useThemeStore = defineStore('theme', {
state: () => ({
dark: false // 主题状态:false为浅色,true为深色
}),
actions: {
// 切换主题模式
toggleTheme() {
this.dark = !this.dark
}
},
// 持久化主题状态
persist: true
})
CSS变量与主题初始化
在main.ts中初始化CSS变量,根据主题状态设置初始值:
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'
import App from './App.vue'
import { useThemeStore } from '@/stores/themeStore'
const pinia = createPinia()
pinia.use(createPersistedState())
const app = createApp(App)
app.use(pinia)
// 初始化主题样式
const themeStore = useThemeStore()
const setThemeVariables = (dark: boolean) => {
const root = document.documentElement
if (dark) {
root.style.setProperty('--bg-color', '#2d2d2d')
root.style.setProperty('--text-color', '#e0e0e0')
root.style.setProperty('--border-color', '#444')
} else {
root.style.setProperty('--bg-color', '#fff')
root.style.setProperty('--text-color', '#333')
root.style.setProperty('--border-color', '#e0e0e0')
}
}
// 初始设置主题
setThemeVariables(themeStore.dark)
// 监听主题变化并更新CSS变量
themeStore.$subscribe((mutation, state) => {
setThemeVariables(state.dark)
})
app.mount('#app')
主题切换按钮组件
在App.vue中添加切换按钮:
<!-- App.vue -->
<template>
<div class="app-container">
<div class="theme-toggle">
<button @click="toggleTheme">
{{ themeStore.dark ? '切换至浅色模式' : '切换至深色模式' }}
</button>
</div>
<!-- 其他组件 -->
</div>
</template>
<script setup lang="ts">
import { useThemeStore } from '@/stores/themeStore'
import { useTodoStore } from '@/stores/todoStore'
const themeStore = useThemeStore()
const todoStore = useTodoStore()
const toggleTheme = () => {
themeStore.toggleTheme()
}
</script>
主题切换实现原理:
themeStore管理主题状态(dark: boolean),并持久化到localStorage $subscribe监听主题变化,实时更新CSS变量值 通过组件懒加载、错误处理等优化手段提升应用质量,并部署到静态托管平台。
项目优化策略
组件懒加载
对非首屏组件使用动态导入,减少初始加载资源体积:
<!-- App.vue -->
<template>
<TodoInput />
<Suspense>
<TodoList />
<template #fallback>加载中...</template>
</Suspense>
<TodoFooter v-if="todoStore.todos.length" />
</template>
<script setup lang="ts">
// 静态导入关键组件
import TodoInput from '@/components/TodoInput.vue'
import TodoFooter from '@/components/TodoFooter.vue'
// 懒加载非关键组件
const TodoList = defineAsyncComponent(() =>
import('@/components/TodoList.vue')
)
</script>
Pinia actions错误处理
增强actions的健壮性,添加错误捕获:
// stores/todoStore.ts
actions: {
addTodo(text: string) {
try {
if (!text.trim()) {
throw new Error('任务文本不能为空')
}
// ...添加任务逻辑
} catch (error) {
console.error('添加任务失败:', error)
// 可添加用户提示,如使用ElMessage等UI组件
}
}
}
CSS变量作用域优化
使用scoped样式和CSS变量结合,避免样式冲突:
<style scoped>
.todo-input {
/* 使用全局CSS变量定义主题色 */
background: var(--bg-color);
color: var(--text-color);
/* 使用局部变量定义组件特有样式 */
--input-height: 40px;
height: var(--input-height);
width: 100%;
}
</style>
部署步骤
以部署到Netlify为例,流程如下:
构建项目
执行构建命令生成静态文件:
npm run build
构建完成后生成dist目录,包含所有静态资源。
上传代码到GitHub
将项目推送到GitHub仓库,确保包含dist目录(或通过.gitignore排除后在Netlify配置中构建)。
Netlify部署配置
npm run build dist 部署验证
访问Netlify提供的URL,验证应用功能正常,包括:
部署注意事项:
_redirects文件处理路由:/* /index.html 200 vite.config.ts中设置正确的base路径,如部署到子路径需配置base: '/todo-mvc/' npm run lint检查代码规范,避免潜在错误本章节通过6个步骤完成了TodoMVC应用的开发,涵盖了项目初始化、组件设计、状态管理、数据持久化、响应式布局与主题切换等核心知识点。通过Pinia实现了清晰的状态管理,使用CSS变量和媒体查询构建了响应式界面,最终通过优化和部署流程将应用发布到生产环境。该项目不仅展示了Vue.js 3.5的核心特性,也体现了现代前端开发的最佳实践,如TypeScript类型安全、组件化设计、状态持久化等。