构建现代化 Vue 3 应用:从零到一实现功能完备的 Todo List

引言:为什么选择这个技术栈?

在当今快速发展的前端领域,选择一个高效、健壮且易于维护的技术栈是项目成功的关键。Vue 3 以其出色的性能、友好的 Composition API 和优秀的开发者体验,赢得了大量开发者的青睐。然而,仅靠 Vue 本身不足以应对复杂应用的状态管理挑战。

本文将带领你深入探索如何将 Vue 3 的组件化开发与 Pinia 状态管理库无缝结合,并辅以 TypeScript 的类型安全、Vite 的极速构建以及 Tailwind CSS 的原子化样式,共同打造一个功能完整、类型安全、样式美观的现代化 Todo List 应用。

我们将摒弃简单的示例代码,专注于实践中的最佳实践和核心概念。通过一步步的构建过程,你不仅能掌握每个技术的使用方法,更能理解它们如何协同工作,从而为构建更复杂的单页应用(SPA)打下坚实的基础。

第一部分:项目初始化与开发环境搭建

1.1 选择 Vite:下一代前端构建工具

在项目伊始,我们选择 Vite 而非传统的 Vue CLI 作为构建工具。Vite 利用了浏览器对 ES 模块的原生支持,实现了闪电般的冷启动和快速的热模块更新(HMR),这将极大提升我们的开发效率。

执行项目初始化命令:

打开你的终端或命令行工具,运行以下命令来创建项目骨架:
npm create vite@latest

接下来,你会看到一系列交互式提示,用于配置项目的基本信息:

• Project name: vue-pinia-todo-app (你可以根据自己的喜好命名,但建议使用有意义的名称)

• Select a framework: Vue

• Select a variant: TypeScript

Vite 会自动创建一个配置好 Vue 3 和 TypeScript 的基本项目结构。命令执行完毕后,进入项目目录并安装依赖:
cd vue-pinia-todo-app
npm install

此时,一个最基础的 Vue 3 + TypeScript 项目已经就绪。你可以通过 npm run dev 命令启动开发服务器,在浏览器中查看默认的应用页面。

1.2 引入状态管理库:Pinia

Pinia 是 Vue 官方推荐的状态管理库,可以看作是下一代 Vuex。它提供了更简洁的 API、完美的 TypeScript 支持,并且与 Vue DevTools 深度集成。

安装 Pinia:

在项目根目录下,执行以下命令:
npm install pinia

Pinia 的设计哲学是直观且类型安全的,它将彻底改变你管理应用状态的方式。

1.3 集成样式引擎:Tailwind CSS

Tailwind CSS 是一个功能类优先的 CSS 框架,它允许你通过组合预定义的原子类来快速构建自定义设计,而无需离开你的 HTML/模板文件。

安装 Tailwind CSS 及其 Vite 插件:

我们安装 Tailwind 及其专为 Vite 优化的插件,以确保最优的构建性能。
npm install -D tailwindcss @tailwindcss/vite

配置 Vite 以使用 Tailwind:

接下来,我们需要告诉 Vite 在处理 CSS 时使用 Tailwind。修改项目根目录下的 vite.config.ts 文件:
// vite.config.ts
import { defineConfig } from ‘vite’
import vue from ‘@vitejs/plugin-vue’
import tailwindcss from ‘@tailwindcss/vite’ // 引入 Tailwind Vite 插件

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
tailwindcss(), // 将 Tailwind 插件添加到 Vite 插件列表中
],
})

引入 Tailwind 样式:

最后,我们需要在主 CSS 文件中引入 Tailwind 的指令。根据你的项目模板,找到 src/style.css 或 src/assets/main.css 文件,清空其内容,并添加以下行:
/* src/style.css */
@import ‘tailwindcss’;

Tailwind v4 简化了引入流程,一个 @import ‘tailwindcss’ 指令就包含了基础样式、组件类和工具类。

至此,我们的开发环境已经全部配置完成。现在,让我们开始构建应用的核心逻辑。

第二部分:应用架构与类型安全设计

2.1 定义数据模型:使用 TypeScript 接口

在编写任何业务逻辑之前,我们先使用 TypeScript 来定义清晰的数据结构。这就像建筑师的蓝图,它能确保我们在整个开发过程中数据的形状都是可预测和安全的。

在 src 目录下创建 types 文件夹,并在其中创建 todo.ts 文件:
// src/types/todo.ts

