Эксперимент: Капсула времени из Октября 2026 в Апрель 2027

Что будет, если Senior Frontend разработчик с 7-летним опытом в React решит с нуля вкатиться в Angular 20+?

В этом спешл-выпуске я провожу ментальный эксперимент. Я записываю этот подкаст в конце октября 2026 года (до того как приступаю к работе с кодом на Angular), но моделирую ситуацию так, будто на дворе уже март-апрель 2027 года — ровно полгода с момента старта моего Angular-трека.

Это не просто сухой обзор технологии, а настоящая «капсула времени» и проверка моей собственной инженерной интуиции. Основываясь на своем опыте работы с реактивными архитектурами, микрофронтендами и написанием собственного стейт-менеджера на сигналах, я на ходу выстраиваю Roadmap перехода, нахожу неожиданные аналогии между React и Angular, и фиксирую свои ожидания.

Включайте этот мини-выпуск, чтобы узнать, как выглядит современный Angular глазами прожженного реактера, и совпадет ли эта теоретическая альтернативная реальность с тем, что я обнаружу, когда переслушаю эту запись через 6 месяцев.

Часть 1: Теоретическая - Ментальный мост (React Senior ➡️ Angular 20+)

Надеюсь, бэкграунд в проектировании высоконагруженных SPA, микрофронтендов, опыт с XState и написание собственного @pravosleva/reactive-engine стал идеальной базой для Angular 20+ (подумал я в апреле 2027). Современный Angular (образца 2026–2027 годов) ушел от тяжеловесных концептов прошлого и предлагает реактивность, которая будет тебе интуитивно понятна.

Сравнительная таблица концептов

Концепт в ReactАналог в Angular 20+В чем принципиальная разница?
useState / useMemoSignals (signal, computed)В Angular Сигналы — это полноценный граф зависимостей. Изменения точечно обновляют DOM (Fine-grained reactivity) без ререндера всего дерева компонентов. Прощай, useCallback и боли с ререндерами.
useEffecteffect()Сигнал-эффект автоматически трекает все сигналы, прочитанные внутри него. Не нужно вручную передавать массив зависимостей в духе [deps].
Context APIDependency Injection (DI)В React Контекст жестко привязан к UI-дереву. В Angular DI — это мощнейший движок на уровне TS-классов/функций. Можно внедрить инстанс куда угодно (в компонент, сервис, роутер), и иерархия внедрения контролируется декларативно.
Кастомные хуки (useAuth, useFetch)Injectable Services (Сервисы)Вместо функций, возвращающих стейт, в Angular ты пишешь класс-сервис (с декоратором @Injectable()), инкапсулируешь туда Сигналы/RxJS и «внедряешь» его через функцию inject(AuthService).
React RouterAngular RouterНамного мощнее «из коробки». Поддерживает ленивую загрузку компонентов через loadComponent: () => import(...), встроенные Guards (защита роутов) и Resolvers (предзагрузка данных), работающие через DI.
Жизненный цикл (Deps в useEffect)ЖЦ Компонентов + SignalsВместо одного хука на все случаи жизни есть четкие методы: ngOnInit (монтирование), ngOnDestroy (размонтирование). Но с Сигналами многие задачи ЖЦ (например, реакция на изменение инпутов) теперь элегантно решаются через computed или новые input() сигналы.

Специфика Angular 20+ (Что важно знать Senior-разработчику)

  1. Zoneless по умолчанию: Забудь про старые статьи, где ругают Angular за Zone.js (инструмент, перехватывающий асинхронные события для запуска Change Detection). В Angular 20+ приложения работают в Zoneless-режиме на Сигналах. Перформанс теперь максимальный «из коробки».
  2. Modern State Management: Больше нет жесткой необходимости тащить огромный NgRx (аналог Redux/Redux Saga). Сейчас стандартом стали Signals-based сервисы (легковесные сторы на сигналах) или @ngrx/signals — модульный, функциональный стейт-менеджер. Полагаю, @pravosleva/reactive-engine с адаптером под Angular идеально встанет в эту экосистему для управления сложными взаимосвязями состояний (но не является целевыи решением).
  3. Standalong-компоненты: Angular полностью отказался от старых модулей (NgModule). Теперь каждый компонент независим, сам импортирует свои зависимости (standalone: true по умолчанию), что делает архитектуру очень похожей на компонентный подход в React / Next.js.

Что под капотом Angular?

Ацикличность — это...

Часть 2: Оптимистическая

Этот план рассчитан на человека с сильной базой в JS/TS и реактивных архитектурах. Мы опускаем основы программирования и фокусируемся исключительно на механике Angular 20+ в режиме 1–2 дня на блок.

Пошаговый Роадмап адаптации (интенсивный трек для Senior-разработчика):

Блок 1: Быстрый старт и ментальный сдвиг (Дни 1–2)

  • День 1: Окружение и Анатомия. Установка Angular CLI. Инициализация проекта с флагами --standalone и без Zone.js (Zoneless). Изучение структуры standalone-компонента: декоратор @Component, секция imports, метаданные.
  • День 2: Новый шаблонизатор (Control Flow). Освоение встроенного синтаксиса шаблонов Angular 17+: @if / @else, @for (item of items; track item.id), @switch. Сравнение производительности track с key в React.

Блок 2: Реактивность нового поколения — Signals (Дни 3–4)

  • День 3: Примитивы Сигналов. Изучение signal(), computed() и effect(). Понимание графа зависимостей (glitch-free reactivity). Как Angular точечно обновляет DOM без глобального рендеринга компонента.
  • День 4: Компонентное взаимодействие на Сигналах. Новые сигнальные инпуты input() и input.required(). Двустороннее связывание через model(). Выходные события output(). Переписывание логики типичного React-компонента с пропсами на сигнальную модель Angular.

