13_自定义指令实践

自定义指令实践:扩展HTML能力

指令是Angular中与组件并列的核心特性,用于扩展HTML元素的行为和外观。与组件不同,指令没有模板,而是通过装饰器和类方法实现对DOM的操作。本章将从基础到高级,系统讲解自定义指令的开发实践,包括属性指令、结构指令的实现技巧,以及指令与组件、服务的协同工作方式。

1. 指令基础:类型与核心概念

Angular指令分为三类:组件指令(带模板的特殊指令)、属性指令(修改元素属性或样式)和结构指令(修改DOM结构)。本章聚焦于自定义属性指令和结构指令的开发。

1.1 指令元数据与选择器

自定义指令通过@Directive装饰器定义,核心元数据包括selector(选择器)和standalone(独立模式):

import { Directive } from '@angular/core';

// 基本指令结构
@Directive({
  selector: '[appHighlight]', // 属性选择器:以[]包裹
  standalone: true // 独立指令(无需模块)
})
export class HighlightDirective {
  // 指令逻辑
}

选择器规则

  • 属性选择器:[属性名](如[appHighlight]),匹配带该属性的元素
  • 元素选择器:元素名(如app-my-directive),匹配特定元素
  • 类选择器:.类名(如.special),匹配带该类的元素
  • 组合选择器:input[appNumber](匹配带appNumber属性的input元素)

1.2 指令与组件的区别

