11. Пользовательские Pipes
🛠️ Кастомные Pipes в Angular
Заголовок раздела «🛠️ Кастомные Pipes в Angular»Когда встроенных Pipes недостаточно — создавай свои. Кастомные Pipes позволяют инкапсулировать любую логику трансформации данных и переиспользовать её в шаблонах всего приложения.
🏗️ Анатомия кастомного Pipe
Заголовок раздела «🏗️ Анатомия кастомного Pipe»Любой кастомный 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 — интерфейс с методом transformexport class MyPipe implements PipeTransform { // 3. Метод transform — принимает значение и аргументы, возвращает результат transform(value: unknown, ...args: unknown[]): unknown { return value; }}✂️ Pipe: Truncate (обрезка текста)
Заголовок раздела «✂️ Pipe: Truncate (обрезка текста)»Самый популярный кастомный pipe — обрезка длинного текста:
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 }} <!-- по границе слова -->⏰ Pipe: TimeAgo («x времени назад»)
Заголовок раздела «⏰ Pipe: TimeAgo («x времени назад»)»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 (подсветка поискового запроса)»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 (форматирование размера файла)»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" -->🔎 Pipe: Filter (фильтрация массива)
Заголовок раздела «🔎 Pipe: Filter (фильтрация массива)»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>}🔑 Pipe: GroupBy (группировка массива)
Заголовок раздела «🔑 Pipe: GroupBy (группировка массива)»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> }}⚡ Async кастомный Pipe
Заголовок раздела «⚡ Async кастомный Pipe»Когда нужно выполнить асинхронную операцию внутри pipe:
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 Pipes (Angular 14+)
Заголовок раздела «📦 Standalone Pipes (Angular 14+)»// С 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.tsexport * from './truncate.pipe';export * from './time-ago.pipe';export * from './file-size.pipe';export * from './filter.pipe';export * from './highlight.pipe';📊 Сравнение Pure vs Impure Pipes
Заголовок раздела «📊 Сравнение Pure vs Impure Pipes»| Параметр | Pure Pipe | Impure Pipe |
|---|---|---|
pure в декораторе | true (дефолт) | false |
Вызов transform() | При смене ссылки на входные данные | При каждом цикле CD |
| Производительность | ✅ Высокая | ⚠️ Зависит от логики |
| Видит мутации | ❌ Нет | ✅ Да |
| Подходит для | Чистых трансформаций | Фильтров, переводов, относительного времени |
| Встроенные примеры | date, currency, uppercase | async |
🧪 Тестирование кастомных Pipes
Заголовок раздела «🧪 Тестирование кастомных Pipes»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('…'); });});