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

24. @Input и @Output

Компоненты в Angular общаются через входные данные (@Input) и исходящие события (@Output). Это основной паттерн parent-child взаимодействия.


import { Component, Input } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `
<div class="card">
<h3>{{ name }}</h3>
<p>{{ email }}</p>
<span [class]="'badge badge-' + role">{{ role }}</span>
</div>
`
})
export class UserCardComponent {
@Input() name: string = '';
@Input() email: string = '';
@Input() role: string = 'user';
}

Использование в родителе:

<app-user-card
name="Яша Смирнов"
role="admin"
/>
<!-- Динамические значения -->
<app-user-card
[name]="currentUser.name"
[email]="currentUser.email"
[role]="currentUser.role"
/>

Начиная с Angular 16 можно пометить Input как обязательный:

import { Component, Input } from '@angular/core';
@Component({
selector: 'app-avatar',
template: `<img [src]="imageUrl" [alt]="userName" />`
})
export class AvatarComponent {
@Input({ required: true }) imageUrl!: string; // ошибка компиляции если не передан
@Input({ required: true }) userName!: string;
@Input() size: 'sm' | 'md' | 'lg' = 'md'; // опциональный с дефолтом
}

При отсутствии обязательного Input — Angular выдаёт ошибку во время сборки.


Angular 16.1+ позволяет трансформировать входное значение:

import { Component, Input, booleanAttribute, numberAttribute } from '@angular/core';
@Component({ selector: 'app-button', template: `...` })
export class ButtonComponent {
// Принимает строку "true"/"false" и конвертирует в boolean
@Input({ transform: booleanAttribute }) disabled: boolean = false;
// Принимает строку "42" и конвертирует в number
@Input({ transform: numberAttribute }) size: number = 16;
// Кастомный трансформ — обрезаем строку
@Input({ transform: (value: string) => value.trim().toLowerCase() })
username: string = '';
}
<!-- Теперь работает без привязки -->
<app-button disabled size="24" username=" Яша " />

@Output() создаёт кастомное событие, которое слушает родитель:

