🅰️ День 2

Что планируем освоить: Примитивы Сигналов (signal, computed, effect), новые сигнальные инпуты (input, input.required), двустороннее связывание через model() и выходные события output().

Забудьте про useEffect с его бесконечными массивами зависимостей [deps] и проблемами с замыканиями (stale closures). В Angular 20+ Сигналы — это синхронный направленный ациклический граф (DAG). Когда вы вызываете effect() или computed(), рантайм Angular во время выполнения автоматически считывает (трекает) все сигналы внутри функции. Никакого ручного управления зависимостями — система гарантирует glitch-free реактивность без лишних вычислений «из коробки».

Шаг 1: Примитивы Сигналов и замена useEffect через effect() (3 часа)

В React для сайд-эффектов (логирование, синхронизация с LocalStorage) вы использовали useEffect. В Angular 20+ эту роль выполняет функция effect(). Она запускается внутри конструктора или контекста внедрения (Injection Context) и автоматически реагирует на изменения атомов данных.

Генерируем изолированный компонент для первого эксперимента (сразу со всеми вашими DX-флагами):

yarn ng generate component components/day-2/step-1 --selector="exp-day-2-step-1" --standalone --inline-template --inline-style --skip-tests

Исходный код.

Как запустить и что проверить в браузере:

  1. Подключите этот компонент в src/app/app.ts (добавьте Day2Step1 в массив imports) и выведите тег <exp-day-2-step-1></exp-day-2-step-1> в шаблоне app.html.
  2. Откройте консоль браузера. При первой загрузке вы увидите лог 🏗️ [Constructor], лог пересчета 🟢 [Computed Nodes] и первичный запуск эффекта 📡 [Effect Triggered].
  3. Нажмите кнопку «Мутировать холостой сигнал». В консоли появится лог клика, но сам эффект и computed-метод останутся в абсолютной тишине. В отличие от useEffect без указания зависимостей или внешних стейт-менеджеров, реактивность здесь изолирована хирургически на уровне атомов данных.

Шаг 2: Сигнальные инпуты input() и выходные события output() (3 часа)

Наша цель на этом шаге — полностью перестроить ментальную модель передачи пропсов (Props) и колбэков, к которой вы привыкли в React.

Компонентное взаимодействие (React Props vs Angular Signals)

В React пропсы — это объект, который полностью пересоздается при ререндере родителя, из-за чего дочерний компонент тоже вынужден перевызываться (если не закрыться с помощью React.memo).

В Angular 20+ входящие свойства — это сигнальные инпуты (input()). Они являются полноценными readonly-сигналами (Nodes в графе реактивности). Если родитель меняет значение пропса, Angular обновляет только конкретную текстовую ноду внутри дочернего компонента. Сам дочерний класс при этом не перерендеривается. Для передачи событий наверх используется новый компактный примитив output().

yarn ng generate component components/day-2/step-2 --selector="exp-day-2-step-2" --standalone --inline-template --inline-style --skip-tests

CLI создаст один файл src/app/components/day-2/step-2/step-2.ts.

👉 Сделайте сами

Откройте созданный файл и попробуйте пошагово воспроизвести следующее:

  1. Создайте два компонента в таком порядке: дочерний Day2Step2Child с селектором exp-day-2-step-2-child и родительский Day2Step2 с селектором exp-day-2-step-2 (родительский будет импортировать дочерний, поэтому родительский Day2Step2 объявляем последним)
    • Поскольку у нас standalone-архитектура, и родитель (Day2Step2) рендерит ребенка (Day2Step2Child), они должны знать друг о друге на уровне TS. Правило для одного файла: сначала объявляем дочерний компонент с декоратором, а затем родительский, который импортирует дочерний в свой массив imports).
    • В Angular каждый визуальный элемент (компонент) обязан иметь свой декоратор @Component. Нельзя просто объявить пустой класс и ожидать, что он станет компонентом.
  2. В src/app/app.ts импортируем и подключаем только родительский
  3. В разметку родительского src/app/components/day-2/step-2/step-2.ts добавляем тег дочернего, соответствующий его селектору
  4. В разметку src/app/app.html добавляем только родительский (добавляем тег родительского, соответствующий его селектору)
  5. Состояние родителя:
    parentCoreName = signal('Main-Core');
    parentFrequency = signal(3200);
    lastChildLog = signal('Пока нет сигналов');
  6. Состояние дочернего:
    // Сигнальные инпуты (Props нового поколения)
    coreName = input.required<string>(); // Обязательный проп
    frequency = input<number>(2400);     // Опциональный проп с дефолтным значением
    
    // Аналог useMemo, завязанный строго на инпут coreName
    derivedMetric = computed(() => {
      console.log('🟢 [Child Computed]: Пересчитываю метрику на основе coreName...');
      return `[${this.coreName().toUpperCase()}]—SYS-OK`;
    });
    
    // Новый компактный примитив для генерации событий наверх
    pingEvent = output<string>();
  7. Обработчики родительского:
      changeCoreName() {
      this.parentCoreName.set(Math.random() > 0.5 ? 'Quantum-Core' : 'Matrix-Unit');
    }
    
    changeFrequency() {
      this.parentFrequency.update(f => f + 200);
    }
    
    // Обработчик события от ребенка. Аргумент $event строго типизирован.
    onChildPing(message: string) {
      this.lastChildLog.set(message);
      console.log(`📡 [Parent Engine] Перехвачено событие: ${message}`);
    }
  8. Обработчики дочернего:
    sendPing() {
      this.pingEvent.emit(`Пинг из дочернего потока в ${new Date().toLocaleTimeString()}`);
    }
  9. Передача сигналов родителя в инпуты ребенка через квадратные скобки [prop] и слушателя событий ребенка через круглые скобки (event):
    <exp-day-2-step-2-child
      [coreName]="parentCoreName()"
      [frequency]="parentFrequency()"
      (pingEvent)="onChildPing($event)"
    />

