24. @Input и @Output
📡 @Input и @Output: Коммуникация компонентов
Заголовок раздела «📡 @Input и @Output: Коммуникация компонентов»Компоненты в Angular общаются через входные данные (@Input) и исходящие события (@Output). Это основной паттерн parent-child взаимодействия.
📥 @Input() — входные данные от родителя
Заголовок раздела «📥 @Input() — входные данные от родителя»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"/>🔒 @Input({required: true}) — Angular 16+
Заголовок раздела «🔒 @Input({required: true}) — Angular 16+»Начиная с 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 выдаёт ошибку во время сборки.
🔄 Input transforms — преобразование значений
Заголовок раздела «🔄 Input transforms — преобразование значений»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() и EventEmitter
Заголовок раздела «📤 @Output() и EventEmitter»@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; }}🔗 Псевдонимы Input/Output
Заголовок раздела «🔗 Псевдонимы Input/Output»// Псевдоним — другое имя снаружи 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;}🔁 Two-way binding с @Input + @Output
Заголовок раздела «🔁 Two-way binding с @Input + @Output»Паттерн “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" />📊 ngOnChanges — реакция на изменения Input
Заголовок раздела «📊 ngOnChanges — реакция на изменения Input»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() { /* ... */ }}🚦 Паттерн: умный vs тупой компонент
Заголовок раздела «🚦 Паттерн: умный vs тупой компонент»// "Умный" компонент (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> );}