资讯动态

Angular项目实战:从基础TodoApp到组件化、状态管理与本地存储的完整改造

发布时间:2026/8/21 2:36:54 来源:尧图企业网站定制
最近在辅导几位刚接触 Angular 的同学时发现他们虽然能跟着官方教程跑通第一个应用但一旦需要自己动手改造、添加新功能就立刻卡壳不知道从何下手。这背后反映出的其实是缺乏对 Angular 项目结构、数据流和组件交互的系统性理解。本文将以一个经典的TodoApp项目为蓝本聚焦于“挑战02”——即对现有基础 Todo 应用进行功能增强与结构改造。我们将从零开始一步步拆解如何分析需求、设计组件、管理状态并实现交互最终打造一个功能更完善、结构更清晰的待办事项应用。无论你是刚刚完成 Angular 初学入门还是希望巩固项目实战能力这篇教程都能提供一套可复用的开发思路和代码模板。1. 项目背景与核心概念在开始编码之前我们首先要明确两件事我们改造的起点是什么以及我们要达成的目标是什么。1.1 起点基础 TodoApp 分析一个最基础的 TodoApp 通常包含以下核心功能展示待办事项列表一个数组每个事项包含内容text和完成状态completed。添加新待办事项通过一个输入框和按钮将新事项加入列表。标记事项为完成/未完成通常通过复选框checkbox来切换单个事项的状态。删除事项提供一个删除按钮移除指定事项。其组件结构可能非常简单例如只有一个AppComponent模板HTML和逻辑TypeScript都写在一起。数据流也是线性的用户操作触发组件内的方法直接修改组件类中的数组。这种结构对于学习基础概念是好的但在功能扩展时如添加过滤、持久化存储、服务注入会变得难以维护。这就是我们“挑战02”的出发点。1.2 目标增强版 TodoApp 功能设计本次改造的目标是引入更工程化的实践主要增强点包括组件化拆分将庞大的AppComponent拆分为更小、职责单一的组件如TodoListComponent,TodoItemComponent,AddTodoComponent。状态提升与管理将数据Todo列表和核心逻辑增删改查提升到父组件或通过服务进行集中管理实现组件间的通信。添加过滤功能实现“全部”、“进行中”、“已完成”三个过滤视图。添加本地存储使用浏览器的localStorage实现刷新页面后数据不丢失。样式与用户体验优化为完成的事项添加删除线优化交互反馈。通过实现这些目标你将深刻理解 Angular 中输入输出Input/Output、服务Service注入、生命周期钩子以及响应式表单等核心概念的实际应用。2. 环境准备与项目初始化在开始改造前请确保你的开发环境已就绪。2.1 环境要求Node.js: 建议安装 LTS 版本如 v18.x 或 v20.x。你可以通过终端运行node --version和npm --version来检查。Angular CLI: 这是 Angular 的开发脚手架工具。通过npm install -g angular/cli进行全局安装。IDE/编辑器: 推荐使用 Visual Studio Code并安装 Angular Language Service 等插件以获得更好的开发体验。2.2 创建与运行基础项目如果你还没有基础项目可以通过以下命令快速创建一个# 使用 Angular CLI 创建一个名为 todo-app 的新项目跳过测试文件创建 ng new todo-app --skip-tests cd todo-app # 启动开发服务器 ng serve打开浏览器访问http://localhost:4200你应该能看到 Angular 的默认欢迎页面。2.3 构建基础 TodoApp 结构起点为了让大家站在同一起跑线我们先在app.component.ts和app.component.html中构建一个最基础的、未拆分的 TodoApp。1. 定义数据模型在src/app目录下创建一个models文件夹并新建todo.model.ts文件。良好的类型定义是 Angular 开发的第一步。// src/app/models/todo.model.ts export interface Todo { id: number; // 唯一标识用于删除和跟踪 text: string; // 待办事项内容 completed: boolean; // 完成状态 }2. 编写基础 AppComponent替换app.component.ts和app.component.html的内容。// src/app/app.component.ts import { Component } from angular/core; import { Todo } from ./models/todo.model; Component({ selector: app-root, templateUrl: ./app.component.html, styleUrls: [./app.component.css] }) export class AppComponent { // 待办事项列表 todos: Todo[] [ { id: 1, text: 学习 Angular 组件, completed: false }, { id: 2, text: 编写 TodoApp 教程, completed: true }, { id: 3, text: 理解 Input 和 Output, completed: false } ]; // 用于绑定输入框的新事项文本 newTodoText: string ; // 添加新待办事项 addTodo(): void { if (this.newTodoText.trim()) { const newTodo: Todo { id: Date.now(), // 使用时间戳作为简单ID text: this.newTodoText.trim(), completed: false }; this.todos.push(newTodo); this.newTodoText ; // 清空输入框 } } // 切换待办事项的完成状态 toggleTodo(id: number): void { const todo this.todos.find(t t.id id); if (todo) { todo.completed !todo.completed; } } // 删除待办事项 deleteTodo(id: number): void { this.todos this.todos.filter(t t.id ! id); } }!-- src/app/app.component.html -- div classcontainer h1Angular Todo App/h1 !-- 添加事项区域 -- div classadd-todo input typetext [(ngModel)]newTodoText placeholder请输入待办事项... (keyup.enter)addTodo() button (click)addTodo()添加/button /div !-- 待办事项列表 -- ul classtodo-list li *ngForlet todo of todos [class.completed]todo.completed input typecheckbox [checked]todo.completed (change)toggleTodo(todo.id) span{{ todo.text }}/span button classdelete-btn (click)deleteTodo(todo.id)×/button /li /ul !-- 统计信息 -- div classstats 总计: {{ todos.length }} | 剩余: {{ todos.filter(t !t.completed).length }} /div /div/* src/app/app.component.css */ .container { max-width: 500px; margin: 2rem auto; padding: 1rem; } .add-todo { display: flex; gap: 0.5rem; margin-bottom: 1rem; } .add-todo input { flex-grow: 1; padding: 0.5rem; } .todo-list { list-style: none; padding: 0; } .todo-list li { display: flex; align-items: center; padding: 0.75rem; border-bottom: 1px solid #eee; } .todo-list li.completed span { text-decoration: line-through; color: #888; } .delete-btn { margin-left: auto; background: #ff6b6b; color: white; border: none; border-radius: 50%; cursor: pointer; } .stats { margin-top: 1rem; color: #666; }3. 导入 FormsModule由于模板中使用了[(ngModel)]需要在AppModule中导入FormsModule。// src/app/app.module.ts import { NgModule } from angular/core; import { BrowserModule } from angular/platform-browser; import { FormsModule } from angular/forms; // 导入 FormsModule import { AppComponent } from ./app.component; NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, FormsModule // 添加 FormsModule 到 imports 数组 ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }现在运行ng serve一个功能完整但结构单一的基础 TodoApp 就完成了。这就是我们改造的起点。3. 核心改造一组件化拆分与通信将所有逻辑塞在一个组件里是初学者的常见做法但不利于维护和复用。我们现在将其拆分为三个子组件。3.1 创建子组件使用 Angular CLI 快速生成组件ng generate component todo-list ng generate component todo-item ng generate component add-todo这会在src/app下创建对应的组件文件夹及文件。3.2 设计组件接口与数据流在拆分前必须规划好数据如何流动AppComponent(父组件)持有核心数据todos和核心方法addTodo,toggleTodo,deleteTodo。AddTodoComponent(子组件)负责渲染输入框和按钮。当用户点击“添加”时它需要向上通知父组件新的待办事项文本。使用Output()发射事件。TodoListComponent(子组件)负责接收todos数组并循环渲染每个TodoItemComponent。它从父组件接收数据(Input())并将子项的事件如切换、删除向上传递给父组件处理。TodoItemComponent(孙组件)负责渲染单个待办事项的视图复选框、文本、删除按钮。它从TodoListComponent接收数据(Input())并将用户操作切换、删除向上传递。3.3 实现 AddTodoComponentAddTodoComponent需要发射一个包含新事项文本的事件。// src/app/add-todo/add-todo.component.ts import { Component, Output, EventEmitter } from angular/core; Component({ selector: app-add-todo, templateUrl: ./add-todo.component.html, styleUrls: [./add-todo.component.css] }) export class AddTodoComponent { // 定义一个输出属性用于向父组件发射事件 Output() add new EventEmitterstring(); // 组件内部管理的输入框文本 todoText: string ; // 触发添加事件的方法 onSubmit(): void { if (this.todoText.trim()) { // 发射事件携带文本数据 this.add.emit(this.todoText.trim()); this.todoText ; // 清空输入框 } } }!-- src/app/add-todo/add-todo.component.html -- div classadd-todo input typetext [(ngModel)]todoText placeholder请输入待办事项... (keyup.enter)onSubmit() button (click)onSubmit()添加/button /div记得在AddTodoComponent所在的模块通常是AppModule中FormsModule已经被导入否则[(ngModel)]无法工作。3.4 实现 TodoItemComponentTodoItemComponent接收一个todo对象并发射两个事件切换和删除。// src/app/todo-item/todo-item.component.ts import { Component, Input, Output, EventEmitter } from angular/core; import { Todo } from ../models/todo.model; Component({ selector: app-todo-item, templateUrl: ./todo-item.component.html, styleUrls: [./todo-item.component.css] }) export class TodoItemComponent { // 输入属性接收一个 Todo 对象 Input() todo!: Todo; // 输出属性当待办事项状态被切换时发射其 ID Output() toggle new EventEmitternumber(); // 输出属性当待办事项被删除时发射其 ID Output() delete new EventEmitternumber(); // 触发切换事件 onToggle(): void { this.toggle.emit(this.todo.id); } // 触发删除事件 onDelete(): void { this.delete.emit(this.todo.id); } }!-- src/app/todo-item/todo-item.component.html -- li [class.completed]todo.completed input typecheckbox [checked]todo.completed (change)onToggle() span{{ todo.text }}/span button classdelete-btn (click)onDelete()×/button /li/* src/app/todo-item/todo-item.component.css */ /* 样式可以复用或微调这里为了清晰将样式移到组件内 */ :host { display: block; } /* 确保组件本身是块级元素 */ li { display: flex; align-items: center; padding: 0.75rem; border-bottom: 1px solid #eee; } li.completed span { text-decoration: line-through; color: #888; } .delete-btn { margin-left: auto; background: #ff6b6b; color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer; }3.5 实现 TodoListComponentTodoListComponent作为中间层接收列表循环创建TodoItemComponent并转发事件。// src/app/todo-list/todo-list.component.ts import { Component, Input, Output, EventEmitter } from angular/core; import { Todo } from ../models/todo.model; Component({ selector: app-todo-list, templateUrl: ./todo-list.component.html, styleUrls: [./todo-list.component.css] }) export class TodoListComponent { // 输入属性接收待办事项数组 Input() todos: Todo[] []; // 输出属性转发切换事件 Output() toggle new EventEmitternumber(); // 输出属性转发删除事件 Output() delete new EventEmitternumber(); }!-- src/app/todo-list/todo-list.component.html -- ul classtodo-list !-- 循环 todos 数组为每个 todo 创建一个 app-todo-item 组件 -- !-- 将 todo 对象传递给子组件 -- !-- 监听子组件的 toggle 和 delete 事件并用自己的输出属性向上转发 -- app-todo-item *ngForlet todo of todos [todo]todo (toggle)toggle.emit($event) (delete)delete.emit($event) /app-todo-item /ul3.6 重构 AppComponent现在AppComponent变得非常简洁它只负责管理数据和协调子组件。// src/app/app.component.ts import { Component } from angular/core; import { Todo } from ./models/todo.model; Component({ selector: app-root, templateUrl: ./app.component.html, styleUrls: [./app.component.css] }) export class AppComponent { todos: Todo[] [ { id: 1, text: 学习 Angular 组件, completed: false }, { id: 2, text: 编写 TodoApp 教程, completed: true }, { id: 3, text: 理解 Input 和 Output, completed: false } ]; // 处理添加事件 onAddTodo(text: string): void { const newTodo: Todo { id: Date.now(), text: text, completed: false }; this.todos.push(newTodo); } // 处理切换事件 onToggleTodo(id: number): void { const todo this.todos.find(t t.id id); if (todo) { todo.completed !todo.completed; } } // 处理删除事件 onDeleteTodo(id: number): void { this.todos this.todos.filter(t t.id ! id); } }!-- src/app/app.component.html -- div classcontainer h1Angular Todo App (组件化版)/h1 !-- 使用 add-todo 组件监听其 add 事件 -- app-add-todo (add)onAddTodo($event)/app-add-todo !-- 使用 todo-list 组件传入数据并监听事件 -- app-todo-list [todos]todos (toggle)onToggleTodo($event) (delete)onDeleteTodo($event) /app-todo-list div classstats 总计: {{ todos.length }} | 剩余: {{ todos.filter(t !t.completed).length }} /div /div/* src/app/app.component.css */ .container { max-width: 500px; margin: 2rem auto; padding: 1rem; } .stats { margin-top: 1rem; color: #666; }运行验证此时运行应用功能应与之前完全一致但代码结构已变得清晰、可维护。这是 Angular 开发中至关重要的第一步。4. 核心改造二添加过滤功能现在我们来添加过滤功能让用户可以查看“全部”、“进行中”或“已完成”的事项。4.1 在 AppComponent 中添加过滤逻辑我们需要一个属性来存储当前过滤类型以及一个计算属性getter来根据过滤类型返回对应的列表。// src/app/app.component.ts (部分新增) export class AppComponent { todos: Todo[] [ ... ]; // 同上 filter: all | active | completed all; // 当前过滤条件 // 计算属性根据 filter 返回过滤后的列表 get filteredTodos(): Todo[] { switch (this.filter) { case active: return this.todos.filter(todo !todo.completed); case completed: return this.todos.filter(todo todo.completed); default: // all return this.todos; } } // 切换过滤条件的方法 setFilter(filter: all | active | completed): void { this.filter filter; } // ... 原有的 onAddTodo, onToggleTodo, onDeleteTodo 方法保持不变 }4.2 创建过滤按钮组件 (TodoFilterComponent)为了进一步组件化我们创建一个专门的过滤组件。ng generate component todo-filter// src/app/todo-filter/todo-filter.component.ts import { Component, Output, EventEmitter } from angular/core; // 定义过滤类型与父组件保持一致 export type FilterType all | active | completed; Component({ selector: app-todo-filter, templateUrl: ./todo-filter.component.html, styleUrls: [./todo-filter.component.css] }) export class TodoFilterComponent { // 当前激活的过滤按钮 activeFilter: FilterType all; // 输出事件当过滤条件改变时通知父组件 Output() filterChange new EventEmitterFilterType(); // 可用的过滤选项 filters: { key: FilterType; label: string }[] [ { key: all, label: 全部 }, { key: active, label: 进行中 }, { key: completed, label: 已完成 } ]; // 切换过滤条件并发射事件 setFilter(filter: FilterType): void { this.activeFilter filter; this.filterChange.emit(filter); } }!-- src/app/todo-filter/todo-filter.component.html -- div classfilters !-- 循环 filters 数组生成按钮 -- !-- 为当前激活的按钮添加 active 类 -- button *ngForlet f of filters [class.active]activeFilter f.key (click)setFilter(f.key) {{ f.label }} /button /div/* src/app/todo-filter/todo-filter.component.css */ .filters { display: flex; gap: 0.5rem; margin: 1rem 0; } .filters button { padding: 0.5rem 1rem; border: 1px solid #ddd; background: white; cursor: pointer; border-radius: 4px; } .filters button.active { background: #007bff; color: white; border-color: #007bff; }4.3 更新 AppComponent 模板修改app.component.html引入过滤组件并将todo-list的数据源从todos改为filteredTodos。!-- src/app/app.component.html -- div classcontainer h1Angular Todo App (带过滤功能)/h1 app-add-todo (add)onAddTodo($event)/app-add-todo !-- 添加过滤组件监听其 filterChange 事件 -- app-todo-filter (filterChange)setFilter($event)/app-todo-filter !-- 将 todos 改为 filteredTodos -- app-todo-list [todos]filteredTodos (toggle)onToggleTodo($event) (delete)onDeleteTodo($event) /app-todo-list div classstats 总计: {{ todos.length }} | 剩余: {{ todos.filter(t !t.completed).length }} /div /div运行验证现在页面上会出现三个过滤按钮点击它们列表会动态显示对应状态的事项。5. 核心改造三添加本地存储为了防止刷新页面后数据丢失我们使用localStorage来持久化todos数组。5.1 创建 TodoService业务逻辑尤其是数据持久化更适合放在服务Service中。服务是 Angular 依赖注入系统的核心可以被多个组件共享。ng generate service services/todo这会在src/app/services文件夹下创建todo.service.ts和todo.service.spec.ts。5.2 实现 TodoService服务负责与localStorage交互并提供数据的增删改查方法。// src/app/services/todo.service.ts import { Injectable } from angular/core; import { Todo } from ../models/todo.model; // 定义 localStorage 中使用的键名 const STORAGE_KEY angular-todo-app-todos; Injectable({ providedIn: root // 使用根注入器使服务在整个应用中都可用 }) export class TodoService { // 私有属性存储当前的 todos private todos: Todo[] []; constructor() { // 在构造函数中从 localStorage 加载数据 this.loadFromStorage(); } // 获取所有待办事项 getTodos(): Todo[] { // 返回副本避免外部直接修改内部数组 return [...this.todos]; } // 添加待办事项 addTodo(text: string): Todo { const newTodo: Todo { id: Date.now(), text: text.trim(), completed: false }; this.todos.push(newTodo); this.saveToStorage(); return newTodo; } // 切换待办事项状态 toggleTodo(id: number): void { const todo this.todos.find(t t.id id); if (todo) { todo.completed !todo.completed; this.saveToStorage(); } } // 删除待办事项 deleteTodo(id: number): void { this.todos this.todos.filter(t t.id ! id); this.saveToStorage(); } // 私有方法从 localStorage 加载数据 private loadFromStorage(): void { const stored localStorage.getItem(STORAGE_KEY); if (stored) { try { this.todos JSON.parse(stored); } catch (error) { console.error(解析 localStorage 数据失败:, error); this.todos []; } } } // 私有方法保存数据到 localStorage private saveToStorage(): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(this.todos)); } }5.3 重构 AppComponent 使用服务现在AppComponent不再直接管理todos数组而是通过TodoService来操作。// src/app/app.component.ts import { Component, OnInit } from angular/core; import { Todo } from ./models/todo.model; import { TodoService } from ./services/todo.service; // 导入服务 Component({ selector: app-root, templateUrl: ./app.component.html, styleUrls: [./app.component.css] }) export class AppComponent implements OnInit { // 不再直接声明 todos 数组 todos: Todo[] []; filter: all | active | completed all; // 在构造函数中注入 TodoService constructor(private todoService: TodoService) {} // 在组件初始化时从服务获取数据 ngOnInit(): void { this.todos this.todoService.getTodos(); } get filteredTodos(): Todo[] { switch (this.filter) { case active: return this.todos.filter(todo !todo.completed); case completed: return this.todos.filter(todo todo.completed); default: return this.todos; } } setFilter(filter: all | active | completed): void { this.filter filter; } // 调用服务的方法来添加事项并更新本地数组 onAddTodo(text: string): void { const newTodo this.todoService.addTodo(text); this.todos.push(newTodo); // 或者重新获取 this.todos this.todoService.getTodos(); } // 调用服务的方法切换状态并更新本地数组的对应项 onToggleTodo(id: number): void { this.todoService.toggleTodo(id); const todo this.todos.find(t t.id id); if (todo) { todo.completed !todo.completed; } } // 调用服务的方法删除事项并更新本地数组 onDeleteTodo(id: number): void { this.todoService.deleteTodo(id); this.todos this.todos.filter(t t.id ! id); } }重要提示上面的onToggleTodo和onDeleteTodo中我们手动更新了this.todos。这是一种简单的同步方式。更优雅的做法是让TodoService的方法返回操作后的新数组或者使用 RxJS 的BehaviorSubject来实现响应式数据流这样组件只需订阅数据变化即可。为了教程清晰我们采用了直观的手动更新方式。运行验证现在添加、切换、删除待办事项后刷新浏览器页面数据依然存在。你可以打开浏览器的开发者工具F12在Application-Local Storage下看到存储的数据。6. 常见问题与排查思路在实现上述功能时你可能会遇到一些典型问题。问题现象可能原因解决思路ngModel无法使用模板报错未在所属的NgModule中导入FormsModule或ReactiveFormsModule。检查组件所在的模块如AppModule确保在imports数组中导入了FormsModule。子组件中的Input()属性值为undefined1. 父组件没有正确绑定属性。2. 子组件在属性输入前就试图使用它如在constructor或ngOnInit中。1. 检查父组件模板中的属性绑定语法[propertyName]value。2. 使用ngOnChanges生命周期钩子来响应输入属性的变化或在模板中使用安全导航操作符?.。事件Output()发射后父组件没反应1. 父组件模板中事件绑定写法错误。2. 子组件中事件发射器未正确初始化或发射。1. 检查父组件模板中的事件绑定语法(eventName)handler($event)。2. 在子组件中确保使用EventEmitter的emit()方法并传递了正确的数据。服务Service中的修改未反映到视图数据不是响应式的。直接修改对象或数组的内部属性Angular 变更检测可能无法捕捉。对于数组使用返回新数组的方法如filter,map,slice, 扩展运算符...。对于对象创建新对象。或者考虑使用ChangeDetectorRef.detectChanges()手动触发检测或使用 RxJSBehaviorSubject。localStorage数据在隐私模式下无效浏览器的隐私无痕模式可能会限制或禁用localStorage。这是正常行为。在生产应用中需要增加错误处理例如尝试使用try...catch并考虑提供降级方案如仅内存存储。过滤功能切换后列表没变化filteredTodos计算属性依赖的todos或filter属性没有被正确更新。1. 检查setFilter方法是否被正确调用并更新了this.filter。2. 确认todos数组的引用是否在增删操作后发生了变化对于使用push等原地修改的方法Angular 可能检测不到变化此时需要手动触发变更检测或改变数组引用this.todos [...this.todos]。7. 最佳实践与工程建议通过这个改造项目我们可以总结出一些 Angular 开发的最佳实践单一职责原则每个组件只做一件事。AddTodoComponent只负责输入TodoItemComponent只负责展示单个项目TodoFilterComponent只负责过滤切换。这使得每个组件易于理解、测试和复用。状态提升将共享的状态如todos和操作该状态的逻辑提升到它们共同的最近父组件AppComponent或注入的服务TodoService中。避免在多个子组件中复制状态和逻辑。使用服务管理业务逻辑和数据将数据获取、持久化、复杂计算等逻辑封装在服务中。组件应专注于视图渲染和用户交互。这使得逻辑更清晰也便于单元测试。定义清晰的接口Interface使用 TypeScript 接口如Todo来定义数据模型。这提供了良好的类型安全性和代码提示是构建可靠应用的基础。善用 Angular CLI使用ng generate命令创建组件、服务、模块等它能自动更新模块声明和引用减少手动配置的错误。为输入输出属性使用明确的类型避免使用any。为Input()和Output()定义具体的类型这能极大提升代码的可维护性。考虑使用响应式状态管理对于更复杂的应用手动同步组件和服务间的状态会变得繁琐。可以考虑引入轻量级状态管理方案如使用 RxJS 的BehaviorSubject在服务中创建可观察的数据流组件通过订阅来获取更新。样式封装Angular 默认使用模拟Emulated的视图封装组件的样式不会影响到其他组件。利用这一点可以为每个组件编写独立的、作用域内的 CSS避免全局样式污染。生产环境注意事项错误处理对localStorage的操作、网络请求等可能失败的操作务必添加try...catch进行错误处理并给用户适当的反馈。性能对于超长列表考虑使用虚拟滚动如angular/cdk/scrolling。可访问性为按钮、输入框添加适当的 ARIA 标签确保键盘导航友好。至此我们已经完成了一个 Angular TodoApp 从“简单实现”到“结构化、可维护、功能增强”的完整改造。你不仅实现了一个应用更重要的是实践了组件化设计、父子通信、服务注入等 Angular 核心开发模式。接下来你可以尝试更多的挑战例如添加编辑待办事项的功能、实现拖拽排序、将过滤状态保存到 URL路由、或者使用更正式的后端 API 替换localStorage。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价