Блок 3: Архитектура, Сервисы и Dependency Injection (Дни 5–6)

  • День 5: Движок DI и Функция inject(). Понимание иерархии инжекторов (EnvironmentInjector vs NodeInjector). Использование современной функции inject(MyService) вместо старого внедрения через конструктор класса.
  • День 6: Паттерн "Сервис как Стор". Создание Singleton-сервисов (providedIn: 'root') для управления глобальным состоянием на Сигналах. Инкапсуляция бизнес-логики вне UI-слоя (чистое разделение ответственности). Тестирование интеграции твоего опенсорс-движка @pravosleva/reactive-engine через DI-сервис.

Блок 4: Навигация и Асинхронные потоки (Дни 7–8)

  • День 7: Angular Router. Конфигурация роутинга. Ленивая загрузка компонентов через loadComponent. Функциональные Guards и Resolvers с использованием inject(). Передача параметров роута прямо в сигнальные input().
  • День 8: RxJS и Мосты с Сигналами. Понимание, где RxJS незаменим (асинхронные потоки, race conditions, debounce, веб-сокеты). Освоение функций-мостов из @angular/core/rxjs-interop: toSignal() и toObservable().

Блок 5: Практика и Закрепление (Дни 9–10)

  • Дни 9–10: Микро-пет-проект. Сборка небольшого дашборда с ленивыми роутами, DI-сервисами для данных, обработкой асинхронных запросов через RxJS и отображением через Сигналы. Настройка кастомного DX и проверка перформанса через Chrome DevTools Performance.

Часть 3: Экспериментальная

Когда мы пытаемся лечить проблемы ререндеров React с помощью внешних стейт-менеджеров (MobX, Valtio, @pravosleva/reactive-engine с прокси/сигналами), мы получаем реактивность, но сталкиваемся с архитектурными ограничениями самой библиотеки UI. Вот как на их фоне выглядит нативный Zoneless Angular:

Сравнение: Нативный Zoneless Angular 20+ vs Сторонние реактивные движки в React (MobX, Valtio, кастомные решения)

Критерий сравненияReact + Внешний реактивный движок (MobX / Valtio)Нативный Zoneless Angular 20+
Единая ментальная модельРазделение. Приходится балансировать между декларативным миром React (useState/хуки) и императивным/мутабельным миром внешнего движка.Монолит. Сигналы — это стандарт для всего: от локального стейта компонента до глобальных сервисов и роутинга.
Связь с шаблоном (DOM)Через VDOM-прослойку. Изменение в сторе все равно триггерит ререндер компонента-обертки (через хуки/обсерверы) и запускает VDOM-диффинг.Прямой надрез. Фреймворк компилирует шаблон так, что сигнал обновляет конкретную текстовую ноду напрямую в живом DOM.
Оптимизация «из коробки»Ручная. Нужно следить за тем, чтобы не «потерять» прокси-реактивность при деструктуризации пропсов или передаче данных в дочерние компоненты.Автоматическая. Граф зависимостей Сигналов собирается сам на этапе компиляции и рантайма. Потерять реактивность невозможно по дизайну.
Проблема «Серебряной пули»Костыль. Внешний движок в React — это попытка вылечить природу компонентных ререндеров сторонним инструментом. Борьба с фреймворком.Серебряная пуля. Избавление от самой концепции тотального ререндера. Фреймворк изначально спроектирован под event-driven обновления.
Архитектурная связностьРазмытая. Компоненты зависят от внешних инстансов сторов. Контекст React часто используется как шина данных, ломая изоляцию.Строгая (DI). Мощнейший паттерн Dependency Injection гарантирует изоляцию, подменяемость и чистую архитектуру сервисов без привязки к UI.

Пример 1: Сравнение ментальных моделей на примере кода

Разница между тем, как компонент обновляет DOM в Zoneless-режиме Angular и как это происходит при обычном useState в React.

⚛️ Вариант на React: Компонентный ререндер

В React обновление стейта запускает вызов всей функции компонента заново.

import React, { useState } from 'react';

// Вызывается ПРИ КАЖДОМ клике
export function ReactCounter() {
  const [count, setCount] = useState(0);

  // Тяжелое вычисление, не связанное со счетчиком
  // Без useMemo оно будет триггериться при каждом setCount!
  const heavyCalculation = () => {
    console.log('🔴 React: Выполняю тяжелые вычисления...');
    return 42;
  };

  const expensiveValue = heavyCalculation(); 

  console.log('🔄 React: Ререндер всего компонента целиком');

  return (
    <div>
      <p>Счетчик: {count}</p>
      <p>Константа: {expensiveValue}</p>
      <button onClick={() => setCount(count + 1)}>Добавить</button>
    </div>
  );
}

Что происходит в рантайме React при клике:

  1. Вызывается setCount.
  2. React ставит компонент ReactCounter в очередь на ререндер.
  3. Функция ReactCounter() выполняется сверху вниз заново.
  4. Заново вызывается heavyCalculation() (если мы забыли завернуть её в useMemo).
  5. Пересоздается функция клика.
  6. Выполняется VDOM-диффинг для поиска изменений.

🅰️ Вариант на Angular 20+ (Zoneless + Signals)

В Angular компонент — это класс. При изменении сигнала функции класса и сам шаблон заново не перечитываются. Изменяется только конкретный текстовый узел в DOM.

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-angular-counter',
  standalone: true,
  template: `
    <div>
      <!-- Angular связывается напрямую с этими узлами -->
      <p>Счетчик: {{ count() }}</p>
      <p>Константа: {{ expensiveValue() }}</p>
      <button (click)="increment()">Добавить</button>
    </div>
  `
})
export class AngularCounterComponent {
  // Инициализация стейта (происходит ОДИН раз)
  count = signal(0);

  // Аналог useMemo. Вызовется ровно ОДИН раз при инициализации, 
  // так как внутри нет зависимых сигналов, которые меняются.
  expensiveValue = computed(() => {
    console.log('🟢 Angular: Выполняю тяжелые вычисления...');
    return 42;
  });

  constructor() {
    console.log('🏗️ Angular: Компонент инициализирован');
  }

  increment() {
    this.count.update(c => c + 1);
  }
}