/**

  • 表示一个单一的待办事项项
    /
    export interface ITodoItem {
    /
    * 事项的唯一标识符 /
    id: number;
    /
    * 事项的文本内容 /
    text: string;
    /
    * 事项的完成状态,true 表示已完成 /
    isCompleted: boolean;
    /
    * 事项的创建时间戳 */
    createdAt: Date;
    }

/**

  • 表示待办事项列表的过滤状态
    */
    export type TodoFilter = ‘all’ | ‘active’ | ‘completed’;

/**

  • 表示 Todo Store 的完整状态
    /
    export interface ITodoState {
    /
    * 所有的待办事项数组 /
    items: ITodoItem[];
    /
    * 用于生成下一个事项 ID 的计数器 /
    nextId: number;
    /
    * 当前应用的筛选条件 */
    currentFilter: TodoFilter;
    }

设计思路解析:

• ITodoItem: 我们为待办事项增加了 createdAt 字段,这在真实应用中非常有用,例如可以用于排序。

• TodoFilter: 使用联合类型明确定义可能的过滤状态,避免魔法字符串(magic strings),增强代码可读性和安全性。

• ITodoState: 状态结构不仅包含核心数据 items,还包含了 UI 状态 currentFilter,这体现了 Pinia 允许将部分 UI 状态集中管理的灵活性。

2.2 构建状态管理中心:Pinia Store

Pinia Store 是状态、业务逻辑和计算属性的容器。我们将在一个 Store 中管理整个 Todo 应用的状态。

在 src 目录下创建 stores 文件夹,并创建 todoStore.ts 文件:

2.2.1 定义 State 和 Getters

// src/stores/todoStore.ts
import { defineStore } from ‘pinia’;
import type { ITodoItem, ITodoState, TodoFilter } from ‘@/types/todo’; // 使用路径别名@

// 使用 defineStore 定义 Store,‘todo’ 是 Store 的唯一 ID
export const useTodoStore = defineStore(‘todo’, {
// State 是一个返回初始状态的函数
state: (): ITodoState => ({
items: [
{
id: 1,
text: ‘学习 Pinia 的核心概念’,
isCompleted: true,
createdAt: new Date(‘2024-01-01’),
},
{
id: 2,
text: ‘掌握 Composition API 的使用’,
isCompleted: false,
createdAt: new Date(‘2024-01-02’),
},
],
nextId: 3,
currentFilter: ‘all’,
}),

// Getters 类似于组件的计算属性,用于派生基于 State 的数据
getters: {
/**
* 根据当前过滤条件返回过滤后的待办事项
*/
filteredItems(state): ITodoItem[] {
switch (state.currentFilter) {
case ‘active’:
return state.items.filter(item => !item.isCompleted);
case ‘completed’:
return state.items.filter(item => item.isCompleted);
case ‘all’:
default:
return state.items;
}
},

/**
 * 统计所有未完成事项的数量
 */
activeItemsCount(state): number {
  // 使用 reduce 方法进行统计,是一种函数式编程的实践
  return state.items.reduce((count, item) => count + (item.isCompleted ? 0 : 1), 0);
},

/**
 * 统计所有已完成事项的数量
 */
completedItemsCount(state): number {
  return state.items.filter(item => item.isCompleted).length;
},

/**
 * 检查是否所有事项都已完成
 */
isAllCompleted(state): boolean {
  return state.items.length > 0 && state.items.every(item => item.isCompleted);
},

},
});

Getters 深度解析:

• filteredItems: 这是核心的 Getter,它根据 currentFilter 状态动态返回不同的列表。这种设计将复杂的过滤逻辑从组件中抽离,使组件更专注于渲染。

• activeItemsCount: 展示了使用 reduce 方法进行累加计算的模式,这是一种高效且声明式的编程方式。

• isAllCompleted: 使用了数组的 every 方法,并添加了 length > 0 的条件,避免了空数组也返回 true 的逻辑错误。

2.2.2 定义 Actions:修改状态的唯一途径