Здесь объявлены оба класса с префиксом Day2Step2, наглядно демонстрирующие работу input(), input.required() и output(): Полный исходный код компонентов Day2Step2Child и Step2Component (а также инструкции по подключению в src/app/app.ts и src/app/app.html) вы можете найти в исходном документе. Ниже приведена краткая структура реализации:

  1. Day2Step2Child — дочерний компонент, принимающий обязательный инпут coreName = input.required<string>(), опциональный frequence = input<number>(2400), производное состояние derivedMetric через computed, а также отправку событий наверх через pingEvent = output<string>().
  2. Step2Component — родительский класс со структурой сигналов состояния и разметкой, связывающей родительские данные с дочерним узлом exp-day-2-step-2-child.

Перечислю, что нового увидел React-разработчик:

  1. Синальный инпут input (для дочернего компонента) в двух вариациях: обязательная и опиональная
  2. Новый компактный примитив output для генерации событий наверх
  3. Спеифический проброс сигналов в "инпуты" и слушателей

Попробуйте кликнуть на «2. Изменить frequency родителя» и убедитесь в консоли, что лог 🟢 [Child Computed] молчит — Angular обновил только текстовый узел частоты, полностью проигнорировав остальной шаблон ребенка.

⚛️ Эквивалент на React

Разберем фундаментальный синтаксис связи компонентов в Angular. Для Senior React разработчика она поначалу выглядит чужеродно из-за обилия скобок, но в ней скрыта строгая и очень логичная система.

В эквивалентном примере на React вы передаете всё в одном объекте props, смешивая данные и колбэки:

<Day2Step2Child 
  coreName={parentCoreName}      // Передача переменной (проп)
  frequency={parentFrequency}    // Передача переменной (проп)
  onPingEvent={onChildPing}      // Передача функции (колбэк)
/>

В Angular потоки данных разделены на уровне синтаксиса с помощью скобок:

  • Квадратные скобки [ ] — это поток данных внутрь компонента.
  • Круглые скобки ( ) — это поток событий наружу из компонента.

Разбор синтаксиса и закрепление материала

[coreName]="parentCoreName()"
  • Что значат [ ]: Мы говорим компилятору Angular: «В дочернем компоненте есть сигнальный инпут с именем coreName. Привяжись к нему».
  • Почему справа вызов функции parentCoreName(): Поскольку parentCoreName — это Сигнал в родительском классе, мы обязаны его вызвать, чтобы извлечь текущее значение.
  • Аналогия в React: Это передача обычного пропа сверху вниз. Изменится сигнал в родителе — дочерний инпут автоматически получит новое значение.
(pingEvent)="onChildPing($event)"
  • Что значат ( ): Мы говорим Angular: «Слушай событие (выходной поток) с именем pingEvent, которое генерирует дочерний компонент».
  • Что такое $event: Это зарезервированное имя переменной в шаблонах Angular. В него рантайм автоматически упаковывает те данные, которые дочерний компонент выбросил наружу через метод .emit(data). В нашем случае туда прилетает строка string.
  • Аналогия в React: Это передача функции-колбэка onPingEvent={data => onChildPing(data)}.

Как легко привыкнуть

[ Квадратные ] = Данные заходят (Вход). Представьте их как жесткие стены коробки (Box), в которую вы упаковываете данные и отправляете вниз дочернему элементу.

( Круглые ) = События вылетают (Выход). Представьте их как круглую кнопку (Button), на которую нажимают, или как взрывную волну события, которая летит наверх к родителю.

Таким обазом, если вы видите [property], вы сразу понимаете, что дочерний элемент зависит от состояния родителя. Если видите (action), вы сразу понимаете, что родитель реагирует на действия внутри дочернего элемента.

