任务管理应用(TodoMVC)

一、项目初始化与架构设计(约1000字)

TodoMVC作为前端框架的经典实践项目,其核心价值在于通过标准化的需求场景(任务的增删改查、状态管理、本地存储等)展示框架特性。本章节将基于Vue.js 3.5与TypeScript,采用Pinia进行状态管理,结合响应式布局与主题切换功能,构建一个功能完整、体验优良的任务管理应用。

项目创建与依赖安装
首先通过Vite初始化项目,选择vue-ts模板以支持TypeScript类型校验:

bash
复制代码
npm create vite@latest todo-mvc -- --template vue-ts
cd todo-mvc
npm install

安装核心依赖:Pinia用于状态管理,pinia-plugin-persistedstate实现本地存储持久化:

bash
复制代码
npm install pinia pinia-plugin-persistedstate

目录结构调整
为保证项目可维护性,调整目录结构如下:

plaintext
复制代码
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目录,简化模块导入路径:

typescript
复制代码
// 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识别别名:

json
复制代码
{
  "compilerOptions": {
    "paths": {
      "@/*": [[27](src/*)]
    }
  }
}

初始化注意事项

  • 确保Node.js版本≥14.18.0,避免Vite依赖兼容性问题
  • 安装依赖后检查package.json,确认pinia版本≥2.1.0以支持最新API
  • pinia-plugin-persistedstate需与Pinia版本匹配,建议使用最新稳定版

二、组件拆分与通信设计(约1500字)

基于单一职责原则,将应用拆分为4个核心组件,通过Pinia实现跨组件通信,形成清晰的数据流架构。

组件树设计
应用整体组件结构如下:

plaintext
复制代码
App
├── TodoInput      # 任务输入框与添加按钮
├── TodoList       # 任务列表容器,遍历渲染TodoItem
│   └── TodoItem   # 单个任务项(复选框+文本+删除按钮)
└── TodoFooter     # 底部工具栏(全选+统计+筛选)

核心组件功能与实现

  1. TodoInput组件
    负责接收用户输入并添加新任务。通过v-model绑定输入框内容,监听回车键或点击事件触发添加逻辑:

    plaintext
    复制代码
    <!-- 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>
  2. TodoItem组件
    展示单个任务项,包含复选框(切换完成状态)、任务文本(根据完成状态添加样式)、删除按钮:

    plaintext
    复制代码
    <!-- 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>
  3. TodoList组件
    作为任务列表容器,根据当前筛选条件(通过Pinia获取)渲染符合条件的TodoItem:

    plaintext
    复制代码
    <!-- 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>
  4. TodoFooter组件
    整合底部工具栏功能:全选复选框(切换所有任务状态)、已完成统计、筛选按钮组(全部/活跃/已完成):

    plaintext
    复制代码
    <!-- 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:

  • 数据上行:TodoInput通过调用todoStore.addTodo将新任务添加到全局状态
  • 状态更新:TodoItem通过todoStore.toggleTododeleteTodo修改任务状态
  • 筛选控制:TodoFooter通过todoStore.setFilter更新全局筛选条件
  • 数据下行:TodoList通过计算属性filteredTodos从Pinia获取筛选后的任务列表

三、Pinia状态管理实现(约2000字)

采用Pinia进行状态管理,定义强类型的Store结构,实现任务数据的CRUD操作与筛选逻辑。

Todo类型定义
首先在types/todo.ts中定义任务数据结构接口:

typescript
复制代码
// types/todo.ts
export interface Todo {
  id: string  // 任务唯一标识,使用UUID或时间戳生成
  text: string // 任务描述文本
  completed: boolean // 任务完成状态
}

export type FilterType = 'all' | 'active' | 'completed' // 筛选条件类型

todoStore实现
创建stores/todoStore.ts,定义包含状态、计算属性和操作的Store:

typescript
复制代码
// 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的类型
  • 显式指定TodoFilterType接口,确保任务数据结构和筛选条件的合法性
  • 在actions中严格定义参数类型(如text: stringid: string),避免运行时类型错误

Store使用方式
在组件中通过useTodoStore函数获取Store实例,调用其方法或访问状态:

typescript
复制代码
// 在组件中使用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在本项目中展现出显著优势:

  1. 简化的API:无需嵌套的modulesmutations,直接通过actions修改状态
  2. TypeScript友好:天生支持类型推断,无需额外类型声明
  3. 更简洁的代码:减少模板代码,如无需在组件中使用mapStatemapActions
  4. DevTools集成:支持时间旅行调试,清晰追踪状态变更历史

四、本地存储与数据持久化(约500字)

通过pinia-plugin-persistedstate插件实现状态的本地存储持久化,确保页面刷新后任务数据不丢失。

插件安装与配置

  1. 初始化Pinia时注册插件
    main.ts中导入并使用持久化插件:

    typescript
    复制代码
    // 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')
  2. 在Store中启用持久化
    todoStore的配置中添加persist: true(已在前面代码中包含):

    typescript
    复制代码
    export const useTodoStore = defineStore('todo', {
      state: () => ({ /* ... */ }),
      getters: { /* ... */ },
      actions: { /* ... */ },
      persist: true // 启用持久化
    })

工作原理与验证

  • 存储机制:插件会将Store的state序列化为JSON字符串,存储在localStorage中(键名为__pinia__todo,由插件自动生成)
  • 数据恢复:页面加载时,插件会从localStorage读取数据并反序列化为state,覆盖初始状态
  • 验证方法
    1. 添加若干任务并标记部分为已完成
    2. 刷新浏览器页面
    3. 观察任务列表是否与刷新前一致,验证数据未丢失

注意事项

  • 存储的数据需为JSON可序列化类型,避免存储函数、Symbol等不可序列化值
  • 若需自定义存储字段,可在persist配置中使用paths指定需要持久化的state字段,如:
    persist: { paths: ['todos'] } // 仅持久化todos字段,忽略filter字段

持久化效果验证
通过浏览器开发者工具的Application > Local Storage面板,可查看存储的原始数据:

json
复制代码
{
  "todos": [
    {"id":"1712345678901","text":"学习Vue3","completed":false},
    {"id":"1712345678902","text":"实现TodoMVC","completed":true}
  ],
  "filter":"all"
}

刷新页面后,Pinia会自动从localStorage恢复上述数据,实现无缝的数据持久化体验。

五、响应式布局与主题切换(约2000字)

采用Flexbox布局实现响应式设计,适配不同屏幕尺寸,并通过CSS变量与Pinia结合实现深色/浅色主题切换。

响应式布局实现
使用Flexbox和CSS Grid构建灵活布局,结合媒体查询适配移动设备。

  1. 基础布局样式
    App.vue中设置全局布局样式:

    plaintext
    复制代码
    <!-- 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>
  2. 组件级响应式样式
    以TodoItem为例,使用Flexbox布局并适配小屏幕:

    css
    复制代码
    .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变量实现深色/浅色模式切换。

  1. themeStore定义
    创建stores/themeStore.ts

    typescript
    复制代码
    // 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
    })
  2. CSS变量与主题初始化
    main.ts中初始化CSS变量,根据主题状态设置初始值:

    typescript
    复制代码
    // 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')
  3. 主题切换按钮组件
    在App.vue中添加切换按钮:

    plaintext
    复制代码
    <!-- 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>