Что происходит в рантайме Angular (Zoneless) при клике:

  1. Вызывается метод increment(), который обновляет сигнал count.
  2. Сигнал оповещает граф зависимостей: «Мое значение теперь X».
  3. Angular не перерендеривает компонент. Он не заходит в constructor, не выполняет заново тело класса.
  4. Метод expensiveValue не вызывается, так как граф знает, что expensiveValue не зависит от count.
  5. Angular идет напрямую к конкретному тегу <p> в DOM, где написано {{ count() }}, и меняет там текстовую ноду.

🧪 Лабораторное резюме

В React обновление состояния — это процесс сверху вниз по UI-дереву с последующей фильтрацией через VDOM и хуки оптимизации. В Zoneless Angular обновление сигнала — это хирургический надрез. Компонент выступает лишь как декларативная фабрика, которая отработала один раз при монтировании, настроила связи, а дальше граф реактивности гоняет точечные обновления напрямую в живой DOM, полностью игнорируя остальную логику класса.

Пример 2

Вот раскрытие тезиса о Dependency Injection (DI) и функции inject() на минималистичных эквивалентных примерах. Задача: есть глубоко вложенный компонент, которому нужны данные авторизованного пользователя и метод для логаута из глобального сервиса Auth.

⚛️ Вариант на React: Пропсы или Context API

В React для этого используется Context API. Чтобы получить данные в глубоком компоненте, нам нужно импортировать сам объект контекста и вызвать хук внутри UI-компонента. Если компонент окажется вне провайдера, приложение упадет или вернет undefined.

import React, { createContext, useContext, useState } from 'react';

// 1. Создаем тип и сам контекст
interface AuthContextType {
  user: string;
  logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);

// 2. Дочерний компонент (глубоко в дереве)
export function UserProfileReact() {
  // Хук жестко привязан к UI-дереву React
  const auth = useContext(AuthContext);
  
  if (!auth) throw new Error('UserProfile должен быть внутри AuthProvider');

  return (
    <div>
      <span>Роль: {auth.user}</span>
      <button onClick={auth.logout}>Выйти</button>
    </div>
  );
}

// 3. Корневой компонент с Провайдером
export App = () => (
  <AuthContext.Provider value={{ user: 'admin', logout: () => console.log('out') }}>
    {/* Километры промежуточных компонентов без пропсов, но... */}
    <UserProfileReact />
  </AuthContext.Provider>
)

🅰️ Вариант на Angular 20+: Движок DI и функция inject()

В Angular сервис авторизации — это независимый синглтон-класс. Компоненту всё равно, где он находится в UI-дереве. Он не зависит от провайдеров-оберток в шаблоне. Функция inject() запрашивает инстанс напрямую у ядра Angular.

import { Component, Injectable, inject, signal } from '@angular/core';

// 1. Создаем изолированный сервис бизнес-логики
@Injectable({
  providedIn: 'root' // Сервис доступен везде в приложении автоматически
})
export class AuthService {
  user = signal('admin');
  
  logout() {
    console.log('out');
  }
}

// 2. Глубокий компонент
@Component({
  selector: 'app-user-profile',
  standalone: true,
  template: `
    <div>
      <span>Роль: {{ auth.user() }}</span>
      <button (click)="auth.logout()">Выйти</button>
    </div>
  `
})
export class UserProfileAngular {
  // Внедряем сервис ОДНОЙ строчкой внутри класса. 
  // Шаблон компонента чист от провайдеров.
  protected auth = inject(AuthService);
}

🧪 Лабораторное резюме: Замена пропсов через inject() (раскрытие тезиса)

В React Context API завязан на иерархию UI-дерева (компоненты обязательно должны быть дочерними по отношению к <Provider>).

В Angular движок DI работает на уровне классов и модулей (runtime инжекторов), полностью отделенных от визуального дерева:

  1. Изоляция: Компонент UserProfileAngular декларативно заявляет: «Мне нужен AuthService» через функцию inject().
  2. Чистый шаблон: В HTML-шаблоне приложения исчезают «километры» оборачивающих провайдеров (<Context.Provider>), которые в React часто раздувают дерево до нечитаемых масштабов.
  3. Тестируемость: В тестах Angular позволяет подменить AuthService на мок-объект в одну строчку через конфигурацию инжектора, не трогая рендеринг самого компонента.

Пример 3: Реактивный асинхронный граф и паттерн «Service-as-a-Store» со сквозными зависимостями

Что демонстрирует этот пример: Данный опыт наглядно иллюстрирует работу нативного направленного ациклического графа Angular 20+ при управлении глобальным состоянием и сетевым слоем без использования тяжеловесных Redux-архитектур (классического NgRx). На примере сквозной реактивной связи (от изменения локального сигнала до автоматического триггера HTTP-запроса) пример показывает, как инструмент rxResource выступает в роли автоматического потребителя (Consumer). Он самостоятельно отслеживает зависимости, прозрачно передает динамические Query-параметры в GET-запросы через встроенный HttpClient, обеспечивает автоматическую отмену зависших потоков и бесшовно инкапсулирует данные в тело (body) POST-запросов, гарантируя точечное обновление интерфейса в Zoneless-режиме без использования Virtual DOM.

🧬 Про RxJS и NgRx

  • RxJS — это про ПОТОКИ и ВРЕМЯ. Инструмент для декларативного управления асинхронными событиями. Он остается фундаментальным ядром Angular для краевых кейсов и сложных сетевых сценариев.
  • NgRx — это про СТРУКТУРУ СТОРA (Redux-way). Тяжелая архитектурная надстройка для контроля глобального состояния. В эпоху Angular 20+ (Zoneless + Сигналы) классический NgRx признан избыточным, так как фреймворк теперь решает задачи реактивности и производительности нативного графа состояний «из коробки».

RxJS — это...

NgRx — это...

⚛️ Вариант 1: Чистый React (useState + useEffect)