特性 组件 指令
模板 必须有(templatetemplateUrl 无模板
用途 构建UI视图,拥有自己的DOM结构 扩展现有元素的行为或样式
选择器 通常为元素选择器(如app-component 通常为属性选择器(如[appDirective]
生命周期 完整的组件生命周期 ngAfterContentInit等内容投影相关钩子外的大部分生命周期

2. 属性指令:增强元素行为

属性指令用于修改DOM元素的外观或行为(如样式、事件监听、属性值等),是最常用的自定义指令类型。

2.1 基础样式修改指令

实现一个高亮指令,根据条件修改元素背景色:

// highlight.directive.ts
import { Directive, ElementRef, Input, OnInit } from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  standalone: true
})
export class HighlightDirective implements OnInit {
  // 输入属性:高亮颜色(默认黄色)
  @Input() highlightColor = 'yellow';
  
  // 输入属性:是否激活(默认true)
  @Input() appHighlight = true; // 与选择器同名,支持<元素 [appHighlight]="false">语法
  
  // ElementRef:获取宿主元素的DOM引用
  constructor(private el: ElementRef) {}
  
  ngOnInit() {
    // 初始化时设置样式
    this.updateHighlight();
  }
  
  // 更新高亮状态
  private updateHighlight() {
    if (this.appHighlight) {
      // 通过nativeElement访问DOM元素
      this.el.nativeElement.style.backgroundColor = this.highlightColor;
    } else {
      this.el.nativeElement.style.backgroundColor = '';
    }
  }
  
  // 监听输入属性变化,动态更新样式
  ngOnChanges() {
    this.updateHighlight();
  }
}

使用指令:

// 使用指令的组件
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';

@Component({
  selector: 'app-example',
  standalone: true,
  imports: [HighlightDirective], // 导入指令
  template: `
    <!-- 基础用法 -->
    <p appHighlight>默认高亮(黄色)</p>
    
    <!-- 自定义颜色 -->
    <p appHighlight [highlightColor]="'lightblue'">浅蓝色高亮</p>
    
    <!-- 动态控制是否激活 -->
    <p [appHighlight]="isActive" [highlightColor]="'pink'">
      可切换的高亮(点击按钮切换)
    </p>
    <button (click)="isActive = !isActive">切换高亮</button>
  `
})
export class ExampleComponent {
  isActive = true;
}

2.2 事件处理指令

实现一个防抖指令,限制高频事件(如输入、滚动)的触发频率:

// debounce.directive.ts
import { Directive, HostListener, Input, Output, EventEmitter } from '@angular/core';
import { Subject, debounceTime, takeUntil, skip } from 'rxjs';
import { DestroyRef, inject } from '@angular/core';

@Directive({
  selector: '[appDebounce]',
  standalone: true
})
export class DebounceDirective {
  // 防抖时间(默认300ms)
  @Input() debounceTime = 300;
  
  // 输出防抖后的事件
  @Output() debounced = new EventEmitter<any>();
  
  // 用于防抖的Subject
  private eventSubject = new Subject<any>();
  
  // 自动销毁订阅(Angular 16+)
  private destroyRef = inject(DestroyRef);
  
  constructor() {
    // 防抖处理
    const subscription = this.eventSubject
      .pipe(
        debounceTime(this.debounceTime),
        skip(1) // 跳过初始值
      )
      .subscribe(value => {
        this.debounced.emit(value);
      });
      
    // 组件销毁时自动取消订阅
    this.destroyRef.onDestroy(() => {
      subscription.unsubscribe();
    });
  }
  
  // 监听宿主元素的input事件
  @HostListener('input', ['$event.target.value'])
  onInput(value: string) {
    this.eventSubject.next(value);
  }
  
  // 支持其他事件(如scroll)
  @HostListener('scroll', ['$event'])
  onScroll(event: Event) {
    this.eventSubject.next(event);
  }
}

使用防抖指令:

<!-- 输入框防抖 -->
<input 
  type="text" 
  appDebounce 
  [debounceTime]="500"
  (debounced)="handleSearch($event)"
  placeholder="搜索(500ms防抖)"
>

<!-- 滚动防抖 -->
<div 
  appDebounce 
  [debounceTime]="100"
  (debounced)="handleScroll($event)"
  style="height: 200px; overflow: auto; border: 1px solid #ccc;"
>
  <!-- 长内容 -->
  <p *ngFor="let i of [1,2,3,4,5,6,7,8,9,10]">滚动内容 {{i}}</p>
</div>

2.3 带依赖注入的指令

指令可以注入服务,实现更复杂的功能(如权限控制、日志记录):

// permission.directive.ts(权限控制指令)
import { Directive, Input, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { AuthService } from './auth.service';

@Directive({
  selector: '[appPermission]',
  standalone: true
})
export class PermissionDirective {
  // 需要的权限
  @Input() appPermission!: string;
  
  constructor(
    private authService: AuthService, // 注入权限服务
    private templateRef: TemplateRef<any>, // 指令所在的模板
    private viewContainer: ViewContainerRef // 视图容器
  ) {}
  
  ngOnInit() {
    // 检查是否有权限
    if (this.authService.hasPermission(this.appPermission)) {
      // 有权限:渲染模板
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else {
      // 无权限:清空视图
      this.viewContainer.clear();
    }
  }
}

权限服务:

// auth.service.ts
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class AuthService {
  // 模拟权限检查
  hasPermission(permission: string): boolean {
    const userPermissions = ['read', 'edit']; // 当前用户拥有的权限
    return userPermissions.includes(permission);
  }
}

使用权限指令:

<!-- 有权限才显示的内容 -->
<button *appPermission="'edit'">编辑(需要edit权限)</button>

<!-- 无权限则不显示 -->
<button *appPermission="'delete'">删除(需要delete权限)</button>

3. 结构指令:操纵DOM结构

结构指令通过*语法糖修改DOM结构(如条件渲染、循环渲染),核心是通过TemplateRefViewContainerRef操作模板。

3.1 基础条件渲染指令

实现一个*appIf指令,类似*ngIf但支持自定义加载状态:

// if.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
  selector: '[appIf]',
  standalone: true
})
export class IfDirective {
  // 存储当前条件
  private currentCondition: boolean = false;
  
  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef
  ) {}
  
  // 输入属性:支持<元素 *appIf="condition; else elseTemplate">语法
  @Input() set appIf(condition: boolean) {
    this.currentCondition = condition;
    this.updateView();
  }
  
  // 输入属性:else模板
  @Input() appIfElse?: TemplateRef<any>;
  
  // 更新视图
  private updateView() {
    // 清空现有视图
    this.viewContainer.clear();
    
    if (this.currentCondition) {
      // 条件为true:渲染主模板
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else if (this.appIfElse) {
      // 条件为false且有else模板:渲染else模板
      this.viewContainer.createEmbeddedView(this.appIfElse);
    }
  }
}

使用*appIf指令:

<!-- 基础用法 -->
<p *appIf="showContent">条件为true时显示</p>

<!-- 带else模板 -->
<div *appIf="hasData; else loading">
  数据加载完成:{{ data }}
</div>

<ng-template #loading>
  <p>加载中...</p>
</ng-template>

<button (click)="showContent = !showContent">切换显示</button>

3.2 循环渲染指令

实现一个*appFor指令,类似*ngFor但支持索引和空状态:

// for.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
  selector: '[appFor]',
  standalone: true
})
export class ForDirective {
  // 存储当前数据
  private items: any[] = [];
  
  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef
  ) {}
  
  // 解析输入:*appFor="let item of items; let i = index; empty emptyTemplate"
  @Input() set appForOf(items: any[]) {
    this.items = items || [];
    this.updateView();
  }
  
  // 空状态模板
  @Input() appForEmpty?: TemplateRef<any>;
  
  // 更新视图
  private updateView() {
    this.viewContainer.clear();
    
    if (this.items.length === 0 && this.appForEmpty) {
      // 数据为空且有empty模板:渲染空状态
      this.viewContainer.createEmbeddedView(this.appForEmpty);
    } else {
      // 渲染列表项
      this.items.forEach((item, index) => {
        this.viewContainer.createEmbeddedView(this.templateRef, {
          // 暴露变量给模板:$implicit(默认变量)、index(索引)
          $implicit: item,
          index: index
        });
      });
    }
  }
}

