Angular 快速入门:组件间通信

前两篇我们用 Todo 应用学完了模板、绑定、指令。但到目前为止,所有代码都写在 TodoComponent 这一个组件里

真实项目里没人这么干。一个组件应该只负责一件事。比如:

AppComponent
  └── TodoComponent(待办列表容器)
        └── TodoItemComponent(单个待办项)

但问题来了:组件之间怎么"说话"?

  • 父组件怎么把数据传给子组件?
  • 子组件怎么通知父组件"用户点了删除"?
  • 非父子关系的组件怎么共享数据?

这篇我们一个个解决。

1. 父传子:@Input

先从最简单的场景开始:TodoComponent 把每一条待办数据传给 TodoItemComponent

第一步:创建子组件

ng g c todo-item

这会生成 app/todo-item/ 目录。

第二步:用 @Input 声明输入属性

打开 todo-item/todo-item.ts,加上 @Input 装饰器:

import { Component, Input } from '@angular/core';   // ← 导入 Input

@Component({
  selector: 'app-todo-item',
  standalone: true,
  imports: [],
  templateUrl: './todo-item.html',
  styleUrls: ['./todo-item.css']
})
export class TodoItemComponent {
  @Input() todo: any;   // ← 声明一个输入属性
}

@Input() todo 的意思是:这个组件的 todo 属性,可以由父组件通过属性绑定传进来。

模板 todo-item.html 先简单展示数据:

<li>
  {{ todo.text }} - {{ todo.done ? '已完成' : '未完成' }}
</li>

第三步:父组件传值

回到 TodoComponent,先导入子组件,然后在模板里使用:

修改 todo/todo.ts

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodoItemComponent } from '../todo-item/todo-item';   // ← 导入子组件

@Component({
  selector: 'app-todo',
  standalone: true,
  imports: [CommonModule, FormsModule, TodoItemComponent],    // ← 加上子组件
  templateUrl: './todo.html',
  styleUrls: ['./todo.css']
})
export class TodoComponent {
  // ... 之前的代码不变
}

修改 todo/todo.html,把原来的 <li> 换成自定义组件,用 [todo] 传值:

<ul *ngIf="todos.length > 0; else emptyState">
  <app-todo-item
    *ngFor="let item of todos"
    [todo]="item">
  </app-todo-item>
</ul>

看明白了吗?[todo]="item"——方括号是属性绑定,把 item 绑定到子组件的 @Input() todo 上。

跨框架对比:Vue 的 props: ['todo'],父组件传 <Child :todo="item" />;React 直接 <Child todo={item} />,子组件用 props.todo 接收。Angular 用 @Input() 装饰器显式声明,用的也是方括号 [] 绑定——跟前一篇学的属性绑定是同一个机制,只不过这次绑到的是子组件的输入属性,不是 HTML 原生属性。

给类型加上去

刚才写的是 @Input() todo: any,不够好。Angular 推荐用 TypeScript 接口定义数据结构。

app/ 下建一个 types.ts

export interface Todo {
  id: number;
  text: string;
  done: boolean;
}

然后在组件里用:

import { Todo } from '../types';

@Input() todo!: Todo;

! 号是 TypeScript 的"非空断言",意思是"这个属性一定会在使用前被赋值"(Angular 会在初始化时由父组件传入)。

2. 子传父:@Output + EventEmitter

现在数据传进去了。但用户点击待办项时,怎么通知父组件切换状态?

子组件自己改不了数据(数据在父组件的 todos 数组里)。它需要发射一个事件,让父组件来处理。

在子组件里声明事件

修改 todo-item/todo-item.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-todo-item',
  standalone: true,
  imports: [CommonModule],       // ← 在模板里用 class 绑定需要 CommonModule
  templateUrl: './todo-item.html',
  styleUrls: ['./todo-item.css']
})
export class TodoItemComponent {
  @Input() todo: any;
  @Output() toggle = new EventEmitter<number>();  // ← 声明输出事件
}

EventEmitter<number>() 的意思是:这个事件发射时,会带一个 number 类型的值(待办的 id)。

在模板里触发事件

<li (click)="onToggle()" [class.done]="todo.done">
  {{ todo.text }} - {{ todo.done ? '已完成' : '未完成' }}