import { Component, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div>
<button (click)="decrement()">−</button>
<span>{{ count }}</span>
<button (click)="increment()">+</button>
<button (click)="onReset()">Сброс</button>
</div>
`
})
export class CounterComponent {
@Input() initialValue: number = 0;
@Output() countChanged = new EventEmitter<number>();
@Output() reset = new EventEmitter<void>();
count = 0;
ngOnInit() {
this.count = this.initialValue;
}
increment() {
this.count++;
this.countChanged.emit(this.count); // отправляем событие
}
decrement() {
this.count--;
this.countChanged.emit(this.count);
}
onReset() {
this.count = 0;
this.reset.emit(); // void событие
}
}

Родитель слушает через (событие):

<app-counter
[initialValue]="5"
(countChanged)="onCountChange($event)"
(reset)="onCounterReset()"
/>
export class ParentComponent {
currentCount = 0;
history: number[] = [];
onCountChange(count: number) {
this.currentCount = count;
this.history.push(count);
}
onCounterReset() {
console.log('Счётчик сброшен');
this.currentCount = 0;
}
}

// Псевдоним — другое имя снаружи vs внутри
@Input('userProfile') profile!: UserProfile; // снаружи: [userProfile], внутри: this.profile
@Output('itemSelected') selected = new EventEmitter<Item>(); // снаружи: (itemSelected), внутри: this.selected.emit()

🏗️ Паттерн: передача объекта vs отдельных свойств

Заголовок раздела «🏗️ Паттерн: передача объекта vs отдельных свойств»
// ❌ Плохо — не сразу понятно что нужно компоненту
@Component({ ... })
export class UserCardComponent {
@Input() user!: User; // передаём весь объект
}
// ✅ Лучше — явные входные данные
@Component({ ... })
export class UserCardComponent {
@Input({ required: true }) name!: string;
@Input({ required: true }) avatarUrl!: string;
@Input() role: string = 'user';
@Input() isOnline: boolean = false;
}

Паттерн “banana in a box” [(property)]:

@Component({
selector: 'app-rating',
template: `
<div>
<span
*ngFor="let star of stars; index as i"
(click)="setValue(i + 1)"
>{{ i < value ? '★' : '☆' }}</span>
</div>
`
})
export class RatingComponent {
stars = [1, 2, 3, 4, 5];
@Input() value: number = 0;
@Output() valueChange = new EventEmitter<number>(); // ВАЖНО: имя = input + "Change"
setValue(rating: number) {
this.value = rating;
this.valueChange.emit(rating);
}
}
<!-- Two-way binding работает благодаря паттерну valueChange -->
<app-rating [(value)]="userRating" />
<!-- Эквивалентно -->
<app-rating [value]="userRating" (valueChange)="userRating = $event" />

import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
@Component({ selector: 'app-chart', template: `...` })
export class ChartComponent implements OnChanges {
@Input() data: number[] = [];
@Input() title: string = '';
ngOnChanges(changes: SimpleChanges) {
if (changes['data']) {
const { currentValue, previousValue, firstChange } = changes['data'];
console.log('Данные изменились:', previousValue, '→', currentValue);
if (!firstChange) {
this.redrawChart();
}
}
}
private redrawChart() { /* ... */ }
}

// "Умный" компонент (Container) — знает о данных и логике
@Component({
selector: 'app-users-container',
template: `
<app-users-list
[users]="users$ | async"
[loading]="loading"
(userSelected)="onUserSelect($event)"
(deleteUser)="onDeleteUser($event)"
/>
`
})
export class UsersContainerComponent {
users$ = this.userService.getUsers();
loading = false;
onUserSelect(user: User) { this.router.navigate(['/users', user.id]); }
onDeleteUser(userId: number) { this.userService.delete(userId).subscribe(); }
}
// "Тупой" компонент (Presentational) — только отображение
@Component({
selector: 'app-users-list',
template: `...`
})
export class UsersListComponent {
@Input() users: User[] | null = [];
@Input() loading: boolean = false;
@Output() userSelected = new EventEmitter<User>();
@Output() deleteUser = new EventEmitter<number>();
}

export default function InputOutputPlayground() {
const [parentCount, setParentCount] = React.useState(0);
const [childCount, setChildCount] = React.useState(5);
const [events, setEvents] = React.useState([]);
const [step, setStep] = React.useState(1);
const addEvent = (msg, direction) => {
setEvents(prev => [{ msg, direction, id: Date.now() }, ...prev].slice(0, 8));
};
// Child emits to parent
const handleChildIncrement = () => {
const newVal = childCount + step;
setChildCount(newVal);
addEvent(`📤 countChanged.emit(${newVal})`, 'up');
setParentCount(newVal);
};
const handleChildDecrement = () => {
const newVal = childCount - step;
setChildCount(newVal);
addEvent(`📤 countChanged.emit(${newVal})`, 'up');
setParentCount(newVal);
};
const handleChildReset = () => {
setChildCount(0);
setParentCount(0);
addEvent('📤 reset.emit() (void)', 'up');
};
// Parent changes input
const handleParentSet = (val) => {
addEvent(`📥 @Input() initialValue = ${val}`, 'down');
setChildCount(val);
setParentCount(val);
};
return (
<div style={{ background: '#0f172a', minHeight: 460, padding: 24, borderRadius: 12, fontFamily: 'system-ui', color: '#e2e8f0' }}>
<div style={{ color: '#dd0031', fontWeight: 700, fontSize: 16, marginBottom: 20 }}>
📡 @Input / @Output: Коммуникация компонентов
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr', gap: 16, marginBottom: 16 }}>
{/* Parent Component */}
<div style={{ background: '#1e293b', borderRadius: 10, padding: 16, border: '1px solid #7c3aed' }}>
<div style={{ color: '#a78bfa', fontSize: 12, fontWeight: 700, marginBottom: 12 }}>
🏠 ParentComponent
</div>
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 8 }}>
currentCount = <span style={{ color: '#a3e635', fontWeight: 700 }}>{parentCount}</span>
</div>
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>Изменить @Input():</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{[0, 3, 7, 10, -5].map(v => (
<button key={v} onClick={() => handleParentSet(v)} style={{ background: '#334155', color: '#94a3b8', border: 'none', padding: '4px 10px', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>
{v}
</button>
))}
</div>
</div>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 4 }}>Шаг приращения:</div>
<div style={{ display: 'flex', gap: 4 }}>
{[1, 2, 5].map(s => (
<button key={s} onClick={() => setStep(s)} style={{ background: step === s ? '#7c3aed' : '#334155', color: 'white', border: 'none', padding: '4px 12px', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>
+{s}
</button>
))}
</div>
</div>
{/* Arrow */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, color: '#475569', fontSize: 12 }}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#7dd3fc', marginBottom: 4 }}>↓ @Input()</div>
<div style={{ fontSize: 10, color: '#475569' }}>[initialValue]</div>
<div style={{ fontSize: 10, color: '#475569' }}>[step]</div>
</div>
<div style={{ width: 1, flex: 1, background: '#334155' }} />
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 10, color: '#475569' }}>(countChanged)</div>
<div style={{ fontSize: 10, color: '#475569' }}>(reset)</div>
<div style={{ color: '#dd0031', marginTop: 4 }}>↑ @Output()</div>
</div>
</div>
{/* Child Component */}
<div style={{ background: '#1e293b', borderRadius: 10, padding: 16, border: '1px solid #dd0031' }}>
<div style={{ color: '#dd0031', fontSize: 12, fontWeight: 700, marginBottom: 12 }}>
👶 CounterComponent
</div>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 4 }}>@Input() initialValue:</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#e2e8f0', textAlign: 'center', margin: '12px 0', padding: '12px', background: '#0f172a', borderRadius: 8 }}>
{childCount}
</div>
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', marginBottom: 8 }}>
<button onClick={handleChildDecrement} style={{ background: '#334155', color: '#e2e8f0', border: 'none', padding: '8px 16px', borderRadius: 8, cursor: 'pointer', fontSize: 16 }}>−</button>
<button onClick={handleChildIncrement} style={{ background: '#dd0031', color: 'white', border: 'none', padding: '8px 16px', borderRadius: 8, cursor: 'pointer', fontSize: 16 }}>+</button>
</div>
<button onClick={handleChildReset} style={{ width: '100%', background: 'transparent', color: '#64748b', border: '1px solid #334155', padding: '6px', borderRadius: 8, cursor: 'pointer', fontSize: 12 }}>
reset.emit()
</button>
</div>
</div>
{/* Event Log */}
<div style={{ background: '#1e293b', borderRadius: 8, padding: 14, border: '1px solid #334155' }}>
<div style={{ color: '#7dd3fc', fontSize: 12, marginBottom: 8 }}>📋 Event Log</div>
{events.length === 0 && <div style={{ color: '#475569', fontSize: 12 }}>Нажмите кнопки для взаимодействия...</div>}
{events.map((e, i) => (
<div key={e.id} style={{ fontSize: 12, color: i === 0 ? (e.direction === 'up' ? '#dd0031' : '#7dd3fc') : '#475569', marginBottom: 4, transition: 'color 0.3s' }}>
{e.direction === 'up' ? '↑' : '↓'} {e.msg}
</div>
))}
</div>
</div>
);
}