主题切换实现原理

  1. 通过CSS变量定义可切换的样式属性(背景色、文本色、边框色等)
  2. themeStore管理主题状态(dark: boolean),并持久化到localStorage
  3. 初始化时根据存储的主题状态设置CSS变量
  4. 通过$subscribe监听主题变化,实时更新CSS变量值
  5. 所有组件样式使用CSS变量,实现主题的全局一致性切换

六、项目优化与部署(约1000字)

通过组件懒加载、错误处理等优化手段提升应用质量,并部署到静态托管平台。

项目优化策略

  1. 组件懒加载
    对非首屏组件使用动态导入,减少初始加载资源体积:

    plaintext
    复制代码
    <!-- 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>
  2. Pinia actions错误处理
    增强actions的健壮性,添加错误捕获:

    typescript
    复制代码
    // stores/todoStore.ts
    actions: {
      addTodo(text: string) {
        try {
          if (!text.trim()) {
            throw new Error('任务文本不能为空')
          }
          // ...添加任务逻辑
        } catch (error) {
          console.error('添加任务失败:', error)
          // 可添加用户提示,如使用ElMessage等UI组件
        }
      }
    }
  3. CSS变量作用域优化
    使用scoped样式和CSS变量结合,避免样式冲突:

    plaintext
    复制代码
    <style scoped>
    .todo-input {
      /* 使用全局CSS变量定义主题色 */
      background: var(--bg-color);
      color: var(--text-color);
      /* 使用局部变量定义组件特有样式 */
      --input-height: 40px;
      height: var(--input-height);
      width: 100%;
    }
    </style>

部署步骤
以部署到Netlify为例,流程如下:

  1. 构建项目
    执行构建命令生成静态文件:

    bash
    复制代码
    npm run build

    构建完成后生成dist目录,包含所有静态资源。

  2. 上传代码到GitHub
    将项目推送到GitHub仓库,确保包含dist目录(或通过.gitignore排除后在Netlify配置中构建)。

  3. Netlify部署配置

    • 登录Netlify,点击"New site from Git"
    • 选择GitHub仓库
    • 配置构建命令:npm run build
    • 配置发布目录:dist
    • 点击"Deploy site"完成部署
  4. 部署验证
    访问Netlify提供的URL,验证应用功能正常,包括:

    • 任务的添加、完成、删除功能
    • 页面刷新后数据是否持久化
    • 主题切换功能是否正常
    • 响应式布局在不同屏幕尺寸下是否适配

部署注意事项

  • 若使用Vue Router的history模式,需在Netlify中配置_redirects文件处理路由:
    /* /index.html 200
  • 确保vite.config.ts中设置正确的base路径,如部署到子路径需配置base: '/todo-mvc/'
  • 生产环境构建前建议执行npm run lint检查代码规范,避免潜在错误

总结

本章节通过6个步骤完成了TodoMVC应用的开发,涵盖了项目初始化、组件设计、状态管理、数据持久化、响应式布局与主题切换等核心知识点。通过Pinia实现了清晰的状态管理,使用CSS变量和媒体查询构建了响应式界面,最终通过优化和部署流程将应用发布到生产环境。该项目不仅展示了Vue.js 3.5的核心特性,也体现了现代前端开发的最佳实践,如TypeScript类型安全、组件化设计、状态持久化等。