Actions 相当于组件中的 methods,是修改 State 的唯一方式。它们可以包含任意异步操作。
// 在 todoStore.ts 的 defineStore 配置对象中继续添加 actions
actions: {
/**
* 添加一个新的待办事项
* @param text - 待办事项的文本内容
*/
addItem(text: string): void {
// 输入验证:确保文本非空
if (!text || text.trim() === ‘’) {
console.warn(‘尝试添加空待办事项’);
return;
}

  const newItem: ITodoItem = {
    id: this.nextId,
    text: text.trim(),
    isCompleted: false,
    createdAt: new Date(),
  };

  // 将新事项添加到数组开头,使最新添加的显示在最上面
  this.items.unshift(newItem);
  this.nextId++;
},

/**
 * 根据 ID 删除一个待办事项
 * @param id - 要删除的事项的 ID
 */
removeItem(id: number): void {
  // 使用 findIndex 找到目标项的索引
  const index = this.items.findIndex(item => item.id === id);
  if (index !== -1) {
    this.items.splice(index, 1);
  }
},

/**
 * 切换单个待办事项的完成状态
 * @param id - 要切换状态的事项的 ID
 */
toggleItem(id: number): void {
  const item = this.items.find(item => item.id === id);
  if (item) {
    item.isCompleted = !item.isCompleted;
  }
},

/**
 * 批量更新所有事项的完成状态
 * @param completed - 要设置的状态,true 为全部完成,false 为全部未完成
 */
toggleAllItems(completed: boolean): void {
  this.items.forEach(item => {
    item.isCompleted = completed;
  });
},

/**
 * 更新待办事项的文本内容
 * @param id - 要更新的事项的 ID
 * @param newText - 新的文本内容
 */
updateItemText(id: number, newText: string): void {
  const item = this.items.find(item => item.id === id);
  if (item && newText.trim() !== '') {
    item.text = newText.trim();
  }
},

/**
 * 清除所有已完成的事项
 */
clearCompleted(): void {
  this.items = this.items.filter(item => !item.isCompleted);
},

/**
 * 设置当前的过滤条件
 * @param filter - 新的过滤条件
 */
setFilter(filter: TodoFilter): void {
  this.currentFilter = filter;
},

},

Actions 设计理念:

• 单一职责: 每个 Action 只负责一个明确的操作,如 addItem 只负责添加,toggleItem 只负责切换状态。

• 数据验证: 在 addItem 和 updateItemText 中,我们对输入进行了基本的验证,提高了应用的健壮性。

• 不可变更新: 在 clearCompleted 中,我们通过赋值一个新的数组 (this.items = …) 来触发响应式更新。虽然 Pinia 允许直接修改数组,但这种模式在某些场景下更清晰。

• 业务逻辑集中化: 所有与待办事项数据变更相关的逻辑都封装在此,组件只需调用相应的 Action,而无需关心内部实现。

第三部分:构建用户界面与组件化实践

3.1 配置应用根组件

首先,我们需要在应用的入口点注册 Pinia。

修改 src/main.ts:
// src/main.ts
import { createApp } from ‘vue’;
import { createPinia } from ‘pinia’;
import App from ‘./App.vue’;
import ‘./style.css’; // 确保 Tailwind CSS 被引入

// 创建应用和 Pinia 实例
const app = createApp(App);
const pinia = createPinia();

// 使用 Pinia 插件
app.use(pinia);

