Angular管道使用与自定义

管道(Pipe)是Angular中用于数据转换和格式化的功能,类似于其他框架中的过滤器(Filter)。管道可以将输入数据转换为更友好的显示格式。

管道语法:使用竖线(|)在模板中应用管道:{{ value | pipeName:arg1:arg2 }}

1. 内置管道

Angular提供了多种内置管道来处理常见的数据格式化需求。

1.1 常用内置管道

内置管道演示:
原始数据: 'angular pipe' {{ 'angular pipe' | uppercase }}
原始数据: 'ANGULAR PIPE' {{ 'ANGULAR PIPE' | lowercase }}
原始数据: 'angular pipe' {{ 'angular pipe' | titlecase }}
原始数据: 123456.789 {{ 123456.789 | number:'1.2-2' }}
原始数据: 1234.56 {{ 1234.56 | currency:'CNY':'symbol':'1.2-2' }}
原始数据: new Date() {{ '2024-01-15' | date:'yyyy-MM-dd HH:mm:ss' }}
<!-- 内置管道使用示例 -->
<div class="container">
  <!-- 字符串管道 -->
  <p>{{ 'angular pipe' | uppercase }}</p>
  <p>{{ 'ANGULAR PIPE' | lowercase }}</p>
  <p>{{ 'angular pipe' | titlecase }}</p>

  <!-- 数字管道 -->
  <p>{{ 123456.789 | number:'1.2-2' }}</p>
  <p>{{ 0.1234 | percent:'1.2-2' }}</p>

  <!-- 货币管道 -->
  <p>{{ 1234.56 | currency:'USD':'symbol':'1.2-2' }}</p>
  <p>{{ 1234.56 | currency:'CNY':'symbol-narrow':'1.2-2' }}</p>
  <p>{{ 1234.56 | currency:'EUR':'symbol':'4.2-2' }}</p>

  <!-- 日期管道 -->
  <p>{{ currentDate | date:'yyyy-MM-dd' }}</p>
  <p>{{ currentDate | date:'medium' }}</p>
  <p>{{ currentDate | date:'fullDate' }}</p>
  <p>{{ currentDate | date:'yyyy-MM-dd HH:mm:ss' }}</p>

  <!-- JSON管道(调试用) -->
  <pre>{{ user | json }}</pre>

  <!-- Slice管道 -->
  <p>{{ 'Hello Angular' | slice:0:5 }}</p>
  <p>{{ [1,2,3,4,5] | slice:1:3 }}</p>

  <!-- I18nSelect管道 -->
  <p>{{ gender | i18nSelect:genderMapping }}</p>

  <!-- I18nPlural管道 -->
  <p>{{ items.length | i18nPlural:messageMapping }}</p>

  <!-- KeyValue管道 -->
  <div *ngFor="let item of object | keyvalue">
    {{item.key}}: {{item.value}}
  </div>
</div>
// 组件中的相关数据
import { Component } from '@angular/core';

@Component({
  selector: 'app-pipe-demo',
  templateUrl: './pipe-demo.component.html'
})
export class PipeDemoComponent {
  currentDate = new Date();

  user = {
    name: '张三',
    age: 30,
    email: 'zhangsan@example.com'
  };

  gender = 'male';
  genderMapping = {
    'male': '先生',
    'female': '女士',
    'other': '其他'
  };

  items = ['苹果', '香蕉', '橙子'];
  messageMapping = {
    '=0': '没有商品',
    '=1': '一件商品',
    'other': '#件商品'
  };

  object = {
    name: '张三',
    age: 30,
    city: '北京'
  };
}

1.2 管道参数和链式管道

原始数据
管道1
管道2
管道3
最终输出
<!-- 管道参数 -->
<p>{{ price | currency:'CNY':'¥':'1.2-2' }}</p>
<p>{{ date | date:'yyyy年MM月dd日 HH:mm:ss' }}</p>
<p>{{ percentage | percent:'1.2-2' }}</p>