</li>

对应的组件方法:

export class TodoItemComponent {
  @Input() todo: any;
  @Output() toggle = new EventEmitter<number>();

  onToggle() {
    this.toggle.emit(this.todo.id);   // ← 发射事件,带上 id
  }
}

父组件监听事件

回到 TodoComponent 的模板,用圆括号监听 toggle 事件:

<app-todo-item
  *ngFor="let item of todos"
  [todo]="item"
  (toggle)="toggleDone($event)">
</app-todo-item>

(toggle)="toggleDone($event)"——圆括号是事件绑定,跟监听原生 click 事件一个道理。$event 就是子组件 emit 出来的值(如 id)。

跨框架对比

  • Vue:子组件 this.$emit('toggle', id),父组件 @toggle="handleToggle"
  • React:父传一个函数 onToggle={handleToggle},子组件调用 props.onToggle(id)
  • Angular@Output() toggle = new EventEmitter<T>(),父组件 (toggle)="fn($event)"

Angular 和 Vue 的"事件发射"模式最像——都叫 emit。React 更直白,就是传回调函数。写法不同,本质一样:子组件发信号,父组件响应

再加一个删除功能

子组件加一个删除按钮和对应事件:

<li (click)="onToggle()" [class.done]="todo.done">
  {{ todo.text }} - {{ todo.done ? '已完成' : '未完成' }}
  <button (click)="onDelete(); $event.stopPropagation()"></button>
</li>

注意到 (click) 里写了两个语句,用分号隔开:onDelete(); $event.stopPropagation()

Angular 的事件绑定支持写多个语句,按顺序执行。 这里是先删除(onDelete()),再阻止事件冒泡($event.stopPropagation())。

为什么要阻止冒泡?<button> 嵌在 <li> 里,而 <li> 自己也有 (click) 事件。如果不阻止冒泡,点击删除按钮会同时触发 <li>onToggle()——结果就是待办被删了,但 Angular 还是会尝试执行切换,可能报错。stopPropagation() 就是防止事件"冒泡"到父元素。

对比其他框架:

  • Vue:用事件修饰符 @click.stop="onDelete",更简洁但不够灵活。
  • React:不支持模板里写多语句,通常在函数里自己调 e.stopPropagation()
  • Angular:支持模板里写 fn(); $event.preventDefault() 这种链式调用。分号左边执行逻辑,右边处理事件对象,是一种常见写法。
@Output() delete = new EventEmitter<number>();

onDelete() {
  this.delete.emit(this.todo.id);
}

父组件模板加上 (delete)

<app-todo-item
  *ngFor="let item of todos"
  [todo]="item"
  (toggle)="toggleDone($event)"
  (delete)="deleteTodo($event)">
</app-todo-item>

父组件加删除方法:

deleteTodo(id: number) {
  this.todos = this.todos.filter(item => item.id !== id);
}

@Input / @Output 完整图解

用一张图来理解:

父组件(TodoComponent)
│
├─ [todo]="item"       → 传入数据    → 子组件的 @Input() todo
│
├─ (toggle)="fn($event)" ← 监听事件  ← 子组件的 @Output() toggle
│
└─ (delete)="fn($event)" ← 监听事件  ← 子组件的 @Output() delete

父传子用属性绑定 [],子传父用事件绑定 () 跟绑定原生 HTML 属性/事件用的是同一套语法。

3. @ViewChild — 父组件直接拿子组件

有时候父组件需要直接调用子组件的方法或访问它的属性。这时候用 @ViewChild

比如给 TodoItemComponent 加一个高亮方法:

export class TodoItemComponent {
  @Input() todo: any;
  @Output() toggle = new EventEmitter<number>();

  highlight() {
    console.log(`高亮待办: ${this.todo.text}`);
  }
}

父组件里用 @ViewChild 获取子组件实例:

import { Component, ViewChild } from '@angular/core';
import { TodoItemComponent } from '../todo-item/todo-item';

export class TodoComponent {
  @ViewChild(TodoItemComponent) child!: TodoItemComponent;

  someMethod() {
    this.child.highlight();   // 直接调用子组件的方法
  }
}

但这里有个坑:如果有多个同类型的子组件(*ngFor 渲染出来的),@ViewChild 只返回第一个。要拿所有的,用 @ViewChildren

