Перейти к содержимому

11. Пользовательские Pipes

Когда встроенных Pipes недостаточно — создавай свои. Кастомные Pipes позволяют инкапсулировать любую логику трансформации данных и переиспользовать её в шаблонах всего приложения.


Любой кастомный Pipe состоит из трёх обязательных частей:

import { Pipe, PipeTransform } from '@angular/core';
// 1. @Pipe декоратор — регистрирует класс как pipe
@Pipe({
name: 'myPipe', // Имя для использования в шаблоне: {{ val | myPipe }}
pure: true, // По умолчанию true — только при смене ссылки
standalone: true, // Angular 14+ — без NgModule!
})
// 2. implements PipeTransform — интерфейс с методом transform
export class MyPipe implements PipeTransform {
// 3. Метод transform — принимает значение и аргументы, возвращает результат
transform(value: unknown, ...args: unknown[]): unknown {
return value;
}
}

Самый популярный кастомный pipe — обрезка длинного текста:

truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'truncate',
standalone: true,
})
export class TruncatePipe implements PipeTransform {
/**
* @param value - исходная строка
* @param limit - максимальная длина (по умолчанию 100)
* @param trail - суффикс при обрезке (по умолчанию '...')
* @param breakWord - обрывать по символу (true) или по слову (false)
*/
transform(
value: string | null | undefined,
limit = 100,
trail = '...',
breakWord = true
): string {
if (!value) return '';
if (value.length <= limit) return value;
if (breakWord) {
return value.slice(0, limit - trail.length) + trail;
}
// Обрезать по ближайшему слову
const truncated = value.slice(0, limit - trail.length);
const lastSpace = truncated.lastIndexOf(' ');
return (lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated) + trail;
}
}
<!-- Использование в шаблоне -->
{{ article.description | truncate }} <!-- до 100 символов -->
{{ article.description | truncate:50 }} <!-- до 50 символов -->
{{ article.description | truncate:30:'…' }} <!-- кастомный суффикс -->
{{ article.description | truncate:80:'...':false }} <!-- по границе слова -->