Чем это лучше?

Как Senior-разработчик, вы оцените это в рантайме:

  • В React, когда вы пишете <Component onClick={handleClick} />, при каждом ререндере родителя ссылка на функцию handleClick может пересоздаваться (если не обернуть её в useCallback), что часто приводит к лишним ререндерам ребенка.
  • В Angular запись (pingEvent)="onChildPing($event)" не пересоздает функции в памяти шаблона. Роутер событий (output()) настраивается компилятором один раз при инициализации. Вы можете мутировать состояние родителя как угодно, но рантайм никогда не будет заниматься холостым перевызовом дочерних обработчиков.

Шаг 3: Двустороннее связывание через model() (2 часа)

В React двустороннее связывание состояния с полем ввода (или дочерним компонентом) всегда пишется через явную связку свойства и колбэка: value={text} и onChange={e => setText(e.target.value)}.

В Angular 20+ появился революционный примитив model(). Он объединяет в себе свойства сигнального инпута (input()) и выходного события (output()). В шаблоне это порождает синтаксис, который в мире Angular называют «Бананом в коробке»: [(modelName)].

yarn ng generate component components/day-2/step-3 --selector="exp-day-2-step-3" --standalone --inline-template --inline-style --skip-tests

Исходный код.

FormsModule в Angular — это...

🔬 Что потестировать в браузере:

  • Инпут родителя: Меняйте значение в верхнем текстовом поле. Нижний компонент мгновенно подтягивает цифру.
  • Инпут ребенка: Меняйте значение в нижнем поле. Вы увидите, что значение в родителе реактивно обновляется в ту же миллисекунду.
  • Кнопки: Нажимайте кнопки сброса или форсирования. Данные синхронизируются во всех направлениях без единого холостого ререндера или перевызова функций классов.

Небольшое пояснение

В React нет встроенного механизма для связывания состояния с тегом <input>. Вам всегда нужно вручную писать value={state} и onChange={e => setState(...)}.

В Angular директива [(ngModel)] внутри FormsModule — это просто синтаксический сахар (макрос). Когда вы пишете:

<input [(ngModel)]="parentLevel" />

Компилятор Angular разворачивает это в обычный проброс данных и слушатель события:

<input [ngModel]="parentLevel()" (ngModelChange)="parentLevel.set($event)" />

FormsModule не занимается «невидимым пробросом» данных между вашими компонентами (родителем и ребенком). В примере Шага 3 данные между родительским Day2Step3 и дочерним Day2Step3Child пробрасывались абсолютно явно через синтаксис [(level)]="parentLevel".

Как работает DI (Аналог Context API без проброса пропсов)

Вместо того чтобы гонять сигналы через три уровня компонентов с помощью [(model)] или [input], мы создаем Изолированный Сервис (Store):

// shared-state.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({
  providedIn: 'root' // Глобальный синглтон, доступен везде автоматически!
})
export class SharedStateService {
  globalLevel = signal(42);
}

Теперь любой компонент на любой глубине UI-дерева может получить доступ к этому состоянию одной строчкой, без каких-либо провайдеров в HTML:

// Глубоко вложенный дочерний компонент
@Component({
  selector: 'app-deep-child',
  standalone: true,
  template: `<button (click)="state.globalLevel.set(100)">Сбросить стейт из глубины</button>`
})
export class DeepChild {
  // Настоящий аналог useContext() в Angular 20+:
  protected state = inject(SharedStateService); 
}

Резюме ментального сдвига:

  • FormsModule — это просто утилита, которая избавляет вас от написания ручных onChange обработчиков для стандартных HTML инпутов.
  • Dependency Injection (DI) + функция inject() — это ваш новый инструмент вместо Context API, который позволяет связывать компоненты с глобальными или локальными сторами данных без раздувания HTML-шаблона километрами провайдеров.

Сигнальные Пропсы (Signal Inputs) — это...

Однонаправленное связывание свойств (Property Binding) — это...

Связывание событий (Event Binding) — это...

Двустороннее связывание (Two-Way Data Binding) — это...

🧪 Итоговый лабораторный вывод Дня 2

  1. Примитивы и Эффекты (effect): Заменяют useEffect, автоматически отслеживая только те сигналы, которые реально считываются внутри их контекста. Изменение неиспользуемых сигналов не вызывает повторных срабатываний.
  2. Сигнальные инпуты (input, input.required) и Outputs (output): Превращают входящие пропсы в безопасные readonly-сигналы, интегрируемые в computed-графы, а события передают наверх без лишних ререндеров и создания колбэков в памяти.
  3. Глитч-фри архитектура: Движок Angular самостоятельно управляет графом зависимостей (DAG) в рантайме, избавляя разработчика от проблем с массивами зависимостей и useCallback, useMemo.