使用*appFor指令:

<!-- 基础用法 -->
<ul>
  <li *appFor="let user of users; let i = index">
    {{ i + 1 }}. {{ user.name }}
  </li>
</ul>

<!-- 带空状态 -->
<div *appFor="let item of products; empty noProducts">
  <p>{{ item.name }}</p>
</div>

<ng-template #noProducts>
  <p>暂无产品数据</p>
</ng-template>

4. 高级指令技巧

4.1 指令与信号(Signals)结合

Angular 16+引入的信号(Signals)可与指令结合,实现响应式状态管理:

// tooltip.directive.ts(信号版 tooltip 指令)
import { Directive, Input, ElementRef, HostListener, signal } from '@angular/core';

@Directive({
  selector: '[appTooltip]',
  standalone: true
})
export class TooltipDirective {
  // 信号:控制tooltip显示状态
  private isVisible = signal(false);
  
  // 输入:tooltip文本
  @Input() appTooltip!: string;
  
  // 输入:位置(上/下/左/右)
  @Input() tooltipPosition: 'top' | 'bottom' | 'left' | 'right' = 'top';
  
  // tooltip元素
  private tooltipElement: HTMLElement;
  
  constructor(private el: ElementRef) {
    // 创建tooltip DOM元素
    this.tooltipElement = document.createElement('div');
    this.tooltipElement.className = 'app-tooltip';
    this.tooltipElement.style.position = 'absolute';
    this.tooltipElement.style.display = 'none';
    document.body.appendChild(this.tooltipElement);
    
    // 监听信号变化,更新显示状态
    this.isVisible.subscribe(visible => {
      this.tooltipElement.style.display = visible ? 'block' : 'none';
      if (visible) {
        this.positionTooltip();
      }
    });
  }
  
  // 定位tooltip
  private positionTooltip() {
    const hostRect = this.el.nativeElement.getBoundingClientRect();
    const tooltipRect = this.tooltipElement.getBoundingClientRect();
    
    switch (this.tooltipPosition) {
      case 'top':
        this.tooltipElement.style.top = `${hostRect.top - tooltipRect.height - 5}px`;
        this.tooltipElement.style.left = `${hostRect.left + hostRect.width / 2 - tooltipRect.width / 2}px`;
        break;
      // 其他位置逻辑...
    }
  }
  
  // 鼠标进入:显示tooltip
  @HostListener('mouseenter') onMouseEnter() {
    this.tooltipElement.textContent = this.appTooltip;
    this.isVisible.set(true);
  }
  
  // 鼠标离开:隐藏tooltip
  @HostListener('mouseleave') onMouseLeave() {
    this.isVisible.set(false);
  }
  
  // 清理:移除tooltip元素
  ngOnDestroy() {
    document.body.removeChild(this.tooltipElement);
  }
}

4.2 指令组合与优先级

多个指令可应用于同一元素,通过priority控制执行顺序(数值越大优先级越高):

// 高优先级指令
@Directive({
  selector: '[appHighPriority]',
  standalone: true,
  priority: 100 // 优先级高
})
export class HighPriorityDirective {
  constructor() {
    console.log('高优先级指令初始化');
  }
}

// 低优先级指令
@Directive({
  selector: '[appLowPriority]',
  standalone: true,
  priority: 10 // 优先级低
})
export class LowPriorityDirective {
  constructor() {
    console.log('低优先级指令初始化');
  }
}

应用多个指令:

<!-- 输出顺序:高优先级 → 低优先级 -->
<div appHighPriority appLowPriority></div>

4.3 宿主绑定与类/样式操作

通过@HostBinding直接绑定宿主元素的属性、类或样式:

// active.directive.ts
import { Directive, HostBinding, HostListener } from '@angular/core';

@Directive({
  selector: '[appActive]',
  standalone: true
})
export class ActiveDirective {
  // 绑定宿主元素的class.active
  @HostBinding('class.active') isActive = false;
  
  // 绑定宿主元素的style.cursor
  @HostBinding('style.cursor') cursor = 'pointer';
  
  // 鼠标点击切换激活状态
  @HostListener('click') onClick() {
    this.isActive = !this.isActive;
  }
  
  // 鼠标悬停效果
  @HostListener('mouseenter') onMouseEnter() {
    this.cursor = 'pointer';
  }
  
  @HostListener('mouseleave') onMouseLeave() {
    this.cursor = 'default';
  }
}

使用效果:

<!-- 点击切换active类,鼠标悬停改变光标 -->
<div appActive>点击激活</div>

5. 指令最佳实践

5.1 命名规范

  • 指令选择器前缀:使用项目特定前缀(如app)避免冲突
  • 功能命名:选择器名称应明确表示指令功能(如appHighlightappDebounce
  • 输入属性:与指令同名的输入属性用于主开关(如[appPermission]="'edit'"

5.2 性能优化

  • 避免频繁DOM操作:通过防抖、节流减少高频事件处理
  • 清理资源:在ngOnDestroy中移除事件监听、定时器等
  • 使用DestroyRef:Angular 16+推荐使用DestroyRef管理订阅生命周期

5.3 适用场景

  • 通用UI增强:如高亮、tooltip、拖拽等
  • 行为封装:如防抖、节流、权限控制等
  • 跨组件复用逻辑:不适宜用组件实现的共享行为

5.4 避免过度使用

  • 复杂UI逻辑优先使用组件
  • 单一职责:一个指令只做一件事
  • 避免嵌套结构指令:多个结构指令应用于同一元素可能导致不可预期的行为

总结

自定义指令是Angular扩展HTML能力的强大工具,通过属性指令可以增强元素行为和样式,通过结构指令可以操纵DOM结构。本章从基础概念出发,通过实例讲解了指令的开发流程,包括选择器定义、宿主元素交互、依赖注入集成等核心技术。

高级部分介绍了指令与信号结合、指令组合、宿主绑定等技巧,帮助开发者构建更灵活、高效的指令。遵循最佳实践,合理使用指令可以显著提升代码复用性和应用质量,尤其在开发通用组件库或处理跨组件共享行为时发挥重要作用。

在实际开发中,应根据需求选择合适的指令类型,平衡灵活性与性能,避免过度设计,让指令真正成为组件的有力补充。

posted @ 2025-09-21 18:19  S&L·chuck  阅读(10)  评论(0)    收藏  举报