Эксперимент: Капсула времени из Октября 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 / useMemo | Signals (signal, computed) | В Angular Сигналы — это полноценный граф зависимостей. Изменения точечно обновляют DOM (Fine-grained reactivity) без ререндера всего дерева компонентов. Прощай, useCallback и боли с ререндерами. |
useEffect | effect() | Сигнал-эффект автоматически трекает все сигналы, прочитанные внутри него. Не нужно вручную передавать массив зависимостей в духе [deps]. |
Context API | Dependency Injection (DI) | В React Контекст жестко привязан к UI-дереву. В Angular DI — это мощнейший движок на уровне TS-классов/функций. Можно внедрить инстанс куда угодно (в компонент, сервис, роутер), и иерархия внедрения контролируется декларативно. |
Кастомные хуки (useAuth, useFetch) | Injectable Services (Сервисы) | Вместо функций, возвращающих стейт, в Angular ты пишешь класс-сервис (с декоратором @Injectable()), инкапсулируешь туда Сигналы/RxJS и «внедряешь» его через функцию inject(AuthService). |
React Router | Angular Router | Намного мощнее «из коробки». Поддерживает ленивую загрузку компонентов через loadComponent: () => import(...), встроенные Guards (защита роутов) и Resolvers (предзагрузка данных), работающие через DI. |
Жизненный цикл (Deps в useEffect) | ЖЦ Компонентов + Signals | Вместо одного хука на все случаи жизни есть четкие методы: ngOnInit (монтирование), ngOnDestroy (размонтирование). Но с Сигналами многие задачи ЖЦ (например, реакция на изменение инпутов) теперь элегантно решаются через computed или новые input() сигналы. |
Специфика Angular 20+ (Что важно знать Senior-разработчику)
- Zoneless по умолчанию: Забудь про старые статьи, где ругают Angular за
Zone.js(инструмент, перехватывающий асинхронные события для запуска Change Detection). В Angular 20+ приложения работают в Zoneless-режиме на Сигналах. Перформанс теперь максимальный «из коробки». - Modern State Management: Больше нет жесткой необходимости тащить огромный NgRx (аналог Redux/Redux Saga). Сейчас стандартом стали Signals-based сервисы (легковесные сторы на сигналах) или @ngrx/signals — модульный, функциональный стейт-менеджер. Полагаю,
@pravosleva/reactive-engineс адаптером под Angular идеально встанет в эту экосистему для управления сложными взаимосвязями состояний (но не является целевыи решением). - Standalong-компоненты: Angular полностью отказался от старых модулей (
NgModule). Теперь каждый компонент независим, сам импортирует свои зависимости (standalone: trueпо умолчанию), что делает архитектуру очень похожей на компонентный подход в React / Next.js.
Часть 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 при клике:
- Вызывается
setCount. - React ставит компонент
ReactCounterв очередь на ререндер. - Функция
ReactCounter()выполняется сверху вниз заново. - Заново вызывается
heavyCalculation()(если мы забыли завернуть её вuseMemo). - Пересоздается функция клика.
- Выполняется 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) при клике:
- Вызывается метод
increment(), который обновляет сигналcount. - Сигнал оповещает граф зависимостей: «Мое значение теперь X».
- Angular не перерендеривает компонент. Он не заходит в
constructor, не выполняет заново тело класса. - Метод
expensiveValueне вызывается, так как граф знает, чтоexpensiveValueне зависит отcount. - 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 инжекторов), полностью отделенных от визуального дерева:
- Изоляция: Компонент
UserProfileAngularдекларативно заявляет: «Мне нужен AuthService» через функциюinject(). - Чистый шаблон: В HTML-шаблоне приложения исчезают «километры» оборачивающих провайдеров (
<Context.Provider>), которые в React часто раздувают дерево до нечитаемых масштабов. - Тестируемость: В тестах 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 признан избыточным, так как фреймворк теперь решает задачи реактивности и производительности нативного графа состояний «из коробки».
⚛️ Вариант 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 Рантайн)
Сравнение кода наглядно обнажает концептуальное различие между фреймворками:
- Раздувание кодовой базы в React: На чистом React разработчик превращается в диспетчера асинхронных потоков. Ему приходится вручную писать логику отмены (
AbortController), следить за флагами загрузки и синхронизировать массивы зависимостейuseEffect. Ошибка в массиве[deps]ведет к бесконечным запросам или "протухшему" стейту. - Сторонние зависимости: Использование
TanStack Queryрешает проблему бойлерплейта и делает код похожим на Angular. Однако это сторонняя библиотека, которая привносит свой контекст, свои правила кэширования и увеличивает вес бандла. - Нативный ациклический граф Angular: В Angular 20+ инструмент
rxResourceвстроен в ядро фреймворка. Он автоматически «слушает» сигналы внутри себя без написания строковых ключей кэша (queryKey). Сетевой слой (HttpClient) нативно интегрирован с менеджером ресурсов, что делает обработку зависимыхGETиPOSTзапросов частью единой, чистой экосистемы, поставляемой из коробки.
Раскрытие тезиса: Рантайм UI-компонента в Zoneless-режиме
Этот пример наглядно демонстрирует, как полностью исчезает концепция компонентного ререндера, привычная по React:
- Реактивный мост инпута: Конструкция
[ngModel]="store.userId()" (ngModelChange)="store.userId.set($event)"связывает нативный<input>с сигналом в сторе. Как только ты меняешь цифру в инпуте, срабатывает метод.set(), граф реактивности на ходу помечаетrxResourceкак устаревший, аHttpClientтут же отправляет новыйGET-запрос с актуальнымuserIdв Query-параметрах. - Изоляция изменений: При вводе символов в инпут создания задачи (
[(ngModel)]="newTodoTitle") Angular обновляет исключительно локальный текстовый узел внутри этого инпута. Весь остальной класс компонента, его тяжелые вычисления или соседние ветки DOM-дерева даже не знают об этих изменениях. - Декларативная секция
@empty: Новый цикл@forсодержит встроенный блок@empty. Это избавляет от необходимости писать тернарные операторы или логические цепочки условий вида{todos.length === 0 && ...}, как в JSX, делая шаблон чистым и легко читаемым.
🧪 Лабораторное резюме: Ациклический граф реактивности в сетевых запросах
В экосистеме React для реализации зависимого запроса (например, перезапрос данных при изменении userId) нам пришлось бы писать useEffect с отслеживанием [userId] и ручным вызовом фетчинга, либо завязываться на декларативные ключи в сторонних библиотеках вроде useQuery([ 'todos', userId ]).
В Zoneless Angular 20+ связка rxResource и HttpClient демонстрирует чистоту нативного графа:
- Автоматический трекинг зависимостей: Внутри
request: () => ({ userId: this.userId() })Angular под капотом регистрирует нашrxResourceкак Consumer для сигналаuserId(Producer). Стоит нам в любой точке приложения вызватьuserId.set(2), граф мгновенно пометит ресурс как устаревший, отменит предыдущий HTTP-запрос (благодаря RxJS-механике под капотом) и выполнит новыйGETс query-параметром?userId=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. Встроенное ветвление -->
@if (isLoading()) {
<div>Загрузка...</div>
} @else if (items().length === 0) {
<p>Список пуст</p>
} @else {
<ul>
<!-- 2. Встроенный цикл с контекстными переменными -->
@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.
- Производительность компиляции: Конструкции
@ifи@forв Angular преобразуются не в дерево виртуального DOM, а в прямые инструкции обновления конкретных живых DOM-нод. Отсутствие VDOM-диффинга делает обработку списков экстремально быстрой. - Безопасность типов: Ошибиться в
trackв цикле@forсложнее — компилятор выдаст ошибку сборки, если выражение отсутствует. В React пропускkeyвыдаст лишь варнинг в консоли браузера во время работы приложения. - Локальный контекст: Встроенные переменные цикла вроде
$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.
- Автоматический Abort: Поскольку метод
http.getвозвращает Observable, в связке соswitchMapиз Примера 5 отмена старого запроса происходит на уровне сетевого стека браузера ( Cancelled в вкладке Network). Никаких ручных манипуляций сAbortController.signalделать не нужно. - Безопасность типов: Метод
http.get<string[]>сразу типизирует возвращаемый поток данных без необходимости писать кастомные обертки-трансформеры. - Глобальный перехват (Interceptors): Если для авторизации к запросу нужно прикрепить JWT-токен, в Angular это решается созданием одной функции-интерцептора, которая автоматически обрабатывает все сетевые вызовы в приложении, избавляя от необходимости настраивать инстансы в каждом сервисе.
🧪 Лабораторный вывод: Зачем нужен RxJS, если есть Сигналы
В экосистеме React для борьбы с гонками условий (Race Conditions) и дребезгом (Debounce) инженерам приходится вручную управлять сайд-эффектами внутри useEffect через AbortController или тащить тяжелые библиотеки вроде redux-saga / RxJS поверх архитектуры.
В Angular 20+ Сигналы и RxJS не конкурируют, а распределяют обязанности:
- Разделение труда: Сигналы отвечают за состояние (State) и мгновенное обновление UI. RxJS отвечает за события и время (Events/Time) — там, где поток данных асинхронен и подвержен задержкам.
- Бесшовные мосты (
rxjs-interop): ФункцияtoObservable(mySignal)позволяет мгновенно встроиться в мощный мир декларативных операторов RxJS. А функцияtoSignal(myObservable$)безболезненно возвращает обработанный поток в синхронный UI-слой, автоматически отписываясь от потока при уничтожении компонента (прощай, утечки памяти и бойлерплейт сngOnDestroy). - Краевые кейсы: Для задач вроде обработки Веб-сокетов, Drag-and-Drop или SwitchMap-фильтрации RxJS остается непревзойденной «серебряной пулей», аналогов которой в чистом React до сих пор нет из коробки.
Пример 6: Про interceptors
В Angular 20+ интерцепторы (мидлвары) пишутся в виде простых и легковесных функциональных выражений, а не тяжелых классов, как это было в старых версиях. Они перехватывают любые запросы, сделанные через HttpClient, и работают на уровне движка DI.
Разберем синтаксис на примере классического authInterceptor, который решает две задачи:
- Автоматически прикрепляет JWT-токен из
AuthService(из Примера 2) в заголовки Authorization. - Перехватывает ошибки 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+ демонстрируют превосходство экосистемы фреймворка:
- Функциональный DX: Интерцептор — это чистая функция
HttpInterceptorFn. Больше никаких громоздких классов и бойлерплейта из старых версий Angular. - Нативный DI в функциях: Функция
inject(AuthService)бесшовно работает внутри интерцептора, связывая сетевой слой с реактивным состоянием приложения без необходимости прокидывать контекст через UI-компоненты. - Цепочки обязанностей (Chain of Responsibility): В метод
withInterceptors([...])можно передать массив функций (например,loggingInterceptor,authInterceptor,errorInterceptor). Они отработают строго по очереди, формируя прозрачный конвейер обработки данных.
Пример 7: Конфигурация роутера с Lazy Loading, функциональным Guard и Resolver
Задача: Настроить маршрут для приватного дашборда /dashboard.
- Lazy Loading: Компонент дашборда должен загружаться только тогда, когда пользователь переходит по ссылке.
- Functional Guard: Роут должен пускать только авторизованных пользователей (проверка через
AuthService). - 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+ демонстрирует монолитность и законченность архитектуры фреймворка:
- Декларативный Lazy Loading: Функция
loadComponentразделяет бандл на этапе сборки автоматически. Никаких оберток вроде<Suspense>в шаблоне не требуется — фреймворк сам знает, когда скачать чанк. - Функциональные Guards: Защита роутов теперь весит несколько строк. Это чистые функции, которые могут использовать
inject()для проверки глобального состояния (например, Сигнала авторизации) прямо в момент перехода. - Прямой стриминг в Сигналы: Опция
withComponentInputBinding()связывает данные роута (параметры URL, query-параметры и данные Resolver-ов) напрямую с сигнальными инпутами компонента. UI-компонент становится изолированным, тестируемым и полностью избавляется от необходимости подписываться на сервисActivatedRouteвручную.