<!-- 链式管道(管道可以串联) -->
<p>{{ 'hello world' | uppercase | slice:0:5 }}</p>
<p>{{ user.birthday | date:'yyyy-MM-dd' | uppercase }}</p>
<p>{{ amount | currency:'USD' | lowercase }}</p>

<!-- 带参数的链式管道 -->
<p>{{ longText | slice:0:100 | lowercase | titlecase }}</p>

2. 自定义管道

2.1 创建自定义管道

// reverse.pipe.ts - 反转字符串管道
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'reverse'
})
export class ReversePipe implements PipeTransform {

  transform(value: string): string {
    if (!value) return '';

    // 反转字符串
    return value.split('').reverse().join('');
  }
}
// truncate.pipe.ts - 截断文本管道(带参数)
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'truncate'
})
export class TruncatePipe implements PipeTransform {

  transform(value: string, limit: number = 50, suffix: string = '...'): string {
    if (!value) return '';

    // 如果文本长度小于等于限制,直接返回
    if (value.length <= limit) {
      return value;
    }

    // 截断文本并添加后缀
    return value.substring(0, limit) + suffix;
  }
}
// filter.pipe.ts - 数组过滤管道
import { Pipe, PipeTransform } from '@angular/core';

interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
  inStock: boolean;
}

@Pipe({
  name: 'filter'
})
export class FilterPipe implements PipeTransform {

  transform(items: Product[], filterBy: string): Product[] {
    if (!items || !filterBy) {
      return items;
    }

    // 将过滤器转换为小写以进行不区分大小写的比较
    filterBy = filterBy.toLowerCase();

    // 过滤数组
    return items.filter(item =>
      item.name.toLowerCase().includes(filterBy) ||
      item.category.toLowerCase().includes(filterBy)
    );
  }
}

2.2 在模块中注册管道

// shared.module.ts 或相关模块
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReversePipe } from './pipes/reverse.pipe';
import { TruncatePipe } from './pipes/truncate.pipe';
import { FilterPipe } from './pipes/filter.pipe';

@NgModule({
  declarations: [
    ReversePipe,
    TruncatePipe,
    FilterPipe
  ],
  imports: [
    CommonModule
  ],
  exports: [
    ReversePipe,
    TruncatePipe,
    FilterPipe
  ]
})
export class SharedModule { }

2.3 在模板中使用自定义管道

<!-- 使用自定义管道 -->
<div class="demo">
  <!-- 反转字符串 -->
  <p>原始: {{ 'Hello Angular' }}</p>
  <p>反转: {{ 'Hello Angular' | reverse }}</p>

  <!-- 截断文本 -->
  <p>{{ longText | truncate:100:'...' }}</p>
  <p>{{ shortText | truncate:50 }}</p>

  <!-- 数组过滤 -->
  <input type="text" [(ngModel)]="searchText" placeholder="搜索...">

  <ul>
    <li *ngFor="let product of products | filter:searchText">
      {{product.name}} - {{product.price | currency}}
    </li>
  </ul>
</div>

3. 异步管道

异步管道用于处理Promise和Observable,自动订阅并在数据到达时更新视图。

// async-demo.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';

@Component({
  selector: 'app-async-demo',
  templateUrl: './async-demo.component.html'
})
export class AsyncDemoComponent implements OnInit {
  // Promise示例
  promiseData: Promise<string>;

  // Observable示例
  observableData$: Observable<string>;
  observableArray$: Observable<number[]>;
  observableUser$: Observable<{name: string, age: number}>;

  // 定时器Observable
  timer$: Observable<number>;