import { Component, ViewChildren, QueryList } from '@angular/core';

@ViewChildren(TodoItemComponent) children!: QueryList<TodoItemComponent>;

@ViewChild 对比其他框架:React 的 useRef 和 Vue 的 $refs 类似——都是拿到子组件/子元素的引用。但 Angular 的 @ViewChild 在视图初始化后才能访问,不是一创建就能用。后面讲生命周期时再细说。

4. 模板引用变量 #var — 最简单的引用

如果只是想在模板里引用某个元素或组件,可以用 # 语法。

<input #newInput [(ngModel)]="newTodoText" placeholder="输入新的待办...">
<button (click)="addTodo(); newInput.focus()">添加</button>

#newInput 就是一个模板引用变量,直接在模板层面引用 <input> 元素,调用它的 focus() 方法。

也可以引用子组件:

<app-todo-item #item *ngFor="..." [todo]="item"></app-todo-item>

但模板引用变量只能在模板范围内用,组件类里拿不到。需要组件类里用的场景,就用 @ViewChild

5. 非父子组件通信:Service

如果两个组件没有直接的父子关系——比如一个是 Header,一个是 Todo 列表——怎么通信?

答案是用 Service(服务)作为中介。这是 Angular 最推荐的方案。

这块内容比较多,下一篇讲依赖注入时会详细展开。这里先给你一个直觉:

组件A ──调用──→ Service(共享数据) ←──调用── 组件B

两个组件都注入同一个 Service,通过 Service 的数据或事件流来通信。Vue 里类似 Vuex/Pinia,React 里类似 Context/Redux,但 Angular 用 Service + RxJS 就能实现,不需要额外的状态管理库。

6.完整代码:重构后 Todo 应用

把上面拆出来的 TodoItemComponent 汇总一下。

todo-item/todo-item.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-todo-item',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './todo-item.html',
  styleUrls: ['./todo-item.css']
})
export class TodoItemComponent {
  @Input() todo: any;
  @Output() toggle = new EventEmitter<number>();
  @Output() delete = new EventEmitter<number>();

  onToggle() {
    this.toggle.emit(this.todo.id);
  }

  onDelete() {
    this.delete.emit(this.todo.id);
  }
}

todo-item/todo-item.html

<li (click)="onToggle()" [class.done]="todo.done">
  {{ todo.text }} - {{ todo.done ? '已完成' : '未完成' }}
  <button (click)="onDelete(); $event.stopPropagation()"></button>
</li>

todo-item/todo-item.css

.done {
  text-decoration: line-through;
  color: #999;
}

todo/todo.ts 核心部分:

import { TodoItemComponent } from '../todo-item/todo-item';

@Component({
  // ...
  imports: [CommonModule, FormsModule, TodoItemComponent]
})
export class TodoComponent {
  // ... title, newTodoText, todos ...

  toggleDone(id: number) { /* ... */ }

  addTodo() { /* ... */ }

  deleteTodo(id: number) {
    this.todos = this.todos.filter(item => item.id !== id);
  }
}

todo/todo.html

<h2>{{ title }}</h2>

<div>
  <input [(ngModel)]="newTodoText" placeholder="输入新的待办...">
  <button (click)="addTodo()">添加</button>
</div>

<ul *ngIf="todos.length > 0; else emptyState">
  <app-todo-item
    *ngFor="let item of todos"
    [todo]="item"
    (toggle)="toggleDone($event)"
    (delete)="deleteTodo($event)">
  </app-todo-item>
</ul>

<ng-template #emptyState>
  <p>还没有待办,输入一条开始吧 🎉</p>
</ng-template>

这篇我们用 TodoItemComponent 的拆分,把组件间通信的几种方式都串了一遍。

7.本章总结

核心就三句话:

  • @Input:父组件传数据给子组件,用 [] 绑定
  • @Output + EventEmitter:子组件发信号给父组件,用 () 监听
  • @ViewChild / #var:引用子组件或 DOM 元素

下一章我们来讲服务与依赖注入——怎么把数据逻辑从组件里抽出来,变成一个可复用的 Service,以及组件之间通过 Service 通信的完整方案。

更多推荐