Angular
Архитектура состояния: smart/dumb на сигналах
Компонент списка задач разросся до 600 строк. Он сам грузит данные из HTTP, хранит фильтр, считает счётчики, рисует разметку и обрабатывает клики. Любая правка в верстке грозит сломать загрузку, а покрыть это тестами без поднятия половины приложения невозможно. Знакомое разделение на smart и dumb компоненты разрезает этот узел: один компонент думает (инжектит сервисы и владеет состоянием), другой только показывает (принимает данные через signal inputs и отдаёт события). В мире сигналов это разделение перестало быть соглашением и превратилось в проверяемую границу.
- Любая фича-страница в крупном Angular-приложении: контейнер маршрута грузит данные, набор презентационных компонентов их показывает
- Design-системы компаний (Material, корпоративные UI-киты): кнопки, карточки, таблицы - чистые presentational-компоненты без знания о бизнес-логике
- Storybook-каталоги: туда попадают именно dumb-компоненты, потому что их можно отрендерить с любыми входными данными без бэкенда
- Командная разработка: один разработчик пилит контейнер и данные, другой - презентацию, граница inputs/outputs позволяет работать параллельно
- Переиспользование: одна и та же презентационная таблица показывает и заказы, и пользователей, потому что не знает, откуда данные
Предварительные знания
- Сервисы и dependency injection в Angular (inject, providedIn root)
- Сигналы на уровне идеи: signal, computed, реактивное чтение значения
- Signal inputs и outputs компонента (input(), output())
От presentational/container к границам на сигналах
Идею деления на presentational и container компоненты популяризировал Dan Abramov в 2015 году в статье про React, и она быстро прижилась в Angular. Долгие годы это было лишь дисциплиной: ничто не мешало dumb-компоненту тайком заинжектить сервис и стать smart. С приходом сигналов (Angular 16, 2023) и signal-based inputs/outputs (Angular 17.1) граница стала технической. Презентационный компонент, который объявляет только input() и output() и не вызывает inject(), физически не имеет доступа к источникам данных и обязан быть чистой функцией от своих входов.
Smart и dumb: кто думает, кто показывает
Архитектура делит компоненты на две роли. Smart-компонент (его ещё зовут контейнером) отвечает на вопрос откуда данные и что происходит: он инжектит сервисы, владеет состоянием, запускает загрузку, реагирует на события снизу. Dumb-компонент (презентационный) отвечает на вопрос как это выглядит: получает готовые данные на вход, рисует их и сообщает наверх о действиях пользователя через события. Презентационный компонент не знает ни о HTTP, ни о роутере, ни о сторе.
- Smart / контейнер — Инжектит сервисы через inject(). Владеет signal-состоянием. Запускает загрузку и сохранение. Передаёт данные вниз и обрабатывает события. Обычно привязан к маршруту.
- Dumb / презентационный — Только input() и output(). Никаких inject() и HTTP. Чистая функция от входов: одинаковые входы дают одинаковый вид. Переиспользуется и легко тестируется.
Этот компонент не знает, откуда взялись задачи и что произойдёт при переключении. Он принимает массив через input.required и кричит наверх через output, когда пользователь кликнул. Любой контейнер может его переиспользовать: список из HTTP, из локального состояния, из мока в тесте - презентации всё равно.
Признак, что компонент тайком стал smart: внутри презентационного появился inject() сервиса, прямой вызов HttpClient или обращение к роутеру. Как только это случилось, компонент перестал быть чистым, и его уже не отрендеришь в Storybook без поднятия зависимостей.
Что отличает презентационный (dumb) компонент от контейнерного (smart) на уровне зависимостей?
Контейнер на сигналах: состояние и computed
Контейнер инжектит сервис фичи и держит исходное состояние в writable-сигналах, а всё производное выводит через computed. Шаблон контейнера в основном состоит из презентационных компонентов, которым он раздаёт сигналы вниз и от которых принимает события. Сам контейнер почти не содержит разметки - его работа в оркестрации.
Здесь видна граница сигналов. Источник истины - сигнал tasks из сервиса и локальный сигнал filter. Производное состояние visibleTasks - это computed, который пересчитывается автоматически при изменении любой из зависимостей. Контейнер передаёт уже отфильтрованный список вниз. Презентационный компонент не знает про фильтр и не пересчитывает ничего сам.
Хорошее правило границы: writable-сигналы (signal) живут в контейнере или сервисе, презентационный компонент получает только readonly-представление через input. Так направление потока данных однозначно - сверху вниз через входы, снизу вверх через события.
Антипаттерн - мутировать входной массив прямо в презентационном компоненте. Это рвёт однонаправленный поток: данные приходят сверху, но меняются снизу, и контейнер о изменении не знает. Изменения отправляются только событием через output, а применяет их владелец состояния.
Где должен жить writable-сигнал состояния и как производное значение (например, отфильтрованный список) попадает в презентационный компонент?
Чистая презентация и тестируемость
Главная выгода границы видна в тестах. Чтобы проверить презентационный компонент, достаточно установить его signal inputs и посмотреть на разметку и испущенные события. Не нужны ни моки HttpClient, ни поднятие роутера, ни фейковый store. Компонент - это функция: вход задан, выход предсказуем.
Тест не упоминает TaskService - его здесь просто нет. setInput подаёт данные на signal input, компонент рисует чекбокс, симулированный клик испускает событие toggle с правильным id. Контейнер тестируется отдельно: там моком подменяется сервис, и проверяется оркестрация, а не разметка. Разделение ответственности дало разделение тестов.
| Что тестируем | Smart-контейнер | Dumb-презентационный |
|---|---|---|
| Зависимости | Мок сервиса/стора | Не нужны |
| Вход | Ответы сервиса | setInput на signal input |
| Проверяем | Оркестрацию, вызовы сервиса, computed | Разметку и испущенные события |
| Скорость и хрупкость | Медленнее, больше моков | Быстро, изолированно |
Та же чистота делает презентационные компоненты пригодными для Storybook и визуальных каталогов. В историю передают набор signal inputs, и компонент рендерится во всех состояниях без бэкенда - именно потому, что он не лезет за данными сам.
Почему чистый презентационный компонент тестировать проще, чем компонент, который сам инжектит сервис и грузит данные?
Когда реально нужен глобальный store
Соблазн вынести всё состояние в глобальный store силён, но он часто избыточен. Локального состояния в контейнере на сигналах хватает для большинства фич. Сервис фичи с сигналами решает следующий уровень - состояние, разделяемое между несколькими компонентами одной фичи. Глобальный store оправдан, когда состояние нужно нескольким несвязанным частям приложения и переживает навигацию между маршрутами.
- Состояние одного экрана и его потомков: локальные сигналы в контейнере, store не нужен
- Состояние одной фичи для нескольких её компонентов: сервис фичи с сигналами (providedIn в компоненте-контейнере)
- Состояние, разделяемое между несвязанными фичами и пережившее навигацию: вот здесь оправдан SignalStore или глобальный store
- Корзина, текущий пользователь, тема оформления, кеш справочников: классические кандидаты на общий store
Признак, что пора в store: одно и то же состояние начинают дублировать в нескольких контейнерах и синхронизировать через сервисы вручную, либо состояние должно пережить уход с маршрута и возврат на него. Пока этого нет, лишний слой store добавляет шаблонный код без выгоды.
Преждевременный глобальный store - частая архитектурная ошибка. Локальное по природе состояние (открыт ли аккордеон, текст в поле поиска формы) в глобальном сторе создаёт связанность между несвязанными экранами и усложняет рассуждение о том, кто и когда его меняет.
Эволюция обычно идёт снизу вверх: сигнал в контейнере, затем сервис фичи с сигналами, и только при доказанной потребности - SignalStore. Это держит большую часть состояния локальным и поднимает его в глобальную область лишь тогда, когда несколько частей приложения действительно делят одни данные.
В каком случае глобальный store оправдан сильнее всего по сравнению с локальными сигналами в контейнере?
Связь с другими темами
Урок задаёт архитектурный каркас модуля качества. Дальше:
- NgRx SignalStore — Когда состояние перерастает один сервис, его выносят в SignalStore с withState/withMethods
- Тестирование — Чистые dumb-компоненты проверяются установкой signal inputs без моков сервисов
- Строгая типизация — Типизированные signal inputs/outputs делают границу между компонентами проверяемой компилятором
Итог
- Smart (контейнерный) компонент инжектит сервисы, владеет signal-состоянием и оркеструет фичу
- Dumb (презентационный) компонент принимает данные через signal inputs, отдаёт события через outputs и не знает об источниках данных
- Граница inputs/outputs на сигналах превращает старое соглашение в техническую проверяемую границу
- Чистый презентационный компонент легко тестировать и переиспользовать, потому что он зависит только от своих входов
- Computed-сигналы в контейнере выводят производное состояние из исходного без ручной синхронизации
- Глобальный store нужен не всегда: локального состояния в контейнере или сервиса фичи часто достаточно, store оправдан при разделяемом между маршрутами состоянии
Связанные уроки
- ng-18-services — Smart-компоненты инжектят сервисы, поэтому нужна модель DI и сервисов
- ng-39-ngrx-signalstore — Когда сервиса с сигналами становится мало, его роль берёт SignalStore - следующий шаг этой архитектуры
- ng-40-testing — Чистые dumb-компоненты проверяются просто через signal inputs, и именно это разделение делает тесты лёгкими