Angular 组件输出(output)完全指南:用 output() 函数打通子组件到父组件的通信链路
组件之间的通信是 Angular 应用开发的核心基本功:当子组件内部发生了"点击了按钮""列表新增/移除了某项""某个重要状态更新了"等事件时,需要把这一信息向上通知给父组件。本文基于 Angular 官方入门教程 Learn Angular 的 Component output properties(第 9 步),完整讲解用 output() 函数定义组件输出属性、用 emit() 派发事件、用模板事件绑定接收事件的完整链路,并结合本仓库中 output() 的真实实现源码,带你从"会写"进阶到"懂原理"。
读完后你将掌握:如何在子组件中声明类型安全的输出事件、如何在父组件模板中通过 (eventName)="handler($event)" 语法订阅事件、事件数据在两层组件间的流转方式,以及 output() 底层 OutputEmitterRef 的工作机制与命名、别名、编程式订阅等进阶技巧。
为什么需要组件输出(Output)
在父子组件结构中,数据与事件的流向是分层的:
- 父 → 子(属性下行):通过
input()或@Input把数据传给子组件(对应 Learn Angular 第 8 步 Component input); - 子 → 父(事件上行):子组件不能直接修改父组件的状态,只能通过"发射事件"通知父组件"某件事发生了",由父组件决定如何响应。实现这条上行通道的方式就是 output(输出属性)。
在 Learn Angular 教程 的组件基础学习路径中,第 9 步(adev/src/content/tutorials/learn-angular/steps/9-output/README.md)正是负责讲解这一环——它是 8 步 input(数据流入)、9 步 output(事件流出)之后整个组件通信基础链路的收官,完成后即具备了组件交互的全部基础能力。
核心概念:output() 函数与自定义事件
Angular 使用 output() 函数来开启"子组件通知父组件"的能力。它替代了旧版 @Output + EventEmitter 的声明方式,使用起来更简洁、更符合现代组件编写的习惯。
创建一个输出属性的基本形式是:用 output 函数初始化一个类属性(class property),并声明该事件将要携带的数据类型:
import {Component, output} from '@angular/core';
@Component({...})
class Child {
incrementCountEvent = output<number>();
}
<number> 是事件的泛型类型。不带泛型时等价于 output<void>(),表示"只通知事件发生了,不携带数据"。
创建完成后,子组件便可以在内部任意位置"触发"事件,触发方式是调用输出属性上的 emit 方法:
class Child {
count = 0;
onClick() {
this.count++;
this.incrementCountEvent.emit(this.count);
}
}
emit 派发的事件携带的数据类型与声明输出时的泛型一致——在本例中,父组件监听到事件后收到的 $event 就是 number 类型的计数值。
output() 的声明式来源:从源码看真实签名
output() 并非黑盒魔法。查看其真实实现 packages/core/src/authoring/output/output.ts:
export function output<T = void>(opts?: OutputOptions): OutputEmitterRef<T> {
ngDevMode && assertInInjectionContext(output);
return new OutputEmitterRef<T>();
}
从源码可以确认三个事实:
- 泛型默认值是
void:output()与output<void>()完全等价,只通知不传值; - 支持可选的
OutputOptions:目前该类型只含一个可选字段alias,用于自定义事件在模板中的外部名称(见下文"自定义事件名"); - 必须在注入上下文中调用:源码第一行
assertInInjectionContext(output)表明,output()应当在组件/指令的字段初始化位置(即类构造期间)调用。如果在非注入上下文(例如普通函数里)调用,开发模式下会直接报错,从而避免运行时意外。
上手实战:让"添加海龟"按钮打通父子通信
教程中的第 9 步提供了一个可编辑的练习(config.json 将任务打开为 child.ts 与 app.ts 两个文件)。场景是:App(父)展示一个列表,Child(子)渲染一个 "Add Item" 按钮,点击按钮后父组件需要把新项加入列表。开始时 Child 还没有任何输出能力,addItem() 方法为空、模板里的 <app-child /> 也没有事件监听,一切都等待你来补齐。
练习按以下三个步骤依次完成。
第 1 步:在子组件中添加 output() 属性
修改 child.ts,添加一个名为 addItemEvent 的输出属性,并把输出类型设置为 string:
import {Component, output} from '@angular/core';
@Component({
selector: 'app-child',
styles: `
.btn {
padding: 5px;
}
`,
template: ` <button class="btn" (click)="addItem()">Add Item</button> `,
})
export class Child {
readonly addItemEvent = output<string>();
addItem() {}
}
建议加上 readonly 修饰:输出属性只允许由组件自身调用 emit,不允许外部重新赋值,readonly 能在编译期就约束这一语义(最终解答 answer/src/app/child.ts 正是如此声明)。
第 2 步:完成 addItem 方法并派发事件
让按钮点击真正产生一个携带字符串数据的输出事件:
addItem() {
this.addItemEvent.emit('🐢');
}
模板里的 (click)="addItem()" 一旦被触发,addItem() 就会调用 addItemEvent.emit('🐢'),向父组件发出一个值为字符串 '🐢' 的事件。这就是本教程约定的"新增一个列表项"的信号。
第 3 步:在父组件模板中监听事件
修改 app.ts 的模板,为 <app-child /> 添加事件绑定:
<app-child (addItemEvent)="addItem($event)" />
这里用到了 Angular 模板中监听自定义事件的标准语法:圆括号包裹事件名 = 回调函数。addItemEvent 是子组件暴露的输出属性名,addItem($event) 是父组件提供的处理方法,$event 中装的就是子组件 emit 出来的 '🐢' 字符串。
配合父组件既有的处理逻辑:
import {Component} from '@angular/core';
import {Child} from './child';
@Component({
selector: 'app-root',
template: `
<app-child (addItemEvent)="addItem($event)" />
<p>🐢 all the way down {{ items.length }}</p>
`,
imports: [Child],
})
export class App {
items = new Array();
addItem(item: string) {
this.items.push(item);
}
}
至此整条链路已打通:每点击一次 "Add Item" 按钮,Child.addItem() 便 emit 一个字符串;父组件的 addItem 方法把收到的值 push 进 items 数组;页面上的 {{ items.length }} 会随事件驱动自动更新渲染。这正是事件驱动的 Angular 应用中典型的"内部状态变化 → 事件通知 → 父组件响应"模式。
$event 与事件数据:类型安全的双向约定
注意到上例中父子双方形成了一种隐式约定:
- 子组件声明
output<string>(),保证emit出去的值永远是string; - 父组件用
addItem(item: string)接收,保证拿到的$event按string处理。
这层约定是类型安全的:如果某天 output<string> 被改成 output<number>,TypeScript 编译器会立即提示两处需要同步调整的地方,避免"改了一端、另一端悄悄坏掉"的隐患。如果确实不需要传数据,直接声明 output() 即可,父组件侧也无需使用 $event。
深挖原理:OutputEmitterRef 内部是如何工作的
output() 返回的对象类型是 OutputEmitterRef<T>,它的实现位于 packages/core/src/authoring/output/output_emitter_ref.ts。阅读这份实现,可以直观理解一次事件从订阅到派发的完整生命周期:
- 监听器列表:内部用
listeners: Array<(value: T) => void> | null保存所有订阅者,模板绑定、编程式subscribe最终都会注册为这里的回调; - 自动清理:构造时通过
destroyRef.onDestroy(...)注册清理逻辑,组件/指令销毁时监听器列表被置空并标记destroyed,避免内存泄漏与"对已销毁组件发事件"的诡异问题; - emit 的健壮性:
emit会逐个调用监听器,单个监听器抛错会被注入的ErrorHandler捕获而不影响其余监听器执行;对已销毁的 ref 再emit只会输出一条开发警告而不会崩溃; - emit 期间退订:为了防止在遍历监听器的过程中修改数组导致错乱,实现采用"先把退订位置置为
null,派发结束后再统一剔除"的策略(removeNullValues)——这是源码中值得学习的边界处理细节; - 与信号机制的关系:
emit执行期间会通过setActiveConsumer(null)暂时脱离信号响应上下文,保证事件监听回调里对信号值的读取不会被误追踪为副作用依赖。
这些实现细节意味着:对使用者来说,output() 声明的事件天然具备"随组件生命周期自动管理、单监听器出错不影响整体派发、类型安全"等优良特性,无需自己手写一套发布订阅。
进阶扩展:事件名、别名与编程式订阅
官方组件指南 guide/components/outputs 对输出属性提供了更完整的延伸用法,在这里一并整理。
自定义事件名(别名)
output() 的可选参数 alias 允许你在不改动 TypeScript 属性名的前提下,为模板中的事件换一个对外名称:
@Component({...})
export class CustomSlider {
changed = output({alias: 'valueChanged'});
}
<custom-slider (valueChanged)="saveVolume()" />
alias 只影响模板中的绑定名,TypeScript 代码中仍然使用属性名 changed 调用 emit。别名主要用在两种场景:为重构后的新属性保留旧名称以兼容既有模板,或避免与原生 DOM 事件(如 click、change)撞名造成混淆。
以编程方式订阅输出
除了模板事件绑定,output() 返回的 OutputRef 还暴露了 subscribe 方法,适合动态创建组件后以命令式方式监听事件:
const someComponentRef: ComponentRef<SomeComponent> = viewContainerRef.createComponent(/*...*/);
someComponentRef.instance.someEventProperty.subscribe((eventData) => {
console.log(eventData);
});
订阅会在宿主组件销毁时由 Angular 自动清理;也可以手动退订——subscribe 返回的 OutputRefSubscription 带有 unsubscribe 方法:
const eventSubscription = someComponent.someEventProperty.subscribe((eventData) => {
console.log(eventData);
});
// 需要提前结束时手动退订
eventSubscription.unsubscribe();
这正是 OutputEmitterRef 中 subscribe/unsubscribe 实现(见 output_emitter_ref.ts)对外暴露的能力。
事件命名的三条建议
指南中对输出事件命名给出明确约定,值得作为团队规范:
- 避免与原生 DOM 事件撞名(如
click、keydown),否则模板中无法区分事件属于组件还是 DOM 元素; - 不需要像组件选择器那样加前缀——一个元素只能承载一个组件,其上的自定义事件理应全部归属于该组件;
- 统一使用 camelCase,且不要加
on前缀,如valueChanged、itemRemoved。
传统方案 @Output + EventEmitter 依然可用
若你的项目仍在使用旧式装饰器风格,官方指南明确说明 @Output 写法完全受支持,只是新项目推荐优先使用 output() 函数:
@Component(/* ... */)
export class ExpandablePanel {
@Output() panelClosed = new EventEmitter<void>();
}
相应的别名写法为 @Output('valueChanged') changed = new EventEmitter<number>()。此外,无论哪种声明方式,都可以通过 @Component 装饰器中的 outputs 数组属性来声明输出,这在"子类继承基类中的输出属性"场景下特别有用——具体示例同样见 guide/components/outputs。
小结:组件基础通信的收官一步
本步位于 Learn Angular 教程第 9 步(9-output),与其前一课 8-input 共同构成了"输入数据 + 输出事件"的组件通信闭环。完整解答代码可在 answer/src/app/child.ts 与 answer/src/app/app.ts 中对照校验。
回顾本课核心要点:
| 环节 | 关键 API | 作用 |
|---|---|---|
| 声明输出 | output<T>() |
定义类型安全的输出属性,返回 OutputEmitterRef<T> |
| 派发事件 | ref.emit(value) |
向所有监听者广播数据,value 类型与 T 一致 |
| 模板订阅 | (name)="handler($event)" |
父组件模板中监听子组件事件并接收 $event |
| 编程式订阅 | ref.subscribe(cb) / unsubscribe() |
动态场景下命令式监听与手动退订 |
至此,组件基础能力(输入、输出、模板、数据流)已经集齐。后续课程将在此基础上继续探索延迟视图、路由、表单与服务注入等进阶主题,而这些上层能力无一不是建立在"input 下行数据、output 上行事件"这套扎实的通信模型之上的。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00