time-ago.pipe.ts
import { Pipe, PipeTransform, OnDestroy } from '@angular/core';
@Pipe({
name: 'timeAgo',
standalone: true,
pure: false, // Impure! Время меняется независимо от входных данных
})
export class TimeAgoPipe implements PipeTransform, OnDestroy {
private timer: ReturnType<typeof setInterval> | null = null;
transform(value: Date | string | number): string {
const date = new Date(value);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
this.scheduleUpdate();
if (seconds < 5) return 'только что';
if (seconds < 60) return \`\${seconds} сек. назад\`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return \`\${minutes} мин. назад\`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return \`\${hours} ч. назад\`;
const days = Math.floor(hours / 24);
if (days < 7) return \`\${days} дн. назад\`;
const weeks = Math.floor(days / 7);
if (weeks < 4) return \`\${weeks} нед. назад\`;
const months = Math.floor(days / 30);
if (months < 12) return \`\${months} мес. назад\`;
return \`\${Math.floor(months / 12)} лет назад\`;
}
private scheduleUpdate(): void {
if (!this.timer) {
this.timer = setInterval(() => { /* change detection trigger */ }, 30_000);
}
}
ngOnDestroy(): void {
if (this.timer) {
clearInterval(this.timer);
}
}
}
<!-- Использование -->
<span>{{ post.createdAt | timeAgo }}</span> <!-- "5 мин. назад" -->
<span>{{ comment.date | timeAgo }}</span> <!-- "2 дн. назад" -->

🔍 Pipe: Highlight (подсветка поискового запроса)

Заголовок раздела «🔍 Pipe: Highlight (подсветка поискового запроса)»
highlight.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Pipe({
name: 'highlight',
standalone: true,
pure: false, // Impure — search query меняется извне
})
export class HighlightPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}
transform(text: string, search: string): SafeHtml {
if (!search || !text) return text;
// Экранируем спецсимволы RegExp
const escaped = search.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
const regex = new RegExp(\`(\${escaped})\`, 'gi');
const highlighted = text.replace(
regex,
'<mark style="background:#fde047;color:#1e293b;border-radius:2px">$1</mark>'
);
// DomSanitizer защищает от XSS
return this.sanitizer.bypassSecurityTrustHtml(highlighted);
}
}
<!-- Использование: innerHtml обязателен для HTML результата -->
<p [innerHTML]="item.title | highlight:searchQuery"></p>
<p [innerHTML]="item.body | highlight:searchQuery | truncate:200"></p>

📁 Pipe: FileSize (форматирование размера файла)

Заголовок раздела «📁 Pipe: FileSize (форматирование размера файла)»
file-size.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
type SizeUnit = 'B' | 'KB' | 'MB' | 'GB' | 'TB';
@Pipe({
name: 'fileSize',
standalone: true,
})
export class FileSizePipe implements PipeTransform {
private readonly units: SizeUnit[] = ['B', 'KB', 'MB', 'GB', 'TB'];
transform(bytes: number, decimals = 2, unit?: SizeUnit): string {
if (bytes === 0) return '0 B';
if (bytes < 0) return 'Invalid';
if (unit) {
const idx = this.units.indexOf(unit);
const converted = bytes / Math.pow(1024, idx);
return \`\${converted.toFixed(decimals)} \${unit}\`;
}
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const size = bytes / Math.pow(k, i);
return \`\${parseFloat(size.toFixed(decimals))} \${this.units[i]}\`;
}
}
{{ file.size | fileSize }} <!-- "4.56 MB" -->
{{ file.size | fileSize:0 }} <!-- "5 MB" (без дробей) -->
{{ file.size | fileSize:3:'KB' }} <!-- "4567.123 KB" -->
{{ diskSpace | fileSize:1:'GB' }} <!-- "2.5 GB" -->

filter.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'filter',
standalone: true,
pure: false, // Следит за мутациями массива
})
export class FilterPipe implements PipeTransform {
transform<T extends Record<string, unknown>>(
items: T[] | null | undefined,
searchTerm: string,
fields: string[] = []
): T[] {
if (!items) return [];
if (!searchTerm.trim()) return items;
const term = searchTerm.toLowerCase();
return items.filter(item => {
if (fields.length === 0) {
// Ищем по всем строковым полям
return Object.values(item).some(
val => typeof val === 'string' && val.toLowerCase().includes(term)
);
}
// Ищем по указанным полям
return fields.some(field => {
const val = item[field];
return typeof val === 'string' && val.toLowerCase().includes(term);
});
});
}
}
<!-- Фильтрация по всем полям -->
@for (user of users | filter:searchText; track user.id) {
<li>{{ user.name }} — {{ user.email }}</li>
}
<!-- Фильтрация по конкретным полям -->
@for (product of products | filter:query:['name', 'description']; track product.id) {
<li>{{ product.name }}</li>
}

group-by.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'groupBy',
standalone: true,
})
export class GroupByPipe implements PipeTransform {
transform<T>(
items: T[],
field: keyof T
): { key: string; items: T[] }[] {
if (!items || !field) return [];
const groups = items.reduce((acc, item) => {
const key = String(item[field]);
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {} as Record<string, T[]>);
return Object.entries(groups).map(([key, items]) => ({ key, items }));
}
}
@for (group of products | groupBy:'category'; track group.key) {
<h3>{{ group.key }}</h3>
@for (product of group.items; track product.id) {
<p>{{ product.name }}</p>
}
}

Когда нужно выполнить асинхронную операцию внутри pipe:

translate.pipe.ts
import { Pipe, PipeTransform, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { TranslateService } from './translate.service';
@Pipe({
name: 'translate',
standalone: true,
pure: false, // Impure — язык может смениться извне
})
export class TranslatePipe implements PipeTransform, OnDestroy {
private lastKey = '';
private lastResult = '';
private subscription = Subscription.EMPTY;
constructor(
private translateService: TranslateService,
private cd: ChangeDetectorRef
) {}
transform(key: string): string {
if (key === this.lastKey) return this.lastResult;
this.lastKey = key;
this.subscription.unsubscribe();
this.subscription = this.translateService
.get(key)
.subscribe(value => {
this.lastResult = value;
this.cd.markForCheck(); // Уведомить Angular об изменении
});
return this.lastResult || key;
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
}

// С standalone: true — не нужен NgModule!
@Pipe({
name: 'truncate',
standalone: true, // ← Ключевое поле
})
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 100): string {
return value?.length > limit ? value.slice(0, limit) + '...' : value ?? '';
}
}
// Использование в standalone компоненте
@Component({
standalone: true,
imports: [TruncatePipe], // ← Просто импортируем
template: `{{ description | truncate:50 }}`
})
export class ArticleCardComponent {
description = 'Очень длинное описание статьи...';
}
// Barrel export для удобства
// pipes/index.ts
export * from './truncate.pipe';
export * from './time-ago.pipe';
export * from './file-size.pipe';
export * from './filter.pipe';
export * from './highlight.pipe';

ПараметрPure PipeImpure Pipe
pure в декоратореtrue (дефолт)false
Вызов transform()При смене ссылки на входные данныеПри каждом цикле CD
Производительность✅ Высокая⚠️ Зависит от логики
Видит мутации❌ Нет✅ Да
Подходит дляЧистых трансформацийФильтров, переводов, относительного времени
Встроенные примерыdate, currency, uppercaseasync

truncate.pipe.spec.ts
import { TruncatePipe } from './truncate.pipe';
describe('TruncatePipe', () => {
let pipe: TruncatePipe;
beforeEach(() => {
pipe = new TruncatePipe();
});
it('возвращает пустую строку для null', () => {
expect(pipe.transform(null)).toBe('');
});
it('не обрезает текст короче лимита', () => {
expect(pipe.transform('hello', 10)).toBe('hello');
});
it('обрезает текст по лимиту с суффиксом', () => {
const result = pipe.transform('hello world test', 10);
expect(result.length).toBeLessThanOrEqual(10);
expect(result).toEndWith('...');
});
it('поддерживает кастомный суффикс', () => {
const result = pipe.transform('hello world test', 8, '…');
expect(result).toEndWith('…');
});
});