从零开始学习 Angular 组件:选择器、模板、输入与输出
现代独立 Angular 组件实用指南:文件结构、装饰器、选择器、模板、作用域样式,以及通过 input() 和 output() 实现的父子组件数据流。
一旦 Angular 项目创建并运行起来,接下来的问题就是如何构建界面本身。答案就是组件:这些小型、独立的单元各自负责界面的某一部分功能。本指南将详细介绍组件的构成,说明独立组件与基于旧版 NgModule 的代码相比有何不同,以及父组件和子组件如何利用当前的 input() 和 output() API 进行数据与事件的交互。读完之后,您应该能够创建组件、将其集成到页面中,并实现双向信息传递。
如果您还需要一个项目进行实验,关于搭建现代 Angular 22 项目的指南涵盖了本文所假设的配置及文件夹结构。
为何 Angular 要将 UI 拆分为组件
想象一个普通的网页:顶部有导航栏,侧面有侧边栏,中间是产品内容,底部则是页脚。你可以把所有的标记、样式和行为都放在一个巨大的文件中,但这样很快就会变得难以阅读且更难修改。Angular 则建议将页面拆分成多个部分,每个部分只负责一项功能。
Angular Application
│
├── Navbar Component
├── Sidebar Component
├── Product Component
├── Login Component
└── Footer Component
一个组件通常包含四部分:用于存储状态和定义行为的 TypeScript 类、描述页面结构的 HTML 模板、控制其外观的样式,以及告知 Angular 如何处理该组件的元数据。由于每部分都有明确的职责,因此你可以单独分析导航栏而无需考虑页脚,在多个位置重复使用相同的商品卡片,并独立测试 UI 的各个部分。登录表单、个人资料面板或控制台小部件都是合适的示例。
独立组件是默认选择
许多 Angular 教程,尤其是较旧的那些,会将组件组织在 NgModule 中,该模块用于声明组件并列出其依赖项。现代 Angular 已不再采用这种模式。现在组件默认为独立存在,因此无需在模块中注册即可单独使用。一个最简单的组件如下所示:
import { Component } from '@angular/core';
@Component({
selector: 'app-welcome',
templateUrl: './welcome.html',
styleUrl: './welcome.css'
})
export class WelcomeComponent {
}
你可能会期望看到类似以下这行代码那样的标记,用来指示组件是独立的,但实际上已不再需要:
standalone: true
不指定该选项也会产生相同效果,因为独立组件本身就是默认行为。当一个独立组件需要其他组件、指令或管道时,它会在自身的 @Component 配置中的 imports 数组中列出这些依赖项。这样做的好处是便于查看:只需打开一个文件就能清楚了解该组件依赖了什么,而无需在项目的其他模块定义中四处查找。
组件在磁盘上的存储结构
一个典型的组件会位于自己的文件夹中,其中包含三个并列的文件:
welcome/
│
├── welcome.ts
├── welcome.html
└── welcome.css
.ts 文件用于存放逻辑代码,.html 文件用于存放标记内容,而 .css 文件则用于存放样式。以下是一个简单欢迎组件的各个文件可能包含的内容。首先是类定义,它向模板暴露了一个 title 属性:
import { Component } from '@angular/core';
@Component({
selector: 'app-welcome',
templateUrl: './welcome.html',
styleUrl: './welcome.css'
})
export class WelcomeComponent {
title = 'Welcome to Angular 22';
}
模板使用双大括号来引用该属性:
<h1>{{ title }}</h1>
<p>
This is my first Angular component.
</p>
而样式表仅作用于该模板中的元素:
h1 {
color: #dd0031;
}
p {
font-size: 18px;
}
@Component()装饰器起到了连接作用。它将模板和样式表告知Angular,从而使这三个文件作为一个整体协同工作,如下图所示:
welcome.ts
│
├── Logic
│
├── welcome.html
│ ↓
│ UI
│
└── welcome.css
↓
Style
Angular的风格指南建议将这类紧密相关的文件放在同一目录中,而这正是当前布局所采用的方案。当组件规模扩大时,你可以清楚地知道其标记、样式和逻辑分别位于何处。
@Component()装饰器向Angular传达的信息
该装饰器会为类添加元数据。正是这些元数据让Angular了解类的用途以及如何渲染它:
@Component({
selector: 'app-welcome',
templateUrl: './welcome.html',
styleUrl: './welcome.css'
})
这里有三个属性承担了大部分工作:
selector用于指定在其它模板中放置该组件的标签名称。templateUrl指向包含该组件标记的HTML文件。styleUrl指向该组件的样式表。
还有其他选项,比如用于引入其他组件或Angular功能的imports,以及本文后面会介绍的templateUrl和styleUrl的替代方案。无需一开始就全部掌握,随着构建更多组件,这些内容会逐渐变得熟悉。
选择器将类转换为自定义元素
选择器是Angular在模板中查找的标识符。在此配置中,它被设置为app-welcome:
@Component({
selector: 'app-welcome',
...
})
因此,选择器值本身很简单:
app-welcome
无论希望组件显示在何处,只需像使用HTML标签一样写出该名称即可:
<app-welcome></app-welcome>
当Angular在渲染模板时遇到<app-welcome>,它会创建对应组件的实例,并在该元素内部渲染其模板。与选择器匹配的元素被称为组件的宿主元素,它会一直存在于DOM中,这在后续需要为整个组件设置样式或添加行为时非常重要。
Angular CLI 会使用为你的应用程序配置的前缀来生成选择器,通常是 app-,不过在生成组件时你可以选择不同的选择器。这个前缀并非仅用于美观:自定义元素名称必须包含连字符,而项目专用的前缀则能避免你的组件与原生元素或第三方库中的标签发生冲突。
模板将标记语与组件数据相连
模板决定了用户最终看到的内容。最简单的情况下,它就是普通的 HTML:
<h1>Welcome!</h1>
<p>We are learning Angular components.</p>
让 Angular 模板比静态 HTML 更强大的地方在于,它们能够读取数据并调用在组件类中定义的行为。假设该类声明了一个属性:
name = 'Mubbassir';
那么模板就可以通过插值来显示该属性的内容:
<h1>Hello {{ name }}!</h1>
Angular会保持渲染输出与数据的一致性:当绑定值发生变化时,页面会自动更新,无需手动操作DOM。TypeScript类与其模板之间的这种关联是Angular所有功能的基础,从事件处理到表单操作都是以此为依托的。
组件样式默认为作用域隔离
每个组件都可以拥有自己的样式:
.card {
padding: 20px;
border-radius: 10px;
}
h2 {
color: #dd0031;
}
默认情况下,Angular会对这些规则应用模拟视图封装机制。在构建阶段,它会重写选择器并为组件的元素添加生成的属性,因此像h2 { ... }这样的规则只会匹配该组件模板中的
元素,而不会影响应用程序中的其他所有标题元素。在拥有数百个组件的应用中,这种机制能够防止某个地方的样式修改意外破坏其他部分的外观。
封装并不排除共享样式的使用。那些应适用于所有地方的规则,比如字体设置、重置样式或布局工具类,应当放在应用程序的全局样式表中,而组件文件则用于存储该组件特有的样式。
使用 Angular CLI 生成组件
手动创建文件也是可行的,但使用 CLI 可以更快完成,并且命名更加统一。在项目根目录下执行以下命令:
ng generate component user-card
或者使用简写形式:
ng g c user-card
生成器会创建类似这样的文件夹(规格文件名后的多余 = 是示例中的笔误,并非文件名的一部分):
user-card/
├── user-card.ts
├── user-card.html
├── user-card.css
└── user-card.spec.ts=
新的文件命名风格比旧的 user-card.component.ts 命名规范更简短。是否创建 .spec.ts 测试文件取决于项目的测试配置。遵循更新后风格指南的较新 CLI 版本通常也会生成不带 Component 后缀的类名(例如 UserCard);下面的示例仍保留了该后缀,只要导入方式一致,两种命名方式均可使用。
父组件与子组件如何相互通信
实际界面是由组件构成的树结构,这些组件需要互相传递信息。假设有一个父组件知晓用户信息,而子组件的职责则是显示该用户信息。父组件需要一种方式将数据传递给子组件,子组件则需要在发生某些事件(如按钮被点击)时向父组件反馈信息。
Angular 使用 inputs 处理向下传递的数据,使用 outputs 处理向上传递的数据:
Parent Component
│
│ Data
│
▼
Child Component
│
│ Event
│
▼
Parent Component
对于新代码,Angular 推荐使用基于信号的 input() 函数和 output() 函数。基于装饰器的 @Input() 和 @Output() API 仍然得到完全支持,因此在现有代码库中仍会看到它们的使用。
使用 input() 向下传递数据
首先从子组件类开始:
import { Component, input } from '@angular/core';
@Component({
selector: 'app-user-card',
templateUrl: './user-card.html',
styleUrl: './user-card.css'
})
export class UserCardComponent {
name = input.required<string>();
}
关键所在是 input 的声明行:
name = input.required<string>();
它表明 UserCardComponent 需要使用它的对象提供一个 name 字符串。通过使用 input.required() 可以使这一要求更加严格:如果父组件忘记绑定 name,Angular 会报错,而不会默许显示空值。对于可选输入,则使用普通的 input() 并设置默认值即可。
由于 name 是一个信号而非普通属性,因此需要通过调用它的名称来读取其值。在类中应这样写:
this.name()
而在模板中则如下所示:
{{ name() }}
忘记加上括号是初学者常见的错误;没有括号的话,模板会渲染信号函数本身而非其对应的值。子组件的模板是这样使用该输入的:
<div class="card">
<h2>{{ name() }}</h2>
<p>Welcome to Angular!</p>
</div>
在父组件端,需要在放置子组件时为该输入绑定一个值:
<app-user-card [name]="userName" />
如果父类包含:
userName = 'Yuvaraj';
子组件会接收到该字符串并对其进行渲染:
Yuvaraj
方括号正是使这成为属性绑定的关键:
[name]="userName"
它们指示 Angular 将 userName 视为父组件中的表达式进行求值,然后将结果传递给子组件的 name 输入。如果没有方括号,Angular 会传递字面文本 userName。由于该输入是一个信号,每当父组件的值发生变化时,子组件会自动重新渲染。
使用 output() 向上发送事件
现在在子组件中添加一个按钮,让父组件能够在用户被选中时得知这一情况。子组件会声明一个输出接口以及一个通过该接口发送事件的方法:
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-user-card',
templateUrl: './user-card.html',
styleUrl: './user-card.css'
})
export class UserCardComponent {
name = input.required<string>();
selected = output<string>();
selectUser() {
this.selected.emit(this.name());
}
}
输出接口的声明定义了一个携带字符串的自定义事件:
selected = output<string>();
emit 方法会触发该事件,并将当前名称作为其参数传递:
this.selected.emit(this.name());
当按钮被点击时,子组件的模板会调用 selectUser()。注意这里的 (click) 语法,这是 Angular 的事件绑定方式:
<div class="card">
<h2>{{ name() }}</h2>
<button (click)="selectUser()">
Select User
</button>
</div>
父组件使用相同的括号语法来监听该自定义事件,这次传入的是事件的名称:
<app-user-card
[name]="userName"
(selected)="userSelected($event)"
/>
圆括号表示“监听此事件”,而 $event 则保存着子组件发出的值,在这里就是用户的名称。父组件会在普通方法中处理该值:
userSelected(name: string) {
console.log('Selected user:', name);
}
这种分工使得子组件具有可重用性。它既不知道也不关心父组件如何处理选中结果,只是简单地通知发生了选中事件。由父组件决定是记录日志、进行导航还是更新自身的状态。
将组件添加到页面中
最后一个示例将所有部分整合在一起。此版本的欢迎组件在装饰器中直接使用内联的template和styles,而非单独的文件,这对于非常小的组件来说十分方便:
import { Component } from '@angular/core';
@Component({
selector: 'app-welcome',
template: `
<div class="welcome-card">
<h2>Welcome Component</h2>
<p>This component was created using the <strong>app-welcome</strong> selector.</p>
</div>
`,
styles: `
.welcome-card {
padding: 25px;
margin-top: 20px;
border-radius: 12px;
background: #f5f5f5;
text-align: center;
font-family: Arial, sans-serif;
}
h2 {
color: #dd0031;
}
p {
font-size: 18px;
}
`
})
export class WelcomeComponent {}
根组件在其imports数组中导入了WelcomeComponent,这正是使得app-welcome选择器能在其模板中使用的关键。若遗漏这一步骤,便是独立应用中出现“不是已知元素”错误的最常见原因之一:
import { Component } from '@angular/core';
import { WelcomeComponent } from './welcome';
@Component({
selector: 'app-root',
imports: [WelcomeComponent],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App {}
最后,根模板通过相应的选择器来放置该组件:
<div class="container">
<h1>Angular Components</h1>
<p>
Below is our custom Angular component:
</p>
<app-welcome></app-welcome>
</div>
内联模板将所有内容保存在同一个文件中,但当标记代码超过几行时就会显得很不方便。一个合理的做法是:对于简单的展示性内容使用内联模板,而一旦模板需要真正的结构,就立即将其移至独立的 .html 和 .css 文件中。
关键要点
- 组件由 TypeScript 类、模板以及可选的样式组成,这些元素通过
@Component()装饰器绑定在一起。 - 在现代 Angular 中,独立组件是默认模式;应在每个组件的
imports数组中声明依赖项,而非放在NgModule中。 - 选择器是用于放置组件的自定义标签,而与之匹配的元素则成为该组件的宿主。
- 模板会与类中的数据绑定,当这些数据发生变化时,Angular 会更新视图。
input()(或 input.required())向下传递数据,使用 output() 向上传递事件;可通过调用这些方法作为信号来读取输入值。从这个角度来看,Angular 应用程序是由多个组件构成的树结构,每个组件负责界面中的特定部分,并通过明确的输入和输出进行通信。正是这种结构使得大型 Angular 应用能够在不断扩展的同时依然易于理解。