Здесь нам приходится вручную управлять состояниями загрузки, ошибок, а главное — синхронизировать очистку сетевого потока через AbortController, чтобы избежать race conditions при быстрой смене userId.

import React, { useState, useEffect } from 'react';

export interface Todo {
  id?: number;
  title: string;
  completed: boolean;
  userId: number;
}

export function ReactTodoStoreClean() {
  // 1. Локальное состояние — аналог Сигналов
  const [userId, setUserId] = useState<number>(1);
  const [todos, setTodos] = useState<Todo[]>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);

  // 2. Декларативный перезапрос при изменении userId (Аналог rxResource)
  useEffect(() => {
    const abortController = new AbortController();
    
    async function fetchTodos() {
      setIsLoading(true);
      try {
        console.log(`📡 Лаборатория (React): GET-запрос. Инициализирован поиск для userId: ${userId}`);
        // Передача query-параметра в URL
        const response = await fetch(`https://typicode.com{userId}&_limit=5`, {
          signal: abortController.signal // Передаем токен отмены
        });
        const data = await response.json();
        setTodos(data);
      } catch (error: any) {
        if (error.name !== 'AbortError') {
          console.error('🔴 Лаборатория: Ошибка запроса', error);
        }
      } finally {
        setIsLoading(false);
      }
    }

    fetchTodos();

    // Cleanup-функция: если userId изменится до завершения fetch, старый запрос прервется
    return () => abortController.abort();
  }, [userId]); // Массив зависимостей — ручной трекинг графа

  // 3. Метод создания задачи (POST) с подмешиванием userId в тело запроса
  const addTodo = async (title: string) => {
    const newTodo: Todo = { title, completed: false, userId };

    try {
      console.log('📡 Лаборатория (React): POST-запрос. Тело запроса отправлено:', newTodo);
      const response = await fetch('https://typicode.com', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newTodo) // Передача данных в теле (body)
      });
      const createdTodo = await response.json();
      
      // Оптимистичное обновление UI
      setTodos(current => [...current, createdTodo]);
    } catch (error) {
      console.error('🔴 Ошибка при создании', error);
    }
  };

  return { userId, setUserId, todos, isLoading, addTodo };
}

⚛️ Вариант 2: React + TanStack Query (React Query)

Этот подход гораздо ближе к декларативной магии rxResource из Angular 20+. Зависимости трекаются через массив ключей кэша (queryKey).

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

