指令(Directive)是 Angular 中容易被低估的能力:组件本质上就是”带模板的指令”,而狭义的指令则用于在不新建组件的情况下复用 DOM 行为。本文聚焦属性型指令(Attribute Directives)——监听宿主元素、改写它的外观与行为、通过 input() 接收配置。所有示例基于 Angular v22,信号 API 优先,旧的装饰器写法作为对照讲解。
指令的三种形态
| 类型 | 作用 | 典型代表 |
|---|---|---|
| 组件 | 带模板的指令,构成视图 | @Component |
| 属性型指令 | 改变单个元素的外观或行为 | NgClass、自定义 appHighlight |
| 结构型指令 | 增删/复制 DOM 子树 | *ngIf(历史)、新控制流 @if |
属性型指令以”属性”的形式挂在元素上,不改变 DOM 结构,只改变元素本身——这是它与结构型指令最本质的区别(结构型指令见下一篇)。
第一个属性型指令:appHighlight
需求:鼠标悬停时给元素加背景色,颜色可配置。
import { Directive, computed, input, signal } from '@angular/core';
@Directive({
selector: '[appHighlight]',
host: {
'(mouseenter)': 'setHover(true)',
'(mouseleave)': 'setHover(false)',
'[style.background]': 'bg()',
},
})
export class HighlightDirective {
color = input<string>('yellow');
hovered = signal(false);
bg = computed(() => (this.hovered() ? this.color() : 'transparent'));
setHover(hovered: boolean) {
this.hovered.set(hovered);
}
}
使用:
<p appHighlight>默认黄色高亮</p>
<p appHighlight color="gold">自定义颜色</p>
要点:
- selector 用方括号
[appHighlight]表示”作为属性匹配”;命名上建议app前缀,避免与原生属性、第三方指令冲突。 - v22 中指令默认 standalone,直接在使用它的组件
imports中声明即可。 - host 对象里静态属性直接写键值对(如
role: 'button'),绑定沿用模板的[]与()语法。 - host 绑定表达式里可以直接调用信号(如
bg()),信号变化会自动同步宿主——这正是 Zoneless 时代的推荐姿势,无需任何手动刷新。
host 对象 vs @HostBinding / @HostListener
同样的功能,历史写法使用装饰器:
import { Directive, HostBinding, HostListener } from '@angular/core';
@Directive({ selector: '[appHighlight]' })
export class HighlightDirective {
@HostBinding('style.background') bg = 'transparent';
@HostListener('mouseenter') onEnter() {
this.bg = 'gold';
}
@HostListener('mouseleave') onLeave() {
this.bg = 'transparent';
}
}
两种写法编译结果等价,但官方风格指南推荐 host 对象:
| 维度 | host 对象(推荐) | @HostBinding / @HostListener |
|---|---|---|
| 声明位置 | 集中在装饰器元数据 | 分散在各个类成员上 |
| 可读性 | 宿主契约一目了然 | 需通读类定义才能拼出全貌 |
| 继承行为 | 父类元数据与子类逻辑解耦,行为可预测 | 装饰器成员随继承传播,容易踩坑 |
| 类型检查 | 参与 strictTemplates 对 host 绑定的检查 | 同样受检查 |
| 现状 | v22 新代码首选 | 完全可用,存量代码大量存在 |
注意 host 对象中的事件绑定引用的是”方法名字符串”,方法必须存在于指令实例上。这反过来促使我们把宿主逻辑收敛为少量方法,而非散落的匿名处理。
用 input() 接收参数
指令与组件一样可以使用函数式 input,并且天然是信号:
@Directive({
selector: '[appBadge]',
host: {
'[textContent]': 'label()',
},
})
export class BadgeDirective {
count = input.required<number>();
max = input(99);
label = computed(() =>
this.count() > this.max() ? `${this.max()}+` : `${this.count()}`
);
}
<span appBadge [count]="messages().length" [max]="99"></span>
如果需要在参数变化时执行副作用(调用 DOM API、打点、发请求),用 effect():
constructor() {
effect(() => {
const count = this.count();
// 副作用:埋点、同步到 DOM 等
});
}
对照历史写法:@Input() + ngOnChanges 仍是可用 API(旧的结构型指令教程大量使用),但在 v22 中,“声明式响应输入”应优先选择 computed / effect,它们与 Zoneless 变更检测无缝配合。
ElementRef 与 Renderer2
host 绑定覆盖不了所有场景。当需要直接操作 DOM 时,注入 ElementRef:
import { Directive, ElementRef, afterNextRender, inject } from '@angular/core';
@Directive({ selector: '[appAutofocus]' })
export class AutofocusDirective {
private el = inject<ElementRef<HTMLElement>>(ElementRef);
constructor() {
afterNextRender(() => this.el.nativeElement.focus());
}
}
两个工程要点:
afterNextRender保证回调只在浏览器执行,SSR 安全;直接在构造器里操作nativeElement可能污染服务端渲染输出。- 需要批量修改属性/样式时,
inject(Renderer2)提供平台无关的setAttribute/setStyle,比直接操作nativeElement在 SSR 等环境下更稳妥。不过若能用 host 绑定 + 信号表达,就优先用绑定,DOM 操作永远是最后手段。
exportAs:让模板引用指令实例
指令可以通过 exportAs 暴露自己,模板变量即可读取指令状态或调用其方法:
@Directive({
selector: '[appColorPicker]',
exportAs: 'appColorPicker',
host: {
'(input)': 'onInput($event)',
},
})
export class ColorPickerDirective {
value = signal('#6750a4');
onInput(event: Event) {
this.value.set((event.target as HTMLInputElement).value);
}
reset() {
this.value.set('#6750a4');
}
}
<input appColorPicker #picker="appColorPicker" />
<button type="button" (click)="picker.reset()">重置</button>
内置属性型指令速览
| 名称 | 来源 | 作用 | 示例 |
|---|---|---|---|
| NgClass | @angular/common | 批量增删 class | <div [ngClass]="cls()"> |
| NgStyle | @angular/common | 批量设置样式 | <div [ngStyle]="{ fontSize: '14px' }"> |
| class 绑定 | 内置语法 | 单个 class 开关 | <div [class.active]="isActive()"> |
| style 绑定 | 内置语法 | 单条样式 | <div [style.width.px]="width()"> |
| 属性绑定 | 内置语法 | 任意 attribute | <td [attr.colspan]="span()"> |
日常开发优先使用 class / style / attr 绑定语法;NgClass / NgStyle 适合对象或数组驱动的批量场景。
实战:图片加载失败回退
import { Directive, ElementRef, inject, input } from '@angular/core';
@Directive({
selector: 'img[appFallback]',
host: {
'(error)': 'onError()',
},
})
export class ImageFallbackDirective {
fallback = input.required<string>();
private el = inject<ElementRef<HTMLImageElement>>(ElementRef);
onError() {
const node = this.el.nativeElement;
if (node.src !== this.fallback()) {
node.src = this.fallback();
}
}
}
<img
[src]="user().avatar"
appFallback
fallback="/assets/avatar-placeholder.png"
alt="avatar"
/>
这个例子体现了属性型指令的核心价值:把”横切”的 DOM 行为从业务模板中抽走,任何 img 标签一行声明即可复用,且实现与业务完全解耦。
小结
- 属性型指令 = 选择器 + host 契约 + 可选的
input(),用于横向复用 DOM 行为。 - host 对象是 v22 推荐写法,
@HostBinding/@HostListener仍可用,重点掌握两者互转。 - 信号(
input/signal/computed)让指令天然融入 Zoneless 变更检测,不需要markForCheck。 - 直接操作 DOM 时注意 SSR 安全:
afterNextRender、Renderer2。 exportAs让指令可以被模板消费,是指令对外暴露受控 API 的正规方式。