// 挂载到 DOM
app.mount(‘#app’);

接下来,我们重写主要的 App.vue 组件,将其作为整个应用的布局容器。

我的任务管理中心

高效管理你的每日任务,提升生产力

  <!-- 主卡片区域 -->
  <main class="bg-white rounded-2xl shadow-xl overflow-hidden">
    <!-- 新增事项输入区 -->
    <div class="p-6 border-b border-gray-200">
      <div class="flex space-x-4">
        <input
          type="text"
          v-model.trim="newTodoText"
          @keypress="handleKeypress"
          placeholder="接下来要做什么?"
          class="flex-1 py-3 px-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition duration-200"
        />
        <button
          @click="handleAddTodo"
          :disabled="!newTodoText.trim()"
          class="bg-blue-600 text-white font-semibold py-3 px-6 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed transition duration-200"
        >
          添加任务
        </button>
      </div>
    </div>

    <!-- 过滤选项卡和统计信息 -->
    <div class="px-6 py-4 bg-gray-50 border-b border-gray-200 flex justify-between items-center">
      <div class="flex space-x-1">
        <button
          v-for="filter in ['all', 'active', 'completed']"
          :key="filter"
          @click="todoStore.setFilter(filter as any)"
          :class="[
            'px-3 py-1 rounded-full text-sm font-medium capitalize transition duration-200',
            todoStore.currentFilter === filter
              ? 'bg-blue-100 text-blue-800'
              : 'text-gray-600 hover:bg-gray-200'
          ]"
        >
          {{ filter }}
        </button>
      </div>
      <div class="text-sm text-gray-500">
        <span class="font-medium text-blue-600">{{ todoStore.activeItemsCount }}</span> 项待完成
      </div>
    </div>

    <!-- 待办事项列表 -->
    <ul v-if="todoStore.filteredItems.length > 0" class="divide-y divide-gray-200">
      <li
        v-for="todo in todoStore.filteredItems"
        :key="todo.id"
        class="px-6 py-4 hover:bg-gray-50 transition duration-150"
      >
        <!-- 我们将使用一个单独的组件来渲染每个 Todo 项 -->
        <TodoItem :todo="todo" />
      </li>
    </ul>

    <!-- 空状态提示 -->
    <div v-else class="text-center py-12">
      <div class="text-gray-400 mb-2">
        <svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
        </svg>
      </div>
      <p class="text-gray-500 italic">
        {{
          todoStore.currentFilter === 'completed'
            ? '还没有完成的任务'
            : todoStore.currentFilter === 'active'
            ? '恭喜!所有任务都已完成!'
            : '还没有添加任何任务'
        }}
      </p>
    </div>

    <!-- 底部操作栏 -->
    <footer v-if="todoStore.items.length > 0" class="px-6 py-4 bg-gray-50 flex justify-between items-center text-sm">
      <span class="text-gray-600">
        总计 <span class="font-semibold">{{ totalItemsCount }}</span> 项任务
      </span>
      <button
        v-if="todoStore.completedItemsCount > 0"
        @click="todoStore.clearCompleted()"
        class="text-red-600 hover:text-red-800 font-medium transition duration-200"
      >
        清除已完成 ({{ todoStore.completedItemsCount }})
      </button>
    </footer>
  </main>
</div>

组件设计分析:

• 布局与样式: 使用了 Tailwind CSS 的原子化类名构建了一个美观的卡片布局,包括渐变背景、阴影、圆角等。

• 条件渲染: 使用 v-if 和 v-else 优雅地处理了列表为空的状态。

• 事件处理: 为输入框绑定了键盘事件,提升了用户体验。

• 动态类名: 过滤按钮使用了 :class 的动态绑定,根据当前状态高亮显示。

• 组件拆分: 我们意识到每个待办事项项(

  • )会包含独立的交互逻辑(如编辑、删除),因此计划将其拆分为独立的子组件 TodoItem。
  • 3.2 创建可复用的子组件:TodoItem

    在 src/components 目录下创建 TodoItem.vue 文件。这个组件将负责单个待办事项的渲染和交互。

    子组件技术亮点:

    • Props 类型定义: 使用 TypeScript 的接口明确定义了组件需要接收的 todo 属性。

    • 双向编辑模式: 实现了常见的“双击编辑”和“点击图标编辑”两种模式,提供了良好的用户体验。

    • 键盘交互: 在编辑模式下支持 Enter 保存和 Escape 取消,符合用户习惯。

    • 条件样式: 使用 :class 动态绑定,为已完成的事项添加删除线和灰色文字。

    • 悬停效果: 操作按钮默认隐藏,只在鼠标悬停在该事项上时显示(通过 group 和 group-hover 实现),保持了界面的简洁。

    • 图标使用: 内联 SVG 图标,无需额外依赖图标库。

    最后,别忘了在 App.vue 中导入并使用 TodoItem 组件。

    import TodoItem from ‘@/components/TodoItem.vue’;

    第四部分:高级技巧与最佳实践

    4.1 状态持久化:使用 Pinia 插件

    应用刷新后,状态会丢失。我们可以使用 pinia-plugin-persistedstate 插件轻松实现状态持久化。

    安装插件:
    npm install pinia-plugin-persistedstate

    配置插件:

    修改 src/main.ts:
    // src/main.ts
    import { createApp } from ‘vue’;
    import { createPinia } from ‘pinia’;
    import { createPersistedState } from ‘pinia-plugin-persistedstate’; // 导入插件
    import App from ‘./App.vue’;
    import ‘./style.css’;

    const app = createApp(App);
    const pinia = createPinia();

    // 使用持久化状态插件
    pinia.use(createPersistedState({
    storage: localStorage, // 默认使用 localStorage
    // 可以在这里配置全局序列化/反序列化方法等
    }));

    app.use(pinia);
    app.mount(‘#app’);

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