export function ReactTodoStoreQuery() {
  const queryClient = useQueryClient();
  const [userId, setUserId] = useState<number>(1);

  // 1. Декларативный ресурс. Зависимость userId прописывается в queryKey
  const { data: todos = [], isLoading } = useQuery({
    queryKey: ['todos', userId], // Смена userId заставит TanStack Query перезапустить fetch
    queryFn: async ({ signal }) => {
      console.log(`📡 Лаборатория (React Query): GET-запрос для userId: ${userId}`);
      const res = await fetch(`https://example.com/user?id=${userId}&_limit=5`, { signal });
      return res.json();
    }
  });

  // 2. Мутация для POST запроса
  const mutation = useMutation({
    mutationFn: async (title: string) => {
      const newTodo = { title, completed: false, userId }; // Подмешиваем userId в тело
      console.log('📡 Лаборатория (React Query): POST-запрос. Тело:', newTodo);
      
      const res = await fetch(`https://example.com/user?id=${userId}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newTodo)
      });
      return res.json();
    },
    onSuccess: (createdTodo) => {
      // Оптимистично пушим в кэш
      queryClient.setQueryData(['todos', userId], (current: any) => [...current, createdTodo]);
    }
  });

  return { userId, setUserId, todos, isLoading, addTodo: mutation.mutate };
}

🅰️ Как это пишется в Angular 20+ (Легковесный Signals-Service)

При изменении userId наш асинхронный граф реактивности (rxResource) будет автоматически триггерить TodoApiService, передавая userId как Query-параметр. А при создании новой задачи (POST), мы будем подмешивать этот userId прямо в тело запроса.

todo-store.service.ts

import { Injectable, inject, signal, computed } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { TodoApiService, Todo } from './todo-api.service';

@Injectable({ providedIn: 'root' })
export class TodoStoreService {
  private api = inject(TodoApiService);

  // 1. Сигнал-зависимость. Изменение userId автоматически перезапустит rxResource!
  userId = signal<number>(1);

  // 2. Современный ациклический асинхронный ресурс Angular 20+
  private todoResource = rxResource({
    // Передаем функцию request, которая трекает сигнал userId.
    // Если userId меняется, Angular понимает, что ресурс "протух" и перезапускает loader.
    request: () => ({ userId: this.userId() }),
    
    // В loader прилетает текущий объект из request
    loader: ({ request }) => this.api.getAllByUserId(request.userId)
  });

  // Публичные readonly данные для UI
  todos = computed(() => this.todoResource.value() ?? []);
  isLoading = this.todoResource.isLoading;

  // 3. Метод создания задачи (POST)
  addTodo(title: string) {
    const newTodo: Todo = {
      title,
      completed: false,
      userId: this.userId() // Подмешиваем текущий ID пользователя в тело запроса
    };

    // Вызываем API. Оптимистично обновляем локальный стейт при успехе
    this.api.create(newTodo).subscribe({
      next: (createdTodo) => {
        this.todoResource.update(current => current ? [...current, createdTodo] : [createdTodo]);
      }
    });
  }
}

todo-api.service.ts

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface Todo {
  id?: number;
  title: string;
  completed: boolean;
  userId: number; 
}

@Injectable({ providedIn: 'root' })
export class TodoApiService {
  private http = inject(HttpClient);
  
  // Базовый URL делаем статичным, без динамических переменных на уровне класса
  private readonly apiUrl = 'https://example.com/user';

  /**
   * GET запрос с Query-параметром зависимости (?id=X&_limit=5)
   */
  getAllByUserId(userId: number): Observable<Todo[]> {
    console.log(`📡 Лаборатория: GET-запрос. Инициализирован поиск для userId: ${userId}`);
    
    return this.http.get<Todo[]>(this.apiUrl, {
      // HttpClient сам превратит этот объект в строку ?id=userId&_limit=5
      params: { 
        id: userId.toString(), 
        _limit: '5' 
      }
    });
  }

  /**
   * POST запрос. Данные userId передаются внутри тела (body) запроса
   */
  create(todo: Todo): Observable<Todo> {
    console.log('📡 Лаборатория: POST-запрос. Тело запроса отправлено:', todo);
    
    // Отправляем POST на базовый URL, а todo (включая userId) улетает в теле запроса
    return this.http.post<Todo>(this.apiUrl, todo);
  }
}

Компонент TodoDashboardComponent, который связывает воедино наш легковесный стор (TodoStoreService) с UI-слоем. Он демонстрирует все фичи Angular 20+ в Zoneless-режиме: двустороннее связывание инпута с сигналом, вывод состояния загрузки и обработку списков через новый Control Flow.

todo-dashboard.component.ts

import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TodoStoreService } from './todo-store.service';

@Component({
  selector: 'app-todo-dashboard',
  standalone: true,
  // Нам нужен FormsModule для работы двустороннего связывания [(ngModel)]
  imports: [FormsModule], 
  template: `
    <div class="container">
      <h2>Лабораторный пульт: Управление задачами</h2>

      <!-- 1. Блок управления зависимостью (userId) -->
      <div class="control-panel">
        <label for="userIdInput">Идентификатор исследователя (User ID): </label>
        <input 
          id="userIdInput"
          type="number" 
          [ngModel]="store.userId()" 
          (ngModelChange)="store.userId.set($event)"
          min="1" 
        />
      </div>

      <!-- 2. Форма добавления новой задачи (POST) -->
      <div class="form-panel">
        <input 
          type="text" 
          placeholder="Название нового процесса..." 
          [(ngModel)]="newTodoTitle" 
        />
        <button (click)="onCreateTodo()" [disabled]="!newTodoTitle().trim()">
          Запустить процесс
        </button>
      </div>

      <hr />

      <!-- 3. Вывод состояния асинхронного запроса -->
      @if (store.isLoading()) {
        <div class="status loading">📡 Лаборатория: Выполняется сетевой фетчинг данных...</div>
      }

      <!-- 4. Вывод результатов через встроенный Control Flow -->
      <div class="results-panel">
        <h3>Список задач для User ID: {{ store.userId() }}</h3>
        
        <ul>
          @for (todo of store.todos(); track todo.id; let idx = $index) {
            <li [class.completed]="todo.completed">
              [{{ idx + 1 }}] {{ todo.title }} 
              @if (todo.id) { <small>(ID: {{ todo.id }})</small> }
            </li>
          } @empty {
            <li class="empty-state">🔬 Наблюдение: Данные отсутствуют или список пуст.</li>
          }
        </ul>
      </div>
    </div>
  `,
  styles: [`
    .container { padding: 20px; font-family: monospace; max-width: 600px; }
    .control-panel, .form-panel { margin-bottom: 15px; }
    input { padding: 5px; font-family: monospace; margin-right: 10px; }
    button { padding: 5px 10px; font-family: monospace; cursor: pointer; }
    .status { color: #888; font-style: italic; margin: 10px 0; }
    ul { list-style-type: none; padding: 0; }
    li { padding: 8px; background: #eee; margin-bottom: 5px; border-radius: 4px; }
    li.completed { text-decoration: line-through; opacity: 0.6; }
    .empty-state { background: #fff3cd; color: #856404; }
  `]
})
export class TodoDashboardComponent {
  // Внедряем наш легковесный стор одной строчкой
  protected store = inject(TodoStoreService);

  // Локальный сигнальный стейт для поля ввода новой задачи
  protected newTodoTitle = signal('');

  /**
   * Обработчик создания новой задачи
   */
  protected onCreateTodo() {
    const title = this.newTodoTitle().trim();
    if (title) {
      // Вызываем экшен стора (userId подмешается внутри стора автоматически)
      this.store.addTodo(title);
      // Очищаем поле ввода
      this.newTodoTitle.set('');
    }
  }
}

Раскрытие тезиса: Зависимые запросы (React-way vs Angular Рантайн)

Сравнение кода наглядно обнажает концептуальное различие между фреймворками:

  1. Раздувание кодовой базы в React: На чистом React разработчик превращается в диспетчера асинхронных потоков. Ему приходится вручную писать логику отмены (AbortController), следить за флагами загрузки и синхронизировать массивы зависимостей useEffect. Ошибка в массиве [deps] ведет к бесконечным запросам или "протухшему" стейту.
  2. Сторонние зависимости: Использование TanStack Query решает проблему бойлерплейта и делает код похожим на Angular. Однако это сторонняя библиотека, которая привносит свой контекст, свои правила кэширования и увеличивает вес бандла.
  3. Нативный ациклический граф Angular: В Angular 20+ инструмент rxResource встроен в ядро фреймворка. Он автоматически «слушает» сигналы внутри себя без написания строковых ключей кэша (queryKey). Сетевой слой (HttpClient) нативно интегрирован с менеджером ресурсов, что делает обработку зависимых GET и POST запросов частью единой, чистой экосистемы, поставляемой из коробки.

Раскрытие тезиса: Рантайм UI-компонента в Zoneless-режиме

Этот пример наглядно демонстрирует, как полностью исчезает концепция компонентного ререндера, привычная по React:

  1. Реактивный мост инпута: Конструкция [ngModel]="store.userId()" (ngModelChange)="store.userId.set($event)" связывает нативный <input> с сигналом в сторе. Как только ты меняешь цифру в инпуте, срабатывает метод .set(), граф реактивности на ходу помечает rxResource как устаревший, а HttpClient тут же отправляет новый GET-запрос с актуальным userId в Query-параметрах.
  2. Изоляция изменений: При вводе символов в инпут создания задачи ([(ngModel)]="newTodoTitle") Angular обновляет исключительно локальный текстовый узел внутри этого инпута. Весь остальной класс компонента, его тяжелые вычисления или соседние ветки DOM-дерева даже не знают об этих изменениях.
  3. Декларативная секция @empty: Новый цикл @for содержит встроенный блок @empty. Это избавляет от необходимости писать тернарные операторы или логические цепочки условий вида {todos.length === 0 && ...}, как в JSX, делая шаблон чистым и легко читаемым.

🧪 Лабораторное резюме: Ациклический граф реактивности в сетевых запросах

В экосистеме React для реализации зависимого запроса (например, перезапрос данных при изменении userId) нам пришлось бы писать useEffect с отслеживанием [userId] и ручным вызовом фетчинга, либо завязываться на декларативные ключи в сторонних библиотеках вроде useQuery([ 'todos', userId ]).

В Zoneless Angular 20+ связка rxResource и HttpClient демонстрирует чистоту нативного графа:

  1. Автоматический трекинг зависимостей: Внутри request: () => ({ userId: this.userId() }) Angular под капотом регистрирует наш rxResource как Consumer для сигнала userId (Producer). Стоит нам в любой точке приложения вызвать userId.set(2), граф мгновенно пометит ресурс как устаревший, отменит предыдущий HTTP-запрос (благодаря RxJS-механике под капотом) и выполнит новый GET с query-параметром ?userId=2.
  2. Иммутабельное тело запроса: При вызове addTodo(), данные из сигнала userId извлекаются в момент выполнения экшена и инкапсулируются в тело POST-запроса, гарантируя строгое соответствие отправляемых на сервер данных текущему состоянию системы.

Пример 4: Новый синтаксис шаблонизатора (Control Flow)

Задача: отобразить панель управления, где в зависимости от статуса загрузки показывается спиннер, пустая корзина или список элементов с выводом индекса и подсветкой четных строк.

⚛️ Вариант на React: Логические операторы и метод map в JSX

В React шаблонизация строится на возможностях самого JavaScript — тернарных операторах, логическом && и методе массивов .map(). Каждая итерация требует ручного указания уникального пропса key.

import React from 'react';

interface ComponentProps {
  items: string[];
  isLoading: boolean;
}

export function ReactControlFlow({ items, isLoading }: ComponentProps) {
  if (isLoading) {
    return <div>Загрузка...</div>;
  }

  return (
    <div className="container">
      {items.length === 0 ? (
        <p>Список пуст</p>
      ) : (
        <ul>
          {items.map((item, index) => (
            <li 
              key={item} // Важно для VDOM
              style={{ backgroundColor: index % 2 === 0 ? '#eee' : '#fff' }}
            >
              [{index + 1}] {item}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

🅰️ Вариант на Angular 20+: Встроенный декларативный Control Flow

В Angular, начиная с 17-й версии, отказались от старых директив (*ngIf, *ngFor). Вместо них используется встроенный макросинтаксис, который компилируется в максимально производительный JS-код обновлений. Параметр track здесь является строго обязательным.

import { Component, input } from '@angular/core';

@Component({
  selector: 'app-angular-control-flow',
  standalone: true,
  template: `
    <div class="container">
      <!-- 1. Встроенное ветвление -->
      &#64;if (isLoading()) {
        <div>Загрузка...</div>
      } &#64;else if (items().length === 0) {
        <p>Список пуст</p>
      } &#64;else {
        <ul>
          <!-- 2. Встроенный цикл с контекстными переменными -->
          &#64;for (item of items(); track item; let idx = $index, isEven = $even) {
            <li [style.backgroundColor]="isEven ? '#eee' : '#fff'">
              [{{ idx + 1 }}] {{ item }}
            </li>
          }
        </ul>
      }
    </div>
  `
})
export class AngularControlFlowComponent {
  // Сигнальные инпуты (современный подход Angular 20+)
  items = input<string[]>([]);
  isLoading = input<boolean>(false);
}

🧪 Лабораторный вывод: Эволюция шаблонов (JSX vs Angular Control Flow)

Перестройка визуального восприятия заключается в переходе от гибкого, но подверженного ошибкам рантайм-кода JS (внутри JSX) к строгому декларативному синтаксису компилятора Angular.

  1. Производительность компиляции: Конструкции @if и @for в Angular преобразуются не в дерево виртуального DOM, а в прямые инструкции обновления конкретных живых DOM-нод. Отсутствие VDOM-диффинга делает обработку списков экстремально быстрой.
  2. Безопасность типов: Ошибиться в track в цикле @for сложнее — компилятор выдаст ошибку сборки, если выражение отсутствует. В React пропуск key выдаст лишь варнинг в консоли браузера во время работы приложения.
  3. Локальный контекст: Встроенные переменные цикла вроде $index, $even, $odd, $first, $last доступны прямо из коробки и рассчитываются автоматически на уровне ядра фреймворка, избавляя от ручного вычисления математики четности внутри шаблона.

Пример 5: Живой поиск (Debounce + Отмена предыдущего запроса / Race Condition)

Задача: Пользователь вводит текст в инпут. Нужно подождать 300 мс (debounce), и если пользователь ввел новый символ до того, как пришел ответ на предыдущий запрос, — старый запрос должен быть немедленно отменен (switchMap), чтобы избежать гонки условий (race condition). Результат нужно положить в реактивный стейт.

❌ Попытка сделать это только на Сигналах (React-way через эффекты)

В React мы бы писали useEffect с очисткой (cleanup) через AbortController. На чистых Сигналах Angular это выглядело бы так же переусложненно: нам пришлось бы вручную городить setTimeout, хранить ID таймера и инстансы AbortController в свойствах класса, плодя императивный код.

🅰️ Идеальный симбиоз: Сигналы + RxJS Операторы

Современный Angular решает это реактивным конвейером: Сигнал ввода ➡️ Поток RxJS (для магии со временем) ➡️ Сигнал результата.

import { Component, signal, inject } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { SearchApiService } from './search-api.service';

@Component({
  selector: 'app-live-search',
  standalone: true,
  template: `
    <div>
      <!-- Двустороннее связывание инпута с сигналом query -->
      <input [ngModel]="query()" (ngModelChange)="query.set($event)" placeholder="Поиск товара..." />

      @if (results.isLoading()) {
        <div>Фильтрация на сервере...</div>
      }

      <ul>
        @for (item of results(); track item) {
          <li>{{ item }}</li>
        }
      </ul>
    </div>
  `
})
export class LiveSearchComponent {
  private searchApi = inject(SearchApiService);

  // 1. Сигнал, куда летит каждый символ из инпута
  query = signal('');

  // 2. Превращаем сигнал в Observable поток, применяем операторы и возвращаем в Сигнал
  results = toSignal(
    toObservable(this.query).pipe(
      debounceTime(300),           // Ждем 300 мс затишья
      distinctUntilChanged(),      // Игнорируем, если текст не изменился (например, нажали Backspace и тут же ту же букву)
      switchMap(text => {          // Магия: если летит новый запрос, switchMap отменяет старый HTTP-поток!
        console.log(`📡 Лаборатория: Отправка запроса для: "${text}"`);
        return this.searchApi.search(text);
      })
    ),
    { initialValue: [] as string[] } // Стартовое значение для результирующего сигнала
  );
}

Вот минималистичный код для SearchApiService. В Angular для выполнения HTTP-запросов используется встроенный HttpClient. Он возвращает потоки RxJS (Observable), что идеально стыкуется с оператором switchMap из Примера 5. Обрати внимание: когда switchMap переключается на новый запрос, подписка на предыдущий Observable отменяется автоматически, и HttpClient на уровне браузера сам делает abort() для незавершенного XHR/Fetch запроса.

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, delay } from 'rxjs/operators';

@Injectable({
  providedIn: 'root' // Сервис регистрируется как синглтон на корневом уровне
})
export class SearchApiService {
  // Внедряем встроенный HTTP-клиент Angular через DI
  private http = inject(HttpClient);
  private apiUrl = 'https://example.com';

  /**
   * Метод поиска. Возвращает поток со списком строк.
   * Если строка пустая, мгновенно возвращает пустой массив без запроса к сети.
   */
  search(query: string): Observable<string[]> {
    if (!query.trim()) {
      return of([]); // Возвращает синхронный поток с пустым массивом
    }

    // Делаем реальный GET-запрос с query-параметром
    return this.http.get<string[]>(this.apiUrl, {
      params: { q: query }
    }).pipe(
      // Имитируем задержку сети для наглядности лабораторного опыта (в прод не нужно)
      delay(150), 
      // Обработка ошибок, чтобы поток не "умер" при неудачном запросе
      catchError(error => {
        console.error('🔴 Лаборатория: Ошибка при поиске товаров:', error);
        return of([]); // В случае ошибки возвращаем пустой массив
      })
    );
  }
}

🅰️ Анатомия асинхронного моста (HttpClient + RxJS)

В отличие от экосистемы React, где для сетевых запросов приходится выбирать между fetch, axios, react-query или RTK Query, в Angular сетевой слой поставляется «из коробки» и нативно интегрирован с RxJS.

  1. Автоматический Abort: Поскольку метод http.get возвращает Observable, в связке со switchMap из Примера 5 отмена старого запроса происходит на уровне сетевого стека браузера ( Cancelled в вкладке Network). Никаких ручных манипуляций с AbortController.signal делать не нужно.
  2. Безопасность типов: Метод http.get<string[]> сразу типизирует возвращаемый поток данных без необходимости писать кастомные обертки-трансформеры.
  3. Глобальный перехват (Interceptors): Если для авторизации к запросу нужно прикрепить JWT-токен, в Angular это решается созданием одной функции-интерцептора, которая автоматически обрабатывает все сетевые вызовы в приложении, избавляя от необходимости настраивать инстансы в каждом сервисе.

🧪 Лабораторный вывод: Зачем нужен RxJS, если есть Сигналы

В экосистеме React для борьбы с гонками условий (Race Conditions) и дребезгом (Debounce) инженерам приходится вручную управлять сайд-эффектами внутри useEffect через AbortController или тащить тяжелые библиотеки вроде redux-saga / RxJS поверх архитектуры.

В Angular 20+ Сигналы и RxJS не конкурируют, а распределяют обязанности:

  1. Разделение труда: Сигналы отвечают за состояние (State) и мгновенное обновление UI. RxJS отвечает за события и время (Events/Time) — там, где поток данных асинхронен и подвержен задержкам.
  2. Бесшовные мосты (rxjs-interop): Функция toObservable(mySignal) позволяет мгновенно встроиться в мощный мир декларативных операторов RxJS. А функция toSignal(myObservable$) безболезненно возвращает обработанный поток в синхронный UI-слой, автоматически отписываясь от потока при уничтожении компонента (прощай, утечки памяти и бойлерплейт с ngOnDestroy).
  3. Краевые кейсы: Для задач вроде обработки Веб-сокетов, Drag-and-Drop или SwitchMap-фильтрации RxJS остается непревзойденной «серебряной пулей», аналогов которой в чистом React до сих пор нет из коробки.

Пример 6: Про interceptors

В Angular 20+ интерцепторы (мидлвары) пишутся в виде простых и легковесных функциональных выражений, а не тяжелых классов, как это было в старых версиях. Они перехватывают любые запросы, сделанные через HttpClient, и работают на уровне движка DI. Разберем синтаксис на примере классического authInterceptor, который решает две задачи:

  1. Автоматически прикрепляет JWT-токен из AuthService (из Примера 2) в заголовки Authorization.
  2. Перехватывает ошибки 401 (Unauthorized) для организации логаута.

🅰️ Функциональный интерцептор

auth.interceptor.ts

import { HttpInterceptorFn, HttpHandlerFn, HttpRequest, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>, 
  next: HttpHandlerFn
) => {
  // 1. Внедряем AuthService прямо внутри функции через DI движок!
  const authService = inject(AuthService);
  const token = 'SECRET_JWT_TOKEN'; // В реальном кейсе: authService.getToken()

  console.log(`🛡️ Лаборатория: Мидлвар перехватил запрос к ${req.url}`);

  // 2. Клонируем запрос и добавляем заголовок (HttpRequest иммутабелен по дизайну)
  const modifiedReq = req.clone({
    setHeaders: {
      Authorization: `Bearer ${token}`
    }
  });

  // 3. Передаем модифицированный запрос дальше по цепочке
  return next(modifiedReq).pipe(
    // 4. Перехватываем ответ на случай сетевых ошибок
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        console.warn('🔴 Лаборатория: Токен не валиден (401). Принудительный логаут.');
        authService.logout(); // Вызываем метод очистки сессии
      }
      return throwError(() => error); // Прокидываем ошибку дальше в сервис
    })
  );
};

🅰️ Регистрация интерцептора в приложении

Чтобы мидлвар заработал глобально, его нужно передать в конфигурацию провайдеров HttpClient при инициализации приложения:

app.config.ts

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    // Регистрируем HTTP-клиент вместе с нашим массивом мидлваров
    provideHttpClient(
      withInterceptors([authInterceptor])
    )
  ]
};

🧪 Лабораторный вывод: Глобальные интерцепторы (React-мидлвары vs Angular DI)

В экосистеме React для создания подобного слоя приходится либо настраивать кастомные инстансы axios (что усложняет архитектуру, если часть команды использует стандартный fetch), либо писать кастомные обертки (middlewares) поверх RTK Query / Zustand.

Функциональные интерцепторы в Angular 20+ демонстрируют превосходство экосистемы фреймворка:

  1. Функциональный DX: Интерцептор — это чистая функция HttpInterceptorFn. Больше никаких громоздких классов и бойлерплейта из старых версий Angular.
  2. Нативный DI в функциях: Функция inject(AuthService) бесшовно работает внутри интерцептора, связывая сетевой слой с реактивным состоянием приложения без необходимости прокидывать контекст через UI-компоненты.
  3. Цепочки обязанностей (Chain of Responsibility): В метод withInterceptors([...]) можно передать массив функций (например, loggingInterceptor, authInterceptor, errorInterceptor). Они отработают строго по очереди, формируя прозрачный конвейер обработки данных.

Пример 7: Конфигурация роутера с Lazy Loading, функциональным Guard и Resolver

Задача: Настроить маршрут для приватного дашборда /dashboard.

  1. Lazy Loading: Компонент дашборда должен загружаться только тогда, когда пользователь переходит по ссылке.
  2. Functional Guard: Роут должен пускать только авторизованных пользователей (проверка через AuthService).
  3. Functional Resolver (Предзагрузка): Перед рендерингом компонента нужно асинхронно запросить критические данные, чтобы компонент отобразился сразу с данными, без мигания лоадеров.

Шаг 1. Описание маршрутов

app.routes.ts

import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { DashboardApiService } from './dashboard-api.service';
import { map } from 'rxjs/operators';

export const appRoutes: Routes = [
  {
    path: 'dashboard',
    // 1. Ленивая загрузка standalone-компонента (Lazy Loading из коробки)
    loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
    
    // 2. Функциональная защита роута (Guard)
    canActivate: [
      () => {
        const auth = inject(AuthService);
        // Сигнал auth.isLoggedIn() трекается прямо внутри функции
        return auth.isLoggedIn() ? true : auth.redirectToLogin();
      }
    ],
    
    // 3. Функциональная предзагрузка данных (Resolver)
    resolve: {
      stats: () => {
        const api = inject(DashboardApiService);
        console.log('📡 Лаборатория: Перехват роута, запуск предзагрузки данных...');
        return api.getMetrics(); // Возвращает Observable поток с данными
      }
    }
  }
];

Шаг 2: Компонент Дашборда

В Angular 20+ параметры роутера и данные резолверов можно прокидывать напрямую в компонент в виде Сигнальных инпутов (Signal Inputs). Для этого достаточно включить одну опцию в конфиге роутера (withComponentInputBinding()).

dashboard.component.ts

import { Component, input } from '@angular/core';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `
    <div class="dashboard-panel">
      <h1>Лабораторная панель управления</h1>
      
      <!-- Читаем данные из Сигнального инпута, который наполнил Resolver -->
      <div class="metrics">
        <p>Активные процессы: {{ stats().activeProcesses }}</p>
        <p>Эффективность рантайма: {{ stats().efficiency }}%</p>
      </div>
    </div>
  `
})
export class DashboardComponent {
  // Название инпута строго совпадает с ключом в объекте resolve конфига путей
  stats = input.required<{ activeProcesses: number; efficiency: number }>();
}

🧪 Лабораторный вывод: Нативная маршрутизация (React Router vs Angular Router)

В экосистеме React роутинг собирается через стороннюю библиотеку (react-router-dom). Для ленивой загрузки приходится комбинировать React.lazy() и <Suspense>, а предзагрузка данных (loaders) требует написания специфических функций, которые сложно связать с чистым Dependency Injection без дополнительных костылей.

Angular Router 20+ демонстрирует монолитность и законченность архитектуры фреймворка:

  1. Декларативный Lazy Loading: Функция loadComponent разделяет бандл на этапе сборки автоматически. Никаких оберток вроде <Suspense> в шаблоне не требуется — фреймворк сам знает, когда скачать чанк.
  2. Функциональные Guards: Защита роутов теперь весит несколько строк. Это чистые функции, которые могут использовать inject() для проверки глобального состояния (например, Сигнала авторизации) прямо в момент перехода.
  3. Прямой стриминг в Сигналы: Опция withComponentInputBinding() связывает данные роута (параметры URL, query-параметры и данные Resolver-ов) напрямую с сигнальными инпутами компонента. UI-компонент становится изолированным, тестируемым и полностью избавляется от необходимости подписываться на сервис ActivatedRoute вручную.