  ngOnInit(): void {
    // Promise示例
    this.promiseData = new Promise((resolve) => {
      setTimeout(() => {
        resolve('Promise数据已加载!');
      }, 2000);
    });

    // Observable示例
    this.observableData$ = of('Observable数据已加载!').pipe(
      delay(1500)
    );

    this.observableArray$ = of([1, 2, 3, 4, 5]);

    this.observableUser$ = of({
      name: '张三',
      age: 30
    });

    // 定时器
    this.timer$ = new Observable(observer => {
      let count = 0;
      const interval = setInterval(() => {
        observer.next(count++);
      }, 1000);

      // 清理函数
      return () => {
        clearInterval(interval);
      };
    });
  }
}
<!-- async-demo.component.html -->
<div class="async-demo">
  <h4>异步管道演示</h4>

  <!-- Promise -->
  <div class="mb-3">
    <h5>Promise数据:</h5>
    <p>{{ promiseData | async }}</p>
    <small class="text-muted">等待2秒后显示</small>
  </div>

  <!-- Observable -->
  <div class="mb-3">
    <h5>Observable数据:</h5>
    <p>{{ observableData$ | async }}</p>
    <p>{{ observableArray$ | async | json }}</p>
    <p *ngIf="observableUser$ | async as user">
      用户: {{user.name}}, 年龄: {{user.age}}
    </p>
  </div>

  <!-- 定时器 -->
  <div class="mb-3">
    <h5>定时器:</h5>
    <p>计数: {{ timer$ | async }}</p>
  </div>

  <!-- 条件渲染 -->
  <div class="mb-3">
    <h5>条件渲染:</h5>
    <ng-container *ngIf="observableData$ | async as data">
      <p>数据已加载: {{data}}</p>
    </ng-container>

    <div *ngIf="!(observableData$ | async)">
      <div class="spinner-border spinner-border-sm" role="status">
        <span class="visually-hidden">加载中...</span>
      </div>
      数据加载中...
    </div>
  </div>
</div>

4. 纯管道与非纯管道

// 纯管道(默认)- 只有当输入值变化时才执行
@Pipe({
  name: 'purePipe',
  pure: true  // 默认值,可省略
})
export class PurePipe implements PipeTransform {
  transform(value: any): any {
    console.log('纯管道执行');
    return value;
  }
}

// 非纯管道 - 每次变更检测都会执行
@Pipe({
  name: 'impurePipe',
  pure: false  // 设置为非纯
})
export class ImpurePipe implements PipeTransform {
  transform(value: any): any {
    console.log('非纯管道执行');
    return value;
  }
}

// 非纯管道示例:实时计算数组长度
@Pipe({
  name: 'arrayLength',
  pure: false
})
export class ArrayLengthPipe implements PipeTransform {
  transform(array: any[]): number {
    return array ? array.length : 0;
  }
}
注意:非纯管道性能开销较大,因为它们在每次变更检测时都会执行。除非必要,否则应该使用纯管道。

5. 带参数的复杂管道

// sort-by.pipe.ts - 数组排序管道
import { Pipe, PipeTransform } from '@angular/core';

interface SortConfig {
  property: string;
  direction: 'asc' | 'desc';
}

@Pipe({
  name: 'sortBy'
})
export class SortByPipe implements PipeTransform {

  transform(array: any[], config: SortConfig): any[] {
    if (!array || !config || !config.property) {
      return array;
    }

    // 复制数组以避免修改原数组
    const result = [...array];

    // 排序逻辑
    result.sort((a, b) => {
      const aValue = this.getPropertyValue(a, config.property);
      const bValue = this.getPropertyValue(b, config.property);

      if (aValue < bValue) {
        return config.direction === 'asc' ? -1 : 1;
      }
      if (aValue > bValue) {
        return config.direction === 'asc' ? 1 : -1;
      }
      return 0;
    });

    return result;
  }

  private getPropertyValue(obj: any, propertyPath: string): any {
    return propertyPath.split('.').reduce((o, i) => o ? o[i] : null, obj);
  }
}
// safe-html.pipe.ts - 安全HTML管道
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';

@Pipe({
  name: 'safeHtml'
})
export class SafeHtmlPipe implements PipeTransform {

  constructor(private sanitizer: DomSanitizer) {}

  transform(value: string): SafeHtml {
    if (!value) return '';

    // 将HTML标记为安全
    return this.sanitizer.bypassSecurityTrustHtml(value);
  }
}
<!-- 使用复杂管道 -->
<div class="complex-pipes">
  <!-- 排序管道 -->
  <div *ngFor="let item of items | sortBy:{property: 'name', direction: 'asc'}">
    {{item.name}} - {{item.price}}
  </div>

  <!-- 安全HTML管道 -->
  <div [innerHTML]="unsafeHtml | safeHtml"></div>

  <!-- 多个参数的管道 -->
  <p>{{ text | truncate:limit:suffix }}</p>

  <!-- 管道链 -->
  <p>{{ items | filter:searchText | sortBy:{property: 'price', direction: 'desc'} | slice:0:5 }}</p>
</div>

6. 管道性能优化

管道性能优化建议:
  • 尽量使用纯管道(默认)
  • 避免在管道中执行复杂计算
  • 对于复杂数据转换,考虑在组件或服务中预处理
  • 使用trackBy*ngFor结合使用过滤管道
  • 考虑使用纯函数计算值,而不是管道
  • 避免在管道中修改输入值
  • 使用变更检测策略OnPush优化
// 性能优化示例
@Component({
  selector: 'app-optimized',
  template: `
    <!-- 使用trackBy优化ngFor性能 -->
    <div *ngFor="let item of filteredItems; trackBy: trackById">
      {{item.name}}
    </div>

    <!-- 在组件中预处理数据,而不是使用非纯管道 -->
    <div>商品总数: {{totalItems}}</div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OptimizedComponent implements OnInit {
  items: Item[] = [];
  filteredItems: Item[] = [];
  totalItems: number = 0;

  constructor(private itemService: ItemService) {}

  ngOnInit(): void {
    this.itemService.getItems().subscribe(items => {
      this.items = items;
      this.filteredItems = this.filterItems(items, 'filter');
      this.totalItems = this.calculateTotal(items);
    });
  }

  // 组件内过滤,避免使用非纯管道
  filterItems(items: Item[], filter: string): Item[] {
    return items.filter(item =>
      item.name.toLowerCase().includes(filter.toLowerCase())
    );
  }

  // 计算总值
  calculateTotal(items: Item[]): number {
    return items.reduce((sum, item) => sum + item.price, 0);
  }

  // trackBy函数
  trackById(index: number, item: Item): number {
    return item.id;
  }
}

7. 管道测试

// reverse.pipe.spec.ts - 管道测试
import { ReversePipe } from './reverse.pipe';

describe('ReversePipe', () => {
  let pipe: ReversePipe;

  beforeEach(() => {
    pipe = new ReversePipe();
  });

  it('应该创建管道', () => {
    expect(pipe).toBeTruthy();
  });

  it('应该反转字符串', () => {
    expect(pipe.transform('hello')).toBe('olleh');
    expect(pipe.transform('angular')).toBe('ralugna');
    expect(pipe.transform('12345')).toBe('54321');
  });

  it('应该处理空字符串', () => {
    expect(pipe.transform('')).toBe('');
    expect(pipe.transform(null as any)).toBe('');
    expect(pipe.transform(undefined as any)).toBe('');
  });

  it('应该保持空格', () => {
    expect(pipe.transform('hello world')).toBe('dlrow olleh');
    expect(pipe.transform(' angular ')).toBe(' ralugna ');
  });
});

// truncate.pipe.spec.ts
import { TruncatePipe } from './truncate.pipe';

describe('TruncatePipe', () => {
  let pipe: TruncatePipe;

  beforeEach(() => {
    pipe = new TruncatePipe();
  });

  it('应该截断超长的文本', () => {
    const longText = '这是一个非常长的文本,需要被截断显示';
    const result = pipe.transform(longText, 10);
    expect(result).toBe('这是一个非常长的文...');
  });

  it('不应该截断短文本', () => {
    const shortText = '短文本';
    expect(pipe.transform(shortText, 10)).toBe(shortText);
  });

  it('应该使用自定义后缀', () => {
    const text = '这是一个测试文本';
    expect(pipe.transform(text, 5, ' [更多]')).toBe('这是一个测 [更多]');
  });

  it('应该使用默认参数', () => {
    const pipe = new TruncatePipe();
    expect(pipe.transform('测试文本')).toBe('测试文本'); // 默认limit 50
  });
});

8. 实际应用场景

// 实际应用中的管道示例集合
// phone-format.pipe.ts - 手机号格式化
@Pipe({ name: 'phoneFormat' })
export class PhoneFormatPipe implements PipeTransform {
  transform(phone: string): string {
    if (!phone) return '';

    // 移除所有非数字字符
    const cleaned = phone.replace(/\D/g, '');

    // 格式化为 123-4567-8900
    if (cleaned.length === 11) {
      return `${cleaned.substr(0, 3)}-${cleaned.substr(3, 4)}-${cleaned.substr(7)}`;
    }

    return phone;
  }
}

// file-size.pipe.ts - 文件大小格式化
@Pipe({ name: 'fileSize' })
export class FileSizePipe implements PipeTransform {
  transform(bytes: number, decimals: number = 2): string {
    if (bytes === 0) return '0 Bytes';

    const k = 1024;
    const dm = decimals < 0 ? 0 : decimals;
    const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];

    const i = Math.floor(Math.log(bytes) / Math.log(k));

    return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
  }
}

// time-ago.pipe.ts - 时间距离现在多久
@Pipe({ name: 'timeAgo', pure: false })
export class TimeAgoPipe implements PipeTransform {
  transform(value: Date): string {
    if (!value) return '';

    const seconds = Math.floor((+new Date() - +new Date(value)) / 1000);

    const intervals = {
      '年': 31536000,
      '月': 2592000,
      '周': 604800,
      '天': 86400,
      '小时': 3600,
      '分钟': 60,
      '秒': 1
    };

    for (const [unit, secondsInUnit] of Object.entries(intervals)) {
      const interval = Math.floor(seconds / secondsInUnit);
      if (interval >= 1) {
        return `${interval}${unit}前`;
      }
    }

    return '刚刚';
  }
}

// highlight.pipe.ts - 搜索关键词高亮
@Pipe({ name: 'highlight' })
export class HighlightPipe implements PipeTransform {
  transform(text: string, search: string): string {
    if (!search || !text) return text;

    const pattern = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const regex = new RegExp(`(${pattern})`, 'gi');

    return text.replace(regex, '<mark>$1</mark>');
  }
}
<!-- 应用中的管道使用 -->
<div class="real-world-examples">
  <!-- 手机号格式化 -->
  <p>电话: {{ user.phone | phoneFormat }}</p>

  <!-- 文件大小 -->
  <p>文件大小: {{ file.size | fileSize }}</p>
  <p>文件大小: {{ file.size | fileSize:1 }}</p>

  <!-- 时间显示 -->
  <p>发布时间: {{ article.createdAt | timeAgo }}</p>
  <p>更新时间: {{ article.updatedAt | date:'yyyy-MM-dd' }}</p>

  <!-- 搜索高亮 -->
  <div [innerHTML]="content | highlight:searchTerm | safeHtml"></div>

  <!-- 综合示例 -->
  <div *ngFor="let file of files">
    {{ file.name }} ({{ file.size | fileSize }}) -
    上传于 {{ file.uploadedAt | timeAgo }}
  </div>
</div>

9. 最佳实践总结

管道最佳实践:
  • 管道应该专注于单一的数据转换任务
  • 保持管道的纯净性,避免副作用
  • 为管道提供充分的测试用例
  • 考虑性能影响,优先使用纯管道
  • 为管道提供有意义的名称
  • 在共享模块中声明和导出常用管道
  • 使用TypeScript接口明确管道的输入输出类型
  • 为管道参数提供默认值
  • 处理边界情况(null、undefined等)
常见错误:
  • 在管道中修改输入值(违反纯函数原则)
  • 过度使用非纯管道导致性能问题
  • 未处理null或undefined输入
  • 管道中的复杂计算影响性能
  • 忘记在模块中声明管道
  • 管道名称冲突(确保唯一性)
  • 未考虑管道链的执行顺序