Курс: Создание веб-приложений с использованием Angular и React

Урок №6

Angular: сервисы, модель Task и DI

Теория — 60 минут
Практика — 90 минут

Сквозной проект курса: TaskFlow

Сервис: единый источник данных через DITaskService (@Injectable)readonly tasks = signal([])add / toggle / removeданные + логика вместеinject()TaskListconst s = inject(TaskService)читает s.tasks()вызывает s.toggle(id)inject()StatsBarтоже inject()тот же сервисодни данные
Рисунок 1. Сервис — общий хозяин состояния; несколько компонентов получают его через DI (inject) и видят одни и те же данные.
Главная идея урока. Урок 5 оставил состояние внутри компонента. Урок 6 выносит его в сервис, чтобы несколько компонентов могли работать с одними и теми же данными. Соединение «компонент ↔ сервис» выполняет Dependency Injection — механизм, встроенный в Angular.

Студенческий материал. Его можно использовать как самостоятельный конспект, раздаточный материал и справочник после занятия.

Содержание

  1. 1. Зачем выносить данные из компонента в сервис
  2. 2. Обзор темы: что умеем и что добавим
  3. 3. Сервис: что это такое и как объявить
  4. 4. @Injectable и providedIn: 'root'
  5. 5. Dependency Injection: как Angular соединяет зависимости
  6. 6. inject(): получение сервиса в компоненте
  7. 7. Один сервис — общие данные
  8. 8. Модель Task как контракт данных
  9. 9. readonly id и неизменяемые поля модели
  10. 10. Перенос состояния из компонента в сервис
  11. 11. Сервис-хранилище: состояние + методы
  12. 12. Внешний API сервиса: read, write, computed
  13. 13. Локальное состояние UI и серверное состояние
  14. 14. Компонент-контейнер и компонент-представление
  15. 15. Модель TaskForm: данные до создания
  16. 16. Инкапсуляция: приватное состояние, публичные методы
  17. 17. DI в деталях: провайдер, инжектор, дерево
  18. 18. Провайдеры: useClass, useValue, useFactory
  19. 19. Область видимости провайдера: root, компонент
  20. 20. Полный TaskFlow на сервисе
  21. 21. Сравнение с React: Context и сервисы
  22. 22. Частые ошибки сервисов и DI
  23. 23. Практика 1: создаём TaskService
  24. 24. Практика 2: хранилище с методами
  25. 25. Практика 3: подключаем TaskList через DI
  26. 26. Практика 4: shared состояние между компонентами
  27. 27. Практика 5: TaskForm и модель TaskForm
  28. 28. Практика 6: сервис с computed и статистикой
  29. 28.1. Модель и сервис: разделение контракта и хранилища
  30. 28.2. Типизация методов сервиса
  31. 28.3. Вынос вычислений в сервис: фильтры
  32. 28.4. Инъекция в дочерний и вложенный компоненты
  33. 28.5. Сервис без providedIn: провайдер вручную
  34. 28.6. DI и жизненный цикл: кто создаёт и уничтожает
  35. 28.7. Тестирование сервиса
  36. 28.8. Сравнение с React Context/useContext
  37. 28.9. Отладка обработчика событий через сервис
  38. 28.10. Практика 7: TaskService с поиском и фильтрами
  39. 28.11. Архитектура: наш слой данных
  40. 28.12. Иерархическая DI
  41. 28.13. Провайдеры: useClass, useValue, useFactory
  42. 28.14. InjectionToken
  43. 28.15. Декораторы параметров: @Optional, @Host
  44. 28.16. Локальное и серверное состояние
  45. 28.17. Слой данных и TaskApiClient
  46. 28.18. Мемоизация производных
  47. 28.19. Тестируемость сервиса
  48. 28.20. Angular DI и React Context
  49. 28.21. Антипаттерны слоя данных
  50. 28.22. Полный листинг TaskService
  51. 28.23. Отладка DI
  52. 28.24. Пошаговая миграция в сервис
  53. 28.25. Мост к уроку 7: HTTP
  54. 29. Самостоятельная работа
  55. 30. Подсказки к самостоятельной работе
  56. 31. Частые ошибки DI и сервисов
  57. 32. Резюме: сервисы как слой данных
  58. 33. Контрольные вопросы
  59. 34. Мини-словарь
  60. 35. Источники и продолжение
  61. 36. Чек-лист урока
Урок 6 • Раздел 1

1. Зачем выносить данные из компонента в сервис

Компонент должен заботиться об отображении и взаимодействии, а не хранить и обрабатывать все данные приложения. Ответственность за данные берёт на себя сервис.

Аналогия. Представьте, что список задач — это общий календарь в офисе. Если каждый сотрудник заведёт свой личный календарь, они быстро разойдутся: кто-то запишет встречу, а кто-то — нет. Поэтому кофеварка должна быть одна на всех, и все наливают из неё. Сервис — это и есть та самая «общая кофеварка на этаже» для данных приложения (один экземпляр на всех).

В уроке 5 список задач жил внутри TaskList. Это работало, пока список нужен был одному компоненту. Но в TaskFlow список читают и фильтр, и статистика, и форма: они должны видеть одни и те же задачи. Хранить копию в каждом — значит рисковать рассинхронизацией.

Контекст. Ниже — два варианта одного и того же кода: как было в уроке 5 (состояние внутри компонента) и как станет в уроке 6 (состояние перенесено в сервис, а компонент лишь получает его через DI).

Аналогия из жизни. Состояние в компоненте — как липкий листок у тебя на столе: видишь только ты. Состояние в сервисе — как доска объявлений в холле: его видят все.

// урок 5: состояние внутри компонента
export class TaskList {
  readonly tasks = signal<Task[]>([]);
}

// урок 6: состояние в сервисе, компонент получает его через DI
@Injectable({ providedIn: 'root' })
export class TaskService {
  readonly tasks = signal<Task[]>([]);
}

export class TaskList {
  private readonly tasksService = inject(TaskService);
}

Разбор по шагам:

  1. В уроке 5 поле tasks объявлено прямо внутри класса TaskList — только этот компонент его и видит.
  2. В уроке 6 то же поле переехало в отдельный класс TaskService, помеченный @Injectable.
  3. Компонент TaskList больше не создаёт данные сам, а получает готовый сервис через inject(TaskService).

Что увидит пользователь. На экране пока ничего не меняется — список выглядит так же. Но теперь любой другой компонент (например, счётчик задач) сможет прочитать те же самые задачи, и они не разойдутся.

Переход от «состояние в компоненте» к «состояние в сервисе» — это шаг от одиночного экрана к приложению, где данные переиспользуются между частями.

Разделение ответственности. Компонент: что показать и как реагировать. Сервис: где хранить данные и как их менять. Такое разделение упрощает тестирование и рост приложения.
Урок 6 • Раздел 2

2. Обзор темы: что умеем и что добавим

В уроке 5 мы научились строить реактивное состояние. Урок 6 переносит его в сервис и подключает через DI.

Аналогия. Представьте, что до сих пор каждая комната (компонент) хранила свои запасы в шкафу. Теперь мы строим общую кофеварку (сервис) на этаже, и все комнаты берут оттуда. Таблица ниже — план этого переезда.

Что былоЧто станет в уроке 6
signal/computed в компонентетот же реактивный код в сервисе
Один компонент владеет даннымисервис — общий хозяин данных
Передача через входыполучение через inject()
Модель Task из урока 2официальный контракт сервиса
разделение «локального» и «серверного» состояния
Связность. Уроки 3–5 строили компоненты и их состояние. Урок 6 добавляет слой данных, общий для всех. В уроке 7 сервис получит данные из HTTP-API — та же структура, другой источник.
Урок 6 • Раздел 3

3. Сервис: что это такое и как объявить

Аналогия. Сервис — как общая кофеварка на этаже. Она одна на всех, стоит на кухне, и любой сотрудник может прийти и налить кофе. Никто не собирает свою кофеварку на рабочем столе. Так же и сервис: это одно место, где «варятся» данные и логика, и любой компонент берёт оттуда готовое.

Определение. Сервис — это обычный TypeScript-класс, который выполняет повторяемую задачу: хранение данных, бизнес-логика, обращение к API. Он не занимается отображением — этим занимается компонент.

Контекст. Так объявляется минимальный сервис задач: внутри — реактивный список, а снаружи Angular знает, что этот класс можно раздавать другим.

Аналогия из жизни. @Injectable — это как табличка на кофеварке «Общая. Пользуйтесь». А providedIn: 'root' — как указание «стоит на кухне всей фирмы, а не в кабинете начальника».

import { Injectable, signal } from '@angular/core';
import type { Task } from '../task'; // модель данных задачи

// @Injectable — помечает класс как пригодный для внедрения (DI)
// providedIn: 'root' — Angular создаёт ОДИН экземпляр на всё приложение
@Injectable({ providedIn: 'root' })
export class TaskService {
  // реактивное хранилище списка задач (сигнал)
  readonly tasks = signal<Task[]>([]);
}

Разбор по шагам:

  1. import { Injectable, signal } — берём из Angular нужные инструменты.
  2. @Injectable({ providedIn: 'root' }) — говорим Angular: «создай один такой объект на всё приложение и раздавай его».
  3. readonly tasks = signal<Task[]>([]) — внутри хранится пустой список задач, обёрнутый в сигнал (реактивную переменную).

Что увидит пользователь. Сам по себе этот код ничего на экране не рисует — он лишь готовит «кухню». Задачи появятся, когда компонент прочитает tasks() и выведет их.

Что делает @Injectable? Он сообщает Angular: этот класс можно внедрять как зависимость. Опция providedIn: 'root' означает, что сервис доступен из любого места приложения (точнее — из корневого инжектора).

Сервис создаётся командой:

Контекст. Angular умеет генерировать файл сервиса сам, чтобы не писать каркас вручную.

Аналогия из жизни. Это как заказать готовый шкаф у сборщика вместо того, чтобы пилить доски самим: каркас приедет уже с нужными креплениями.

ng generate service task
# создаёт task.service.ts

Что увидит пользователь. В папке появится файл task.service.ts с заготовкой класса. Пока на экране ничего не меняется — это просто новый файл в проекте.

Сгенерированный файл уже содержит @Injectable({ providedIn: 'root' }).

Сервис — не компонент. У него нет шаблона, селектора и стилей. Он — чистый класс для логики и данных. В этом смысле он похож на обычную функцию-утилиту, но интегрирован с DI Angular.
Урок 6 • Раздел 4

4. @Injectable и providedIn: 'root'

Аналогия. @Injectable — как пропуск в здание: без него охрана (Angular) не пустит класс «на работу» в систему внедрения зависимостей.

Определение. Декоратор @Injectable помечает класс как доступный для внедрения. Без него Angular не сможет подставить этот класс в конструктор или inject().

@Injectable({ providedIn: 'root' })
export class TaskService { }

Контекст. Опция providedIn отвечает на вопрос «где этот сервис доступен». От неё зависит, будет ли сервис один на всё приложение или по экземпляру на каждый экран.

Аналогия из жизни. 'root' — как городская библиотека: одна на весь город, ходят все. А провайдер внутри конкретного компонента — как домашняя полка: пользуется только эта семья.

providedInГде доступен
'root'всё приложение (один общий экземпляр)
имя компонентаконкретный компонент и его дочерние
(отсутствует)только там, где провайдер указан вручную

Для TaskFlow 'root' — типичный выбор: список задач один для всего приложения.

@Injectable({ providedIn: 'root' })
export class TaskService {
  // один экземпляр на всё приложение
}
Почему 'root' удобен. Вам не нужно нигде вручную регистрировать сервис. Angular сам создаёт один экземпляр и переиспользует его по всему дереву. Это избавляет от лишнего кода и ошибок подключения.
Урок 6 • Раздел 5

5. Dependency Injection: как Angular соединяет зависимости

Аналогия. Dependency Injection (внедрение зависимостей) — как передача готового инструмента туда, где он нужен. Представьте мастерскую: вместо того чтобы каждому работнику самому ковать молоток, кладовщик выдаёт уже готовый. Работник просто берёт молоток и работает.

Определение. Dependency Injection (DI) — механизм, при котором объект получает свои зависимости «извне», а не создаёт их сам. Angular автоматически доставляет нужный класс туда, где он объявлен.

Без DI компонент создавал бы сервис сам — это плодит много экземпляров и жёсткие связи:

// без DI — плохо
export class TaskList {
  private readonly tasksService = new TaskService(); // свой экземпляр
}

Аналогия из жизни. new TaskService() внутри компонента — как если бы каждый работник сам собрал себе кофеварку. У всех получатся разные кофеварки, и кофе в них будет разный. Общего состояния не будет.

С DI компонент просто просит сервис, а Angular решает, какой экземпляр дать:

// с DI — Angular сам поставит сервис
export class TaskList {
  private readonly tasksService = inject(TaskService);
}

Разбор по шагам:

  1. Компонент пишет inject(TaskService) — то есть «дай мне сервис задач».
  2. Angular смотрит в свой «шкаф» (инжектор) и находит один общий экземпляр TaskService.
  3. Компонент получает именно его — тот же самый, что и все остальные компоненты.

Что увидит пользователь. На экране — как и раньше, список задач. Но теперь за кулисами все компоненты работают с одним источником, поэтому, добавив задачу в одном месте, вы увидите её во всех.

DI связывает компоненты и сервисы «через контракт», а не напрямую по коду. Это упрощает замену реализации и тестирование.

💡 Что такое DI простыми словами. Представьте, что сервис — это кофеварка на кухне. Вы не собираете её заново каждый раз, когда хотите кофе: она создана один раз, и любой, кто хочет кофе, берёт готовую. Dependency Injection работает так же: сервис создаётся один раз, а Angular «приносит» его туда, где он нужен (в компонент). Компонент просто говорит: «дай мне TaskService», — и получает тот же общий экземпляр, что и все остальные. Не нужно писать new TaskService() вручную и следить за тем, чтобы это был один и тот же объект.
Три участника DI. (1) Зависимость — класс, который нужен (TaskService). (2) Потребитель — класс, который её запрашивает (TaskList). (3) Инжектор/провайдер — механизм Angular, который знает, как создать и отдать зависимость. Все три части вы увидите далее.
Урок 6 • Раздел 6

6. inject(): получение сервиса в компоненте

Аналогия. inject() — как кнопка вызова лифта: вы нажимаете, и система сама привозит нужное. Не нужно бежать за сервисом самим.

Определение. Функция inject() — современный способ получить зависимость. Её вызывают в поле или конструкторе компонента.

Контекст. Компонент запрашивает сервис и сразу «прокидывает» его сигнал наружу, чтобы шаблон мог читать список.

Аналогия из жизни. Это как взять общий блокнот из шкафа и положить его на свой стол, чтобы пользоваться им прямо тут.

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

@Component({ selector: 'app-task-list', standalone: true, ... })
export class TaskList {
  private readonly tasksService = inject(TaskService);

  readonly tasks = this.tasksService.tasks; // сигнал
  // или использовать напрямую
}

Разбор по шагам:

  1. import { Component, inject } — подключаем функцию внедрения.
  2. inject(TaskService) — получаем общий сервис.
  3. readonly tasks = this.tasksService.tasks — делаем его список доступным внутри компонента для шаблона.

Что увидит пользователь. Компонент теперь «вооружён» данными: в шаблоне можно перебирать tasks() и показывать задачи.

Далее читаем сигнал и вызываем методы:

export class TaskList {
  private readonly tasksService = inject(TaskService);

  onToggle(id: number) {
    this.tasksService.toggle(id);
  }
}

Аналогия из жизни. Метод onToggle — как нажатие галочки в общем чате: вы сообщаете сервису «задача №id сделана», а он меняет состояние у всех.

@for (task of tasksService.tasks(); track task.id) { ... }

Что увидит пользователь. На экране появится перечень задач из общего списка. Каждая отрисуется один раз, а track task.id поможет Angular быстро обновлять только изменившиеся строки.

Старый способ — конструктор. Раньше зависимости передавали параметром конструктора:
constructor(private readonly tasksService: TaskService) {}
// то же, но длиннее и требует type metadata

Что увидит пользователь. Результат тот же, что и у inject() — сервис доступен в компоненте. Просто запись длиннее.

inject() проще и работает в полях, поэтому наш курс использует его. Оба подхода функционируют; выбор стиля — личный или командный.

Урок 6 • Раздел 7

7. Один сервис — общие данные

Аналогия. Один сервис с 'root' — как единое табло на вокзале. Все пассажиры смотрят на одно и то же табло, и когда рейс меняется, видят это все сразу.

Определение. Главная выгода сервиса: несколько компонентов получают один и тот же экземпляр и, следовательно, одни и те же данные.

Контекст. Два разных компонента — список и счётчик статистики — оба внедряют один сервис и читают из него.

Аналогия из жизни. Это как два сотрудника, читающих один и тот же общий чат: кто-то написал «задача готова» — и оба сразу это видят.

// TaskList
export class TaskList {
  private readonly s = inject(TaskService);
  readonly tasks = this.s.tasks;          // сигнал списка
}

// StatsBar тоже внедряет сервис
export class StatsBar {
  private readonly s = inject(TaskService);
  readonly doneCount = this.s.doneCount;  // тот же источник
}

Разбор по шагам:

  1. Оба компонента вызывают inject(TaskService) — и получают один и тот же объект.
  2. TaskList берёт сигнал списка, а StatsBar — сигнал счётчика сделанных.
  3. Поскольку оба сигнала живут в одном сервисе, изменение в одном месте автоматически отражается в другом.

Что увидит пользователь. Добавили задачу в списке — и счётчик в StatsBar тут же вырос. Никакой ручной синхронизации писать не нужно.

Когда TaskList добавляет задачу через s.task(), StatsBar видит изменение автоматически, потому что оба читают один и тот же сигнал.

Общий сервис = общие данныеTaskServicetasks: signalTaskListinject(TaskService)StatsBarinject(TaskService)
Рисунок 2. Два компонента внедряют один сервис и видят один сигнал.
Экземпляр один. При providedIn: 'root' Angular создаёт единственный экземпляр сервиса на всё приложение. Поэтому в нём хранят «общее» состояние. Именно это превращает локальный список из урока 5 в глобальный источник задач.
Урок 6 • Раздел 8

8. Модель Task как контракт данных

Аналогия. Модель (интерфейс) — как анкета для пропуска: в ней заранее нарисованы графы «имя», «номер», «статус». Кто бы ни заполнял, структура одина и та же, и на входе билетера не бывает сюрпризов.

Определение. Модель описывает форму данных, которой придерживается сервис. Это «контракт» между сервисом, компонентами и (в будущем) API.

Контекст. Файл task.ts задаёт, из каких полей состоит задача. Сервис и компоненты будут строить задачи строго по этой форме.

Аналогия из жизни. Поле completed — как галочка «выполнено» в чек-листе: либо стоит, либо нет, третьего не дано.

// task.ts
export type Priority = 'high' | 'medium' | 'low';

export interface Task {
  id: number;
  title: string;
  completed: boolean;
  priority: Priority;
}

Разбор по шагам:

  1. Priority — перечисление допустимых важностей задачи (только три варианта).
  2. Task — «чертеж» задачи: у неё есть номер id, заголовок, статус completed и важность.
  3. Любой объект с такими полями считается задачей, и TypeScript проверит это при сборке.

Что увидит пользователь. Сама по себе модель невидима — она работает «за сценой», не давая программисту случайно создать задачу без заголовка или с неправильной важностью.

Сервис опирается на модель при объявлении сигнала и методов:

@Injectable({ providedIn: 'root' }) // сервис доступен во всём приложении
export class TaskService {
  // приватное изменяемое состояние скрыто за сигналом
  readonly tasks = signal<Task[]>([]);

  add(title: string): void {
    const id = this.nextId();               // сервис сам назначает id
    const task: Task = { id, title, completed: false, priority: 'low' };
    this.tasks.update(cur => [task, ...cur]); // неизменяемое обновление
  }

  // приватный помощник: следующий уникальный id
  private nextId(): number {
    return Math.max(0, ...this.tasks().map(t => t.id)) + 1;
  }
}

Аналогия из жизни. Метод add — как кассир: он сам пробивает новый номер задачи и выдаёт карточку с правильно заполненными графами. Вы не пишете номер ручкой — сервис делает это за вас.

Разбор по шагам:

  1. Сервис берёт заголовок и сам назначает id через nextId().
  2. Собирает объект Task со статусом completed: false (новая задача всегда не сделана).
  3. Кладёт его в начало списка через update — не меняя старый массив, а создавая новый (неизменяемость).

Что увидит пользователь. Когда форма вызовет add(...), в списке появится новая задача с правильным номером и статусом «не выполнено».

Модель из урока 2. В уроке 2 (разделы 40–42) мы определили модели Task и TaskForm как «типовой контракт». Урок 6 делает их официальными: сервис и компоненты используют одни и те же интерфейсы, поэтому ошибки типов ловятся компилятором.
✍️ Действие. Создайте файл src/app/models/task.model.ts с интерфейсами Task, TaskDraft и типом Priority из этого раздела.
Урок 6 • Раздел 9

9. readonly id и неизменяемые поля модели

Аналогия. readonly id — как номер паспорта. Его нельзя переписать ручкой: выдали один раз, и он такой навсегда. Если разрешить менять номер, всё перепутается.

Определение. Поле id — идентификатор, который не должен меняться после создания. Пометив его readonly, мы защищаем от случайной перезаписи.

export interface Task {
  readonly id: number;
  title: string;
  completed: boolean;
  priority: Priority;
}

Аналогия из жизни. Это как защитить в анкете графу «номер» от редактирования — оставить её серой, чтобы никто случайно не вписал другое число.

// TS ошибка при попытке поменять id
task.id = 10; // ❌ Cannot assign to 'id' because it is a read-only property

Что увидит пользователь. Прямо на экране — ничего. Но в редакторе кода вы сразу увидите красную волнистую линию и подсказку об ошибке, если попытаетесь переписать id.

Неизменяемость при обновлении сохраняется: меняем поля через spread, создавая новый объект.

toggle(id: number) {
  this.tasks.update(cur =>
    cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
  );
}

Аналогия из жизни. Операция { ...t, completed: !t.completed } — как переписывание строки в чек-листе на чистый листок с одной поправкой, а старый листок оставляем нетронутым. Никто не правит «поверх», поэтому история понятна.

Разбор по шагам:

  1. update берёт текущий список cur.
  2. map проходит по каждой задаче; у нужной (совпал id) меняет только completed на противоположное.
  3. Остальные задачи возвращаются без изменений, но весь массив — новый.

Что увидит пользователь. Галочка у задачи переключится: была пустая — стала заполненной (и наоборот).

readonly — по контракту TypeScript, не рантайм. TypeScript проверяет readonly только на этапе компиляции. В рантайме поле технически перезаписываемо. Но контракт дисциплинирует разработчика и ловит большинство ошибок заранее (урок 5, раздел 28.10).
Урок 6 • Раздел 10

10. Перенос состояния из компонента в сервис

Покажем пошаговый перенос списка задач из компонента (урок 5) в сервис.

Было: состояние в компоненте

Контекст. Так выглядел TaskList в уроке 5: список и видимый список жили внутри компонента.

Аналогия из жизни. Как если бы только один сотрудник держал список дел у себя в телефоне и не показывал другим.

// task-list.ts (урок 5)
export class TaskList {
  readonly tasks = signal<Task[]>([]);
  readonly visibleTasks = computed(() => ...);

  onToggled(id: number) { ... }
}

Стало: состояние в сервисе

Контекст. Теперь список и операции переехали в сервис, а компонент только «подключается» к нему.

Аналогия из жизни. Список дел вывесили на общее табло — и любой сотрудник (компонент) может его читать.

// task.service.ts
@Injectable({ providedIn: 'root' })
export class TaskService {
  readonly tasks = signal<Task[]>([]);

  toggle(id: number) { ... }
}
// task-list.ts
export class TaskList {
  private readonly s = inject(TaskService);
  readonly tasks = this.s.tasks;
  readonly visibleTasks = computed(() => ...);
}

Разбор по шагам:

  1. Создаём сервис и переносим в него поле tasks и метод toggle.
  2. В компоненте оставляем только inject(TaskService) и «прокидываем» нужное наружу.
  3. Видимый список visibleTasks пока остаётся в компоненте, так как он зависит от экрана.

Что увидит пользователь. Внешне ничего не меняется — задачи отображаются как раньше. Но теперь тот же список доступен и другим компонентам.

Шаблон TaskList почти не меняется, но данные теперь живут в сервисе и доступны другим компонентам.

Что переносим, что оставляем. В сервис — данные и операции над ними (add/toggle/remove). В компоненте остаются вычисления, специфичные для экрана (видимый список) и обработка событий. Точную границу выбирают по «кто ещё нуждается в этих данных/логике».
Урок 6 • Раздел 11

11. Сервис-хранилище: состояние + методы

Аналогия. Сервис-хранилище — как шкаф с инвентарём и инструкцией: вещи лежат внутри, а брать их можно только через ручки (методы). Нельзя залезть внутрь руками и переложить что попало.

Определение. Хороший сервис-хранилище объединяет состояние и операции над ним в одном месте. Компоненты вызывают методы, а не лезут «внутрь» сигнала напрямую.

Контекст. Полноценный сервис с добавлением, переключением и удалением задач.

Аналогия из жизни. Каждый метод — как кнопка на кофеварке: «налить», «выключить», «слить». Вы нажимаете кнопку, а внутренняя механика спрятана.

@Injectable({ providedIn: 'root' }) // создаётся один раз на всё приложение
export class TaskService {
  readonly tasks = signal<Task[]>([]); // состояние хранилища

  // добавить задачу: валидируем ввод и дописываем служебные поля
  add(title: string): void {
    if (!title.trim()) return;               // guard: пустая строка — выход
    const id = this.nextId();
    this.tasks.update(cur => [             // неизменяемое обновление
      { id, title: title.trim(), completed: false, priority: 'low' },
      ...cur,
    ]);
  }

  // переключить статус completed у задачи по id
  toggle(id: number): void {
    this.tasks.update(cur =>
      cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
    );
  }

  // удалить задачу по id
  remove(id: number): void {
    this.tasks.update(cur => cur.filter(t => t.id !== id));
  }

  // приватный генератор уникального id
  private nextId(): number {
    return Math.max(0, ...this.tasks().map(t => t.id)) + 1;
  }
}

Разбор по шагам:

  1. add: сначала проверяет, что заголовок не пустой (guard), затем назначает id и кладёт новую задачу в начало списка.
  2. toggle: находит задачу по id и переворачивает её статус completed.
  3. remove: оставляет в списке все задачи, кроме той, чей id совпал (через filter).
  4. nextId приватный — внешние компоненты его не вызывают, он нужен только самому сервису.

Что увидит пользователь. В приложении появляются кнопки «добавить», «готово», «удалить», и список живо реагирует на каждую. Пустая строка просто игнорируется — задача не создаётся.

Компоненты не обновляют tasks через update() сами — они зовут s.add(), s.toggle(). Это централизует логику и делает её переиспользуемой.

Польза. Если правило изменится (например, добавить проверку длины названия), вы меняете один метод сервиса, и все компоненты получат новое поведение. Компоненты остаются тонкими, логика — в одном месте.
✍️ Действие. Создайте файл src/app/services/tasks.service.ts с классом TaskService и методами add / toggle / remove из этого раздела.
▶️ Быстрый запуск. Ниже — аналог сервиса-хранилища на чистом JS/HTML (Angular-версия запускается через ng serve). Объект создаётся один раз и рассылает изменения всем подписчикам, как это делает DI-сервис.

Контекст песочницы. Это упрощённая копия сервиса на чистом JavaScript, чтобы вы могли нажать «Запустить» и пощёлкать кнопки без установки Angular.

Аналогия из жизни. Объект store — как дежурный у доски объявлений: вы просите его повесить/снять листок, а он сам обновляет доску и кричит всем «посмотрите, изменилось!».

Что увидит пользователь. Список задач под кнопками. Впишите текст и нажмите «Добавить» — появится новая строка с ⬜. Нажмите «Toggle #1» — первая задача сменит ⬜ на ✅.

Песочница: нажми «Запустить» — и увидишь живой список задач с кнопками «Добавить» и «Toggle #1». Меняй код и запускай снова.
Песочница: сервис-хранилище (аналог на JS)
Урок 6 • Раздел 12

12. Внешний API сервиса: read, write, computed

Аналогия. Внешний API сервиса — как витрина магазина: снаружи вы видите только «взять товар» и «положить деньги», а склад за стеклом спрятан. Вы не лезете на склад сами.

Определение. Важно продумать, что сервис «отдаёт наружу». Это его публичный контракт. Хороший сервис разделяет доступ на чтение, запись и производные значения.

Контекст. Показываем, как спрятать изменяемый сигнал и выставить наружу только безопасное чтение и методы.

Аналогия из жизни. asReadonly() — как стеклянная витрина: рассмотреть можно, а руками тронуть и переставить — нельзя.

@Injectable({ providedIn: 'root' })
export class TaskService {
  // приватное изменяемое состояние — скрыто
  private readonly tasks = signal<Task[]>([]);

  // публичное чтение — только readonly
  readonly tasksReadonly = this.tasks.asReadonly();

  // публичные производные
  readonly pendingCount = computed(() =>
    this.tasks().filter(t => !t.completed).length
  );

  // публичные операции записи
  add(title: string): void { ... }
  toggle(id: number): void { ... }
  remove(id: number): void { ... }
}

Разбор по шагам:

  1. private readonly tasks — внутреннее хранилище, снаружи его нет.
  2. tasksReadonly — копия «только для чтения», которую компоненты могут смотреть.
  3. pendingCount — производное значение (сколько задач ещё не сделано), считается автоматически.
  4. Методы add/toggle/remove — единственный способ что-то изменить.

Что увидит пользователь. Приложение работает как раньше, но теперь архитектурно никто не может случайно «испортить» список напрямую.

Компоненты читают tasksReadonly(), считают через pendingCount() и изменяют только через методы. Это инкапсуляция: внутреннее состояние не «светится» наружу для произвольной записи.

Не отдавайте сам изменяемый сигнал. Если компонент получит tasks и вызовет set(), это обойдёт логику сервиса. Используйте asReadonly() для чтения и методы для записи.
Урок 6 • Раздел 13

13. Локальное состояние UI и серверное состояние

Аналогия. Локальное состояние — как липкий листок на мониторе: нужен только вам прямо сейчас, оторвали — и забыли. Серверное состояние — как доска объявлений в холле: её видят все, и она «главнее», потому что оттуда берут данные многие.

Определение. Локальное состояние — данные одного экрана (открыто ли меню, что в поле ввода). Серверное состояние — данные, которые пришли (или придут) из API и нужны многим компонентам.

Не всё состояние принадлежит сервису. Различают два типа (урок 1, раздел 32).

ТипПримерыГде живёт
Локальный UI-статусоткрыта ли панель, активная вкладка, фокускомпонент
Серверное состояниесписок задач из APIсервис (позже — с HTTP)

Контекст. Пример: открытое меню фильтра — это чисто экранная мелочь, а список задач — общие данные.

Аналогия из жизни. Меню фильтра — как липкий листок у тебя на столе (видно только вам). Список задач — как доска объявлений в холле (видят все).

// локальный UI-статус — остаётся в компоненте
export class FilterBar {
  isDropdownOpen = false; // никому кроме себя не нужно
}

// данные задач — сервис
export class TaskService {
  readonly tasks = signal<Task[]>([]);
}

Разбор по шагам:

  1. isDropdownOpen объявлено прямо в компоненте FilterBar — оно нужно только ему для показа/скрытия меню.
  2. tasks лежит в сервисе — потому что задачи нужны списку, статистике и форме одновременно.

Что увидит пользователь. Меню фильтра открывается/закрывается только на этом экране. А задачи видны везде, где их вывели компоненты.

В уроке 7 список задач придёт из API — это серверное состояние. Тогда сервис будет загружать, кэшировать и обновлять его. Сейчас он хранит данные локально как «муляж» будущего серверного состояния.

💡 Локальное vs серверное состояние. Локальное состояние — это то, что нужно только одному экрану прямо сейчас: открыто ли меню, что написано в поле ввода, какая вкладка активна. Оно живёт и умирает вместе с компонентом. Серверное состояние — это данные, которые пришли (или придут) извне, из API: список задач, профиль пользователя. Ими часто делятся несколько компонентов, поэтому оно оседает в сервисе. Простое правило: «видит ли эти данные кто-то ещё или они из сети?» — да, значит в сервис; нет — оставьте в компоненте.
Правило. Данные, которыми делятся несколько компонентов или которые приходят извне, — в сервис. Чисто визуальный статус одного компонента — в компоненте. Не «тащите» всё в сервис без причины.
Урок 6 • Раздел 14

14. Компонент-контейнер и компонент-представление

Аналогия. Контейнер — как официант: он ходит на кухню (сервис), берет заказ и относит вам. Представление — как тарелка: на ней просто лежит еда, она не знает, как её готовили.

Определение. Компонент-контейнер работает с данными и сервисом. Компонент-представление только показывает то, что ему передали, и не знает о сервисе.

Контекст. Контейнер берёт данные из сервиса и передаёт их вниз; представление получает их через входы.

Аналогия из жизни. Вход [tasks] — как поднос, на который официант поставил блюдо. Тарелке (представлению) всё равно, откуда еда.

// КОНТЕЙНЕР: работает с сервисом, передаёт данные вниз
export class TaskPage {
  private readonly s = inject(TaskService);
  readonly tasks = this.s.tasks;
}

<app-task-list [tasks]="tasks()" (toggled)="s.toggle($event)" />
// ПРЕДСТАВЛЕНИЕ: получает данные и события, без DI к сервису
export class TaskList {
  readonly tasks = input.required<Task[]>();
  readonly toggled = output<number>();
}

Разбор по шагам:

  1. Контейнер TaskPage внедряет сервис и получает список.
  2. Через шаблон он передаёт список в app-task-list как вход [tasks].
  3. Представление TaskList просто объявляет вход и выход, не зная про сервис вообще.

Что увидит пользователь. На экране — тот же список задач. Разница видна программисту: представление можно переиспользовать с любыми данными.

Контейнер «знает» сервис и бизнес-логику. Представление — «чистый»: только входы и выходы (урок 4). Такое разделение облегчает тестирование и переиспользование.

Слабая связанность. Представление ничего не знает о сервисе — его легко показать с любыми данными и переиспользовать. Контейнер связывает представления с источником данных. Для TaskFlow вы можете упростить: один компонент и в роли контейнера, и в роли представления; но знание паттерна полезно.
Урок 6 • Раздел 15

15. Модель TaskForm: данные до создания

Аналогия. TaskDraft — как черновик анкеты без номера: вы заполнили графы «название» и «важность», но номера и штампа «принято» ещё нет. Их добавит секретарь (сервис) при получении.

Определение. Модель TaskDraft описывает только те поля, что ввёл пользователь, до того как задача получила id и статус.

Создавая задачу, форма не знает id и дату создания — они появятся при добавлении в сервис. Для этого отделяют модель TaskForm (до создания) от Task (после создания).

Контекст. Полная задача и её черновик — два разных типа. Черновик берёт только нужные поля от задачи.

Аналогия из жизни. Pick — как вырезание из анкеты только нужных граф: «возьми из Task только заголовок и важность».

// task.ts — что существует как запись
export interface Task {
  readonly id: number;
  title: string;
  completed: boolean;
  priority: Priority;
}

// task-form.ts — что нужно, чтобы создать запись
export type TaskDraft = Pick<Task, 'title' | 'priority'>;

Разбор по шагам:

  1. Task — полная запись, включая id и completed.
  2. TaskDraft через Pick берёт из неё только title и priority — то, что реально вводит пользователь.

Что увидит пользователь. В форме — только поля «название» и «важность». Номер задачи пользователь не заполняет, он появится сам.

Сервис принимает черновик и дополняет его id/статусами:

add(draft: TaskDraft): void {
  const task: Task = {
    id: this.nextId(),
    ...draft,
    completed: false, // форма не выбирает статус при создании
  };
  this.tasks.update(cur => [task, ...cur]);
}

Аналогия из жизни. Сервис — как регистратор: берёт ваш черновик, штампует номер и пишет «не выполнено», превращая записку в официальную задачу.

Что увидит пользователь. После отправки формы задача появляется в списке уже с номером и статусом «не готова».

Разделение Task и TaskDraft — это урок 2 (разделы 40–42). Модель черновика не обязана знать id и completed до отправки в сервис.

💡 Зачем отдельный TaskDraft. Поля формы — это ещё не полноценная задача. Пока пользователь печатает, у записи нет id (его выдаст сервис/сервер) и не выбран финальный статус. Если бы форма работала с типом Task напрямую, пришлось бы врать: подставлять фиктивный id и статус completed. TaskDraft честно говорит: «здесь только то, что ввёл пользователь», а сервис превращает черновик в настоящую задачу.
Pick из TypeScript. Pick<Task, 'title' | 'priority'> берёт из Task только указанные поля. Удобно, когда полей немного; при сложной форме создают отдельный интерфейс (урок 8 — формы).
▶️ Быстрый запуск. Ниже — аналог на чистом JS/HTML (Angular-версия — через ng serve). Показывает разницу: данные с сервера уже содержат id и completed, а черновик формы — только title и priority, которые сервис потом дополняет.

Контекст песочницы. Показывает на живом примере, чем отличаются полная запись (Task) и черновик (TaskDraft).

Аналогия из жизни. В поле вывода вы увидите «до» и «после»: сырую записку и готовую задачу с номером.

Что увидит пользователь. Введите название, выберите важность, нажмите «Создать задачу» — в блоке появится JSON полной задачи (с id и completed:false) и JSON черновика (без них).

Песочница: нажми «Запустить» — и увидишь две карточки: готовый билет Task (с id и completed) и черновик TaskDraft (без номера). Меняй код и запускай снова.
Песочница: Task vs TaskDraft (аналог на JS)
Урок 6 • Раздел 16

16. Инкапсуляция: приватное состояние, публичные методы

Аналогия. Инкапсуляция — как термос: внутри горячий чай, снаружи — только крышка с кнопкой. Вы пьёте через кнопку, но не можете засунуть руку внутрь и перемешать чай.

Определение. Инкапсуляция означает: скрыть внутренности, показать только нужный интерфейс. В сервисе это реализуется так.

Контекст. Внутренний сигнал прячем за private, наружу даём только чтение и методы.

@Injectable({ providedIn: 'root' })
export class TaskService {
  // скрытое изменяемое состояние
  private readonly tasks = signal<Task[]>([]);

  // открытое чтение
  readonly tasksRO = this.tasks.asReadonly();

  // открытые операции
  add(draft: TaskDraft): void { ... }
  toggle(id: number): void { ... }
  remove(id: number): void { ... }
}

Аналогия из жизни. private — как замок на складе: сами туда не зайдёте, только через кассира (методы).

Разбор по шагам:

  1. private readonly tasks — недоступно снаружи класса.
  2. tasksRO — безопасная «витрина» для чтения.
  3. Методы — единственные «двери», через которые данные меняются.

Что увидит пользователь. Приложение работает как прежде, но программист защищён от ошибок: случайно испортить список из компонента нельзя.

Компонент не может случайно сделать s.tasks().push(...) или s.tasks.set(...) — ему доступны только tasksRO и методы.

Зачем это. Инкапсуляция защищает инварианты: например, что id уникален, а completed при создании всегда false. Если позволить менять данные из любого места, гарантии ломаются. Методы — единственные «двери» для изменения.
Урок 6 • Раздел 17

17. DI в деталях: провайдер, инжектор, дерево

Аналогия. Провайдер — как рецепт блюда. Инжектор — как шкаф с готовыми порциями. Дерево инжекторов — как этажи здания: на каждом этаже свой шкаф, а если на своём нет нужного, идёте на этаж выше.

Определение. Провайдер — запись «как создать экземпляр». Инжектор — контейнер, хранящий экземпляры. Дерево инжекторов — иерархия от корня приложения до вложенных компонентов.

Разберём три понятия DI: провайдер, инжектор и дерево зависимостей.

Когда компонент вызывает inject(TaskService), Angular ищет провайдер от текущего уровня вверх по дереву, до корня.

Контекст. Схема поиска сервиса в дереве компонентов.

Аналогия из жизни. Спускаетесь по этажам вниз — на каждом спрашиваете шкаф; дошли до крыши (корня) — там точно есть общий.

App(корневой инжектор: TaskService)
└── TaskPage
    └── TaskList   // inject(TaskService) → нашёл в корне

Что увидит пользователь. На экране — ничего нового; это внутренняя логика Angular. Но понимание помогает, когда сервис вдруг «не находится».

Интуиция. Думайте об инжекторе как о «шкафе зависимостей». Компонент просит «дай мне TaskService», и Angular смотрит в ближайший шкаф; если там нет — поднимается выше. Находит в корне — отдаёт общий экземпляр.
Урок 6 • Раздел 18

18. Провайдеры: useClass, useValue, useFactory

Аналогия. Провайдеры — как разные способы выдать инструмент: useClass — дать другой такой же ключ (заменитель), useValue — выдать конкретную вещь (например, адрес), useFactory — собрать инструмент на заказ по чертежу.

Определение. Провайдер описывает, чем заменить класс при внедрении. Три основных вида:

Контекст. Как подменить реализацию или передать значение без создания класса.

// useClass: заменить класс (например, другой реализацией)
providers: [{ provide: TaskService, useClass: MockTaskService }]

// useValue: подставить готовый объект (например, конфиг)
providers: [{ provide: API_URL, useValue: 'http://localhost:3000' }]

// useFactory: создать значение функцией
providers: [{ provide: Logger, useFactory: () => new Logger(level) }]

Аналогия из жизни. useClass — как подставить запасную кофеварку, пока основная на ремонте. useValue — как повесить табличку с адресом склада. useFactory — как собрать кофеварку прямо в момент выдачи.

Разбор по шагам:

  1. useClass подменяет один класс другим с тем же интерфейсом (удобно для тестов).
  2. useValue отдаёт уже готовое значение (строку, число, объект).
  3. useFactory вызывает функцию, которая сама создаёт нужное.

Что увидит пользователь. Само по себе — ничего; это настройка «под капотом». Но в тестах вы, например, увидите фейковые данные вместо реальных.

Провайдеры можно указывать в массиве providers компонента или на уровне роутера/приложения.

@Component({
  selector: 'app-task-page',
  standalone: true,
  providers: [
    { provide: TaskService, useClass: MockTaskService },
  ],
})

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

Что увидит пользователь. Для TaskPage и его детей сервис теперь будет «фейковым» (MockTaskService), а не настоящим — полезно при отладке и тестах.

Зачем. Замена сервиса без изменения компонентов — главная сила DI. В тестах подставляют фейковый сервис, не трогая реальный код. Для TaskFlow по умолчанию достаточно providedIn: 'root', но знание провайдеров пригодится в уроке 13 и тестах (урок 12).
Урок 6 • Раздел 19

19. Область видимости провайдера: root, компонент

Аналогия. 'root' — как городская площадь, где один фонтан на всех. Провайдер в компоненте — как личный кувшин у каждого дома: у соседа свой, и вода может быть другой.

Определение. Где объявлен провайдер — определяет, один ли экземпляр сервиса на всё приложение или по одному на компонент.

Контекст. Два способа объявить область видимости сервиса.

// один экземпляр на всё приложение
@Injectable({ providedIn: 'root' })
export class TaskService { }

// НОВЫЙ экземпляр на каждый компонент TaskPage
@Component({
  selector: 'app-task-page',
  standalone: true,
  providers: [TaskService], // локальный провайдер
})

Аналогия из жизни. В первом случае все смотрят в одно табло. Во втором — у каждого TaskPage свой личный список, независимый от остальных.

Разбор по шагам:

  1. С providedIn: 'root' Angular создаёт сервис один раз и раздаёт всем.
  2. С providers: [TaskService] в компоненте Angular создаёт новый экземпляр для каждого такого компонента.

Что увидит пользователь. При 'root' задача, добавленная в одном месте, видна везде. При локальном провайдере — только внутри своего экрана.

В первом случае все компоненты делят одно состояние. Во втором — каждый TaskPage получает собственный экземпляр и собственное состояние.

Где провайдерЭкземпляровСостояние
providedIn: 'root'1 на приложениеобщее для всех
в компонентепо 1 на компонентсвоё на экран
Важно. Если нужны действительно общие данные — используйте 'root'. Если у каждого независимого экрана должен быть свой набор (например, изолированный фильтр) — локальный провайдер. Не смешивайте без понимания.
Урок 6 • Раздел 20

20. Полный TaskFlow на сервисе

Контекст. Соберём весь слой данных TaskFlow: список, фильтр, видимые задачи и статистику — всё в одном сервисе.

Аналогия из жизни. Это как диспетчерская: один пульт, на котором видны все поезда (задачи), выбранный фильтр расписания и общая статистика.

// task.service.ts
@Injectable({ providedIn: 'root' }) // один экземпляр на всё приложение
export class TaskService {
  private readonly tasks = signal<Task[]>([]);          // приватное состояние
  private readonly filter = signal<TaskFilter>('all');  // текущий фильтр

  // наружу — только чтение (readonly), запись только через методы
  readonly tasksRO = this.tasks.asReadonly();
  readonly filterRO = this.filter.asReadonly();

  // производный список с учётом фильтра
  readonly visibleTasks = computed(() => {
    const f = this.filter();
    if (f === 'active') return this.tasks().filter(t => !t.completed); // не сделанные
    if (f === 'done') return this.tasks().filter(t => t.completed);   // сделанные (фильтр 'done')
    return this.tasks();
  });

  // производная статистика (мемоизируется)
  readonly counts = computed(() => ({
    total: this.tasks().length,
    active: this.tasks().filter(t => !t.completed).length,
    done: this.tasks().filter(t => t.completed).length,
  }));

  // публичные методы записи
  add(title: string): void { ... }
  toggle(id: number): void { ... }
  remove(id: number): void { ... }
  setFilter(f: TaskFilter): void { this.filter.set(f); }
}

Аналогия из жизни. visibleTasks — как отфильтрованное расписание: выбрали «только неготовые» — и видите только их. done здесь — значение фильтра, а не поле задачи.

Разбор по шагам:

  1. Два приватных сигнала: tasks (все задачи) и filter (текущий фильтр).
  2. visibleTasks считает, какие задачи показать, в зависимости от фильтра.
  3. counts считает статистику (всего / активных / сделанных).
  4. Методы setFilter и др. меняют состояние только через «двери» сервиса.

Что увидит пользователь. В приложении — список задач, кнопки фильтра и счётчики, которые живо обновляются при любом действии.

// task-list.ts — контейнер
export class TaskList {
  readonly s = inject(TaskService);
}

Контекст. Контейнер подключает сервис и выступает посредником между данными и дочерними компонентами.

Что увидит пользователь. Этот класс сам по себе не рисует интерфейс, но даёт шаблону доступ ко всем данным сервиса.

<!-- task-list.html -->
<app-filter-bar [filter]="s.filterRO()" (filterChange)="s.setFilter($event)" />
@for (task of s.visibleTasks(); track task.id) {
  <app-task-item [task]="task"
    (toggled)="s.toggle(task.id)"
    (deleted)="s.remove(task.id)" />
}
<app-stats-bar [total]="s.counts().total" [done]="s.counts().done" />

Аналогия из жизни. Шаблон — как витрина диспетчерской: на ней развешаны фильтр, список и счётчики, все берут данные из одного пульта.

Разбор по шагам:

  1. Панель фильтра получает текущее значение и сообщает об изменении через setFilter.
  2. Список перебирает visibleTasks() — уже отфильтрованные задачи.
  3. Каждая задача по кнопкам вызывает toggle или remove сервиса.
  4. Счётчик статистики берёт готовые числа из counts().

Что увидит пользователь. Полноценный экран TaskFlow: фильтр, список с кнопками «готово»/«удалить» и панель статистики.

Теперь весь слой данных — один сервис, а компоненты — тонкие обёртки вокруг него.

Основной шаг. Список, фильтр и производные переехали из компонента в сервис. Любой компонент может их читать и вызывать методы. Это фундамент для урока 7 (HTTP) и урока 11 (чистая архитектура).
Урок 6 • Раздел 21

21. Сравнение с React: Context и сервисы

В React общее состояние обычно передают через Context, который выполняет роль, близкую к DI-сервису Angular.

ЗадачаAngularReact
Общий источник данныхСервис + DIContext + Provider
Получить в компонентеinject(TaskService)useContext(TasksContext)
Сделать доступнымprovidedIn: 'root'обернуть в <Provider value>
Изменить состояниеметод сервисафункция из context
// React (урок 21, условно)
const TasksContext = createContext(null);
const s = useContext(TasksContext);

Контекст. Так в React получают тот же «общий» объект данных, что в Angular даёт inject(TaskService).

Аналогия из жизни. Оба подхода — как один и тот же общий чайник для двух разных кухонь: механика разная, но цель одна — налить всем общий «чай».

Что увидит пользователь. В обоих фреймворках результат один: данные, общие для нескольких компонентов, без ручной передачи «по цепочке».

Не торопитесь. Полное изучение Context — в уроке 21. Сейчас достаточно увидеть параллель: и там, и там общее состояние выносится из отдельного компонента и становится доступным нескольким потребителям через некий «провайдер».
Урок 6 • Раздел 22

22. Частые ошибки сервисов и DI

Разберём типичные проблемы, которые возникают при работе с сервисами и DI.

Контекст. Таблица ниже — ваш «аварийный справочник», если что-то пошло не так.

Аналогия из жизни. «No provider» — как прийти к шкафу за кофеваркой, а в шкафу её нет, потому что никто не поставил туда рецепт (провайдер).

СимптомПричинаРешение
«No provider for TaskService»Класс не помечен @Injectable и нет провайдераДобавьте @Injectable({ providedIn: 'root' })
Два компонента видят разное состояниеЛокальный провайдер на компонентеПроверьте, где объявлен провайдер; для общего — 'root'
Компонент меняет данные в обход сервисаОтдаётся изменяемый сигнал, а не readonlyОтдавайте asReadonly(), изменение — методами
Типы не сходятсяКомпонент и сервис используют разные интерфейсыЕдиная модель Task в общем файле
«ng inject must be called from...»inject() вызван вне контекста инъекцийВызывайте в поле/конструкторе компонента или сервиса
Смотрите на контекст инъекций. inject() работает в местах, где Angular понимает текущий инжектор: в поле/конструкторе компонента, в сервисе, в охраннике. Не вызывайте его в произвольной функции без контекста (раздел 28.7 урока 5).
Урок 6 • Раздел 23

23. Практика 1: создаём TaskService

Начнём с генерации и простого сервиса.

Контекст. Команда генерации и минимальный сервис с парой начальных задач для демонстрации.

Аналогия из жизни. Как открыть новый «цех» и положить туда две первые заготовки, чтобы сразу было что показывать.

ng generate service task

Что увидит пользователь. В папке проекта появится новый файл task.service.ts с пустым классом сервиса.

// task.service.ts
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class TaskService { }

Добавим сигнал списка:

import { Injectable, signal } from '@angular/core';
import type { Task } from './task';

@Injectable({ providedIn: 'root' })
export class TaskService {
  private readonly tasks = signal<Task[]>([
    { id: 1, title: 'Создать сервис', completed: false, priority: 'high' },
    { id: 2, title: 'Подключить DI', completed: false, priority: 'low' },
  ]);

  readonly tasksRO = this.tasks.asReadonly();
}

Разбор по шагам:

  1. Генерируем файл командой ng generate.
  2. Объявляем приватный сигнал с двумя начальными задачами.
  3. Выставляем наружу tasksRO для безопасного чтения.

Что увидит пользователь. При подключении сервиса к списку на экране сразу появятся две задачи: «Создать сервис» и «Подключить DI».

Сервис готов: приватное состояние и публичное чтение. Убедитесь, что файл task.service.ts создан в правильной папке и импорты верны.

Урок 6 • Раздел 24

24. Практика 2: хранилище с методами

Контекст. Добавляем операции add/toggle/remove, превращая сервис в настоящее хранилище.

Аналогия из жизни. Как научить кассира принимать новые карточки, отмечать готовые и выбрасывать ненужные.

add(title: string): void {
  const t = title.trim();
  if (!t) return;
  const id = this.nextId();
  this.tasks.update(cur => [
    { id, title: t, completed: false, priority: 'low' },
    ...cur,
  ]);
}

toggle(id: number): void {
  this.tasks.update(cur =>
    cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
  );
}

remove(id: number): void {
  this.tasks.update(cur => cur.filter(t => t.id !== id));
}

private nextId(): number {
  return Math.max(0, ...this.tasks().map(t => t.id)) + 1;
}

Разбор по шагам:

  1. add чистит текст, проверяет пустоту, назначает id и кладёт задачу в начало.
  2. toggle переворачивает статус нужной задачи.
  3. remove оставляет все, кроме совпавшей по id.
  4. nextId находит максимальный номер и прибавляет 1.

Что увидит пользователь. В приложении заработают кнопки добавления, отметки «готово» и удаления.

Проверьте логику: guard для пустого названия, уникальный id, неизменяемые обновления (spread/map/filter). Всё это — паттерны уроков 2 и 5, применённые к сервису.

Урок 6 • Раздел 25

25. Практика 3: подключаем TaskList через DI

Контекст. Компонент получает сервис через inject() и читает его сигнал.

Аналогия из жизни. Как подключить к вашему рабочему столу провод из общего щитка — и лампа загорелась.

// task-list.ts
import { Component, inject } from '@angular/core';
import { TaskService } from '../task.service';

@Component({ selector: 'app-task-list', standalone: true, ... })
export class TaskList {
  readonly s = inject(TaskService);
  readonly tasks = this.s.tasksRO;
}
<!-- task-list.html -->
@for (task of tasks(); track task.id) {
  <li>{{ task.title }} — {{ task.completed ? '✅' : '⬜' }}</li>
}

Разбор по шагам:

  1. Импортируем сервис и вызываем inject(TaskService).
  2. «Прокидываем» tasksRO наружу компонента.
  3. В шаблоне перебираем задачи и показываем заголовок с галочкой/пустым квадратом.

Что увидит пользователь. Список задач из сервиса: «Создать сервис — ⬜», «Подключить DI — ⬜».

Теперь TaskList показывает список из сервиса. Никакого signal() в компоненте — данные приходят извне через DI.

Почему это важно. Компонент больше не «владеет» данными. Он их потребитель. Если данные изменятся в другом месте приложения, TaskList увидит это автоматически.
Урок 6 • Раздел 26

26. Практика 4: shared состояние между компонентами

Контекст. Добавляем StatsBar, чтобы убедиться: два компонента делят один сервис.

Аналогия из жизни. Как два табло в разных углах зала, подключённых к одному и тому же датчику.

// stats-bar.ts
import { Component, inject } from '@angular/core';
import { TaskService } from '../task.service';

@Component({ selector: 'app-stats-bar', standalone: true, ... })
export class StatsBar {
  readonly s = inject(TaskService);
}
<!-- stats-bar.html -->
<span>Всего: {{ s.tasksRO().length }}</span>

Разбор по шагам:

  1. StatsBar внедряет тот же TaskService, что и TaskList.
  2. В шаблоне показывает длину общего списка.

Что увидит пользователь. Если в TaskList добавить задачу, число «Всего:» в StatsBar вырастет само — без дополнительного кода.

Если в TaskList что-то добавить, количество в StatsBar обновится само. Проверьте: оба читают один сигнал tasksRO.

Наблюдение. Здесь StatsBar внедряет сервис напрямую, тогда как в уроке 4 он получал данные входом. Оба подхода корректны; DI удобен, когда данных много и они общие. Для UI-гибкости (переиспользование StatBar с любыми числами) входы лучше — это паттерн «представления» из раздела 14.
Урок 6 • Раздел 27

27. Практика 5: TaskForm и модель TaskForm

Контекст. Свяжем форму с сервисом через черновик TaskDraft.

Аналогия из жизни. Форма — как бланк заявки у входа; секретарь (сервис) сам допишет номер и поставит штамп.

// task-form.ts — собирает черновик
import { Component, inject, output, signal } from '@angular/core';
import { TaskService } from '../task.service';

export class TaskForm {
  private readonly s = inject(TaskService);

  readonly newTitle = signal('');
  readonly created = output<string>();

  submit() {
    const title = this.newTitle().trim();
    if (!title) return;
    this.s.add(title);          // сервис сам создаёт Task
    this.newTitle.set('');
  }
}

Разбор по шагам:

  1. Форма хранит введённый заголовок в локальном сигнале newTitle.
  2. При submit проверяет пустоту и вызывает s.add(title) — сервис сам создаёт задачу.
  3. Очищает поле ввода.

Что увидит пользователь. Вписал текст, нажал «Добавить» — задача появилась в общем списке, а поле ввода стёрлось.

Форма может звать метод сервиса напрямую (а не через событие наверх), потому что сервис — общий. Это упрощает связку по сравнению с уроком 4, где TaskForm сообщал родителю через output().

<input [value]="newTitle()" (input)="onInput($event)" />
<button (click)="submit()">Добавить</button>

Контекст. Шаблон формы связывает поле ввода и кнопку с логикой выше.

Что увидит пользователь. Поле ввода и кнопка «Добавить», готовые принимать текст и отправлять его в сервис.

Когда метод сервиса, когда событие? Если сервис доступен и создание задачи — «глобальная» операция, можно звать метод прямо. Если форма — переиспользуемое «представление» (раздел 14), ей лучше сообщать через output(), а сервис вызывать у контейнера. Оба допустимы; для простоты TaskFlow зовём метод напрямую.
Урок 6 • Раздел 28

28. Практика 6: сервис с computed и статистикой

Контекст. Перенесём вычисления в сервис, чтобы компоненты не дублировали логику фильтра и подсчёта.

Аналогия из жизни. Раньше каждый официант сам считал чек; теперь один кассир (сервис) считает за всех, и цифры везде одинаковые.

readonly visibleTasks = computed(() => {
  const f = this.filter();
  if (f === 'active') return this.tasks().filter(t => !t.completed);
  if (f === 'done') return this.tasks().filter(t => t.completed);
  return this.tasks();
});

readonly counts = computed(() => ({
  total: this.tasks().length,
  active: this.tasks().filter(t => !t.completed).length,
  done: this.tasks().filter(t => t.completed).length,
}));

Разбор по шагам:

  1. visibleTasks смотрит на текущий фильтр и возвращает подходящие задачи.
  2. counts считает три числа: всего, активных, сделанных (done — значение фильтра/категория, а не поле задачи).
  3. Оба значения пересчитываются автоматически при изменении списка.

Что увидит пользователь. В приложении список подстраивается под фильтр, а счётчики показывают актуальные числа без дополнительного кода в компонентах.

Компоненты читают готовые производные:

@for (task of s.visibleTasks(); track task.id) { ... }
<app-stats-bar [total]="s.counts().total" [active]="s.counts().active" />

Контекст. Компоненты просто читают уже готовые производные значения сервиса.

Что увидит пользователь. Те же список и счётчики, но теперь логика живёт в одном месте — сервисе.

Логика фильтра и статистики теперь живёт в одном месте — сервисе. Разные компоненты используют один и тот же вычисленный результат.

Урок 6 • Раздел 28.1

28.1. Модель и сервис: разделение контракта и хранилища

Аналогия. Модель — как чертёж детали, а сервис — как станок, который по этому чертежу детали делает. Чертёж не крутит гайки, а станок не рисует чертежи.

Определение. Модель (интерфейс) и сервис (класс) выполняют разные роли. Модель описывает форму данных, сервис — хранение и логику.

Контекст. Показываем рядом «что такое данные» (типы) и «что с ними делают» (класс).

// model: форма данных (только типы)
export interface Task {
  readonly id: number;
  title: string;
  completed: boolean;
  priority: Priority;
}

// service: хранение и операции (код)
@Injectable({ providedIn: 'root' })
export class TaskService { ... }

Что увидит пользователь. На экране — как и раньше, список задач. Разница в коде: типы и поведение лежат в разных файлах, и менять их можно независимо.

Разделяя их, вы получаете: модель можно использовать в любом месте (компонент, сервис, API), а сервис — единственное место, где данные меняются.

Правило. Типы — в файлах моделей (task.ts), поведение — в сервисах (task.service.ts). Компоненты импортируют и то, и другое, но не смешивают роли.
Урок 6 • Раздел 28.2

28.2. Типизация методов сервиса

Аналогия. Типы у методов — как подписи на дверях склада: «сюда приносят только коробки», «отсюда выдают только по списку». Ошибёшься с грузом — дверь не откроется.

Определение. Методы сервиса стоит типизировать явно: что ожидают на вход и что возвращают.

Контекст. Объявляем, какие данные принимает и отдаёт каждый метод.

add(draft: TaskDraft): void
toggle(id: number): void
remove(id: number): void
setFilter(f: TaskFilter): void

nextId(): number                  // приватный, возвращает число

Явные типы параметров и возврата помогают компилятору ловить несоответствия. Возврат void подчёркивает, что метод меняет состояние, а не возвращает результат.

// продумайте, что возвращать
findById(id: number): Task | null {
  return this.tasks().find(t => t.id === id) ?? null;
}

Разбор по шагам:

  1. Метод findById ищет задачу по id внутри сигнала.
  2. Если нашёл — возвращает объект, если нет — null (оператор ??).
  3. Тип Task | null честно говорит вызывающему: задачи может не быть.

Что увидит пользователь. Приложение работает как прежде, но программист защищён от ошибок: нельзя случайно передать в add число вместо строки.

Task | null честно говорит: задача может не найтись. Компонент обработает null (guard-стиль урока 2).

Урок 6 • Раздел 28.3

28.3. Вынос вычислений в сервис: фильтры

Аналогия. Фильтр в сервисе — как один общий фильтр для воды на кухне. Все пьют из него, и никто не таскает свой личный.

Определение. Если фильтрацию используют несколько компонентов, перенесите её в сервис как публичный computed.

Контекст. Выносим логику «какие задачи показывать» из компонентов в один общий источник.

readonly visibleTasks = computed(() => {
  const f = this.filter();
  if (f === 'active') return this.tasks().filter(t => !t.completed);
  if (f === 'done') return this.tasks().filter(t => t.completed);
  return this.tasks();
});

readonly doneCount = computed(() =>
  this.tasks().filter(t => t.completed).length
);

Разбор по шагам:

  1. visibleTasks зависит от фильтра и возвращает подходящие задачи.
  2. doneCount считает только сделанные (статус completed === true).
  3. Оба значения — общие, их читают все компоненты.

Что увидит пользователь. Список и счётчик «готово» показывают одинаково верные данные во всех частях экрана.

Компоненты читают s.visibleTasks() и s.doneCount(), а не пересчитывают сами. Это устраняет дублирование и гарантирует согласованность.

Признак дублирования. Если вы заметили одинаковый filter(...)` в двух и более компонентах — это сигнал вынести вычисление в общий сервис. Один источник производных — меньше шансов на рассинхронизацию.
Урок 6 • Раздел 28.4

28.4. Инъекция в дочерний и вложенный компоненты

Контекст. Дочерние и вложенные компоненты тоже могут внедрять сервис — DI ищет провайдера вверх по дереву.

Аналогия из жизни. Внук в многоэтажке тоже может дойти до общего подвала (корневого сервиса), поднявшись наверх по лестнице.

App (root provider: TaskService)
└── TaskPage
    └── TaskList
        └── TaskItem        // inject(TaskService) — найдёт в корне

Что увидит пользователь. Ничего нового на экране; это про то, что даже глубоко вложенный компонент «дотянется» до общего сервиса.

TaskItem может внедрить сервис и вызвать, например, s.toggle(id). Но осторожно: это связывает «представление» с данными, ослабляя переиспользование (раздел 14).

// вариант: TaskItem внедряет сервис
export class TaskItem {
  private readonly s = inject(TaskService);
  readonly task = input.required<Task>();

  toggle() {
    this.s.toggle(this.task().id);
  }
}

Разбор по шагам:

  1. Компонент задачи внедряет сервис напрямую.
  2. Получает свою задачу через вход task.
  3. При клике зовёт s.toggle(this.task().id) — меняет общий список.

Что увидит пользователь. Нажали на галочку у конкретной задачи — она переключилась, и счётчик тут же обновился.

Оба подхода (событие наверх vs прямой вызов сервиса) работают. Прямой вызов короче, событие — чище для переиспользуемых компонентов. Выбирайте по контексту.

Урок 6 • Раздел 28.5

28.5. Сервис без providedIn: провайдер вручную

Аналогия. Сервис без providedIn — как инструмент без постоянного места: вы достаёте его только тогда, когда сами кладёте в конкретный ящик.

Определение. Иногда сервис не указывает providedIn — тогда его нужно объявить провайдером вручную.

Контекст. Когда нужен отдельный экземпляр на каждый экран, а не один на всё приложение.

// без providedIn
@Injectable()
export class TaskService { }

Что увидит пользователь. Сам по себе сервис ничего не делает, пока вы не «включите» его в нужном месте через провайдер.

// объявить вручную в компоненте
@Component({
  selector: 'app-task-page',
  standalone: true,
  providers: [TaskService],
})
export class TaskPage { }

Разбор по шагам:

  1. Класс помечен просто @Injectable() без области.
  2. В компоненте в массиве providers мы явно говорим «создай здесь свой экземпляр».

Что увидит пользователь. У каждого TaskPage будет свой независимый список задач, не связанный с другими экранами.

Теперь TaskService виден TaskPage и его дочерним, но не всему приложению.

Зачем вручную. Когда нужен новый экземпляр на каждый «модуль»/экран (изолированное состояние). Если данных общих не требуется — ручной провайдер даже предпочтителен. Если нужны общие данные на всё приложение — используйте 'root'.
Урок 6 • Раздел 28.6

28.6. DI и жизненный цикл: кто создаёт и уничтожает

Аналогия. Корневой сервис — как дворник всего здания: пришёл с открытием офиса и уходит с закрытием. А локальный сервис компонента — как сменный рабочий: появляется, когда открыли кабинет, и уходит, когда закрыли.

Определение. Инжектор управляет жизнью сервиса. Понимание этого помогает избегать утечек.

Контекст. Пример: сервис сам сохраняет задачи в браузере при каждом изменении.

@Injectable({ providedIn: 'root' })
export class TaskService {
  constructor() {
    // эффект живёт, пока жив сервис (всё приложение)
    effect(() => {
      localStorage.setItem('tasks', JSON.stringify(this.tasks()));
    });
  }
}

Разбор по шагам:

  1. В конструкторе сервиса запускается effect.
  2. При каждом изменении tasks() он записывает список в localStorage.
  3. Так как сервис корневой, эффект работает всё время жизни приложения.

Что увидит пользователь. Если перезагрузить страницу, задачи восстановятся из сохранённой копии (в уроке 7 это будет делать сервер, а пока — браузер).

Автоматическая отписка. Эффекты в сервисе/компоненте не нужно отписывать вручную — Angular связывает их с жизненным циклом и очищает при уничтожении владельца (это продолжение раздела 28.7 урока 5).
Урок 6 • Раздел 28.7

28.7. Тестирование сервиса

Аналогия. Тестировать сервис — как проверять кассу отдельно от всего магазина: открыл, положил деньги, убедился, что в ящике правильно. Сам магазин (экран) для этого не нужен.

Определение. Сервисы легко тестировать, потому что они не зависят от DOM. Достаточно создать экземпляр и проверить методы.

Контекст. Пример теста на фреймворке Jasmine/Karma, который проверяет поведение сервиса без запуска браузера.

import { TestBed } from '@angular/core/testing';
import { TaskService } from './task.service';

describe('TaskService', () => {
  it('добавляет задачу и меняет счётчик', () => {
    const s = TestBed.inject(TaskService);

    s.add('Купить молоко');
    expect(s.tasksRO().length).toBe(1);
    expect(s.tasksRO()[0].title).toBe('Купить молоко');
  });

  it('не добавляет пустое название', () => {
    const s = TestBed.inject(TaskService);
    s.add('   ');
    expect(s.tasksRO().length).toBe(0);
  });
});

Разбор по шагам:

  1. TestBed.inject(TaskService) даёт тот же сервис, что и в приложении.
  2. Первый тест добавляет задачу и проверяет, что список вырос и заголовок верный.
  3. Второй тест добавляет одни пробелы и проверяет, что задача НЕ создалась.

Что увидит пользователь. Вы сами ничего на экране не видите — это проверка для программиста, которая запускается автоматически и говорит «всё работает».

TestBed.inject(TaskService) даёт тот же экземпляр, что получили бы компоненты. Проверяем поведение методов, не трогая UI.

Польза разделения. Логика в сервисе = тестируемость без монтажа компонента. В уроке 12 расширим тесты. Сейчас достаточно увидеть, как легко проверяется сервис.
Урок 6 • Раздел 28.8

28.8. Сравнение с React Context/useContext

Аналогия. Angular-сервис и React Context — как две разные марки одного «общего холодильника»: внутри устроены по-разному, но оба раздают еду всем жильцам.

Определение. Углубим параллель Angular-сервиса и React Context — оба решают задачу «передать общее состояние нескольким потребителям».

Контекст. Слева — как это выглядит в Angular, справа — в React (для общего понимания).

// Angular
const s = inject(TaskService);
s.add(title);

Что увидит пользователь. В обоих случаях задача добавляется в общий список и появляется во всех местах, где он показан.

// React (урок 21, для понимания)
const TaskContext = createContext(null);

function Provider({ children }) {
  const [tasks, setTasks] = useState([]);
  const add = (t) => setTasks(prev => [...prev, t]);
  return <TaskContext.Provider value={{ tasks, add }}>{children}</TaskContext.Provider>;
}

// потребление
const { tasks, add } = useContext(TaskContext);

Разбор по шагам:

  1. React создаёт контекст и «провайдер», который держит состояние.
  2. Функция add обновляет состояние неизменяемо (через Spread).
  3. Любой вложенный компонент читает его через useContext — аналог Angular-овского inject().

Что увидит пользователь. Тот же результат, что в Angular: данные общие, и изменения видны везде.

АспектAngular DI-сервисReact Context
Объявлениекласс + @InjectablecreateContext + Provider
Получениеinject(Service)useContext(Context)
Областьroot или компонентпод Provider
Производныеcomputed в сервисеuseMemo / вычисление
Итог параллели. И DI, и Context решают задачу «передать общее состояние нескольким потребителям». Механика разная, намерение то же. Вы уже знаете один способ на Angular — перенос на React дастся легче.
Урок 6 • Раздел 28.9

28.9. Отладка обработчика событий через сервис

Аналогия. Отладка через сервис — как искать протечку в одной трубе, а не в десяти разведённых по дому. Всё течёт через сервис, поэтому смотрим только на него.

Определение. Когда события больше не «поднимаются» вручную, а вызывают методы сервиса, отладка меняется: ищите, какой метод сервиса вызван и как он меняет состояние.

Контекст. Временно добавляем вывод в консоль, чтобы понять, что происходит при клике.

// временная диагностика в сервисе
toggle(id: number) {
  console.log('toggle', id, this.tasks().map(t => t.id));
  this.tasks.update(...);
}

Разбор по шагам:

  1. Перед изменением состояния пишем в консоль, какой id пришёл и какие задачи сейчас есть.
  2. Запускаем приложение и смотрим консоль браузера при клике.
  3. Если id не тот или список пуст — понятно, где искать ошибку.

Что увидит пользователь. На экране — как обычно, список. А программист в консоли видит диагностические сообщения и понимает, срабатывает ли метод.

Шаги при проблеме:

  1. Срабатывает ли событие? Посмотрите, вызывается ли метод (лог в сервисе).
  2. Доходит ли до сервиса правильный id?
  3. Меняется ли сигнал (лог значения до/после)?
  4. Читают ли компоненты обновлённый сигнал?
Плюс DI. Раз состояние в одном сервисе, достаточно проверить этот сервис — не нужно искать по десяткам компонентов, где хранится копия. Единый источник правды упрощает отладку.
Урок 6 • Раздел 28.10

28.10. Практика 7: TaskService с поиском и фильтрами

Контекст. Соберём сервис, который объединяет состояние, фильтры, поиск и статистику — всё в одном источнике.

Аналогия из жизни. Это как диспетчер, который одновременно ведёт журнал задач, отбирает нужные по запросу и считает статистику — один «организатор» вместо пяти разрозненных.

@Injectable({ providedIn: 'root' })
export class TaskService {
  private readonly tasks = signal<Task[]>([]);
  private readonly filter = signal<TaskFilter>('all');
  private readonly query = signal('');

  readonly tasksRO = this.tasks.asReadonly();

  readonly visibleTasks = computed(() => {
    let list = this.tasks();
    const f = this.filter();
    if (f === 'active') list = list.filter(t => !t.completed);
    if (f === 'done') list = list.filter(t => t.completed);

    const q = this.query().trim().toLowerCase();
    if (q) list = list.filter(t => t.title.toLowerCase().includes(q));
    return list;
  });

  readonly counts = computed(() => ({
    total: this.tasks().length,
    active: this.tasks().filter(t => !t.completed).length,
    done: this.tasks().filter(t => t.completed).length,
  }));

  setFilter(f: TaskFilter) { this.filter.set(f); }
  setQuery(q: string) { this.query.set(q); }
  add(title: string): void { ... }
  toggle(id: number): void { ... }
  remove(id: number): void { ... }
}

Разбор по шагам:

  1. Три приватных сигнала: сами задачи, выбранный фильтр и строка поиска.
  2. visibleTasks сначала отбирает по фильтру, затем — по слову поиска.
  3. counts считает статистику, а setFilter/setQuery меняют настройки отбора.

Что увидит пользователь. В приложении появляется поле поиска и фильтры; список мгновенно подстривается под ввод, а счётчики остаются точными.

Компоненты теперь вызывают setFilter/setQuery и читают visibleTasks(). Вся фильтрация и поиск — централизованы.

<input (input)="s.setQuery($any($event.target).value)" placeholder="Поиск" />
<app-filter-bar [filter]="s.filterRO()" (filterChange)="s.setFilter($event)" />

Контекст. Шаблон подключает поле поиска и панель фильтра к методам сервиса.

Что увидит пользователь. При вводе текста список тут же отфильтровывается, а переключение фильтра меняет набор показанных задач.

Объединение фильтра и поиска в один сервис гарантирует, что все компоненты видят одинаковый результат.

Урок 6 • Раздел 28.11

28.11. Архитектура: наш слой данных

Аналогия. Слой данных — как почта: письма (модели) имеют форму, почтальон (сервис) их сортирует и разносит, а жильцы (компоненты) только получают и читают.

Определение. К концу урока у TaskFlow появился явный слой данных. Опишем его словами и схемой.

Контекст. Схема направления зависимостей: от показа к хранилищу и дальше к типам данных.

Компоненты (отображение)
   │  читают / вызывают
   ▼
TaskService (хранилище + логика)
   │  использует
   ▼
Модели Task, TaskDraft, TaskFilter (контракт)

Разбор по шагам:

  1. Компоненты не хранят данные сами — они лишь читают и вызывают методы.
  2. TaskService держит состояние и логику.
  3. В основе лежат модели — «договор» о форме данных.

Что увидит пользователь. На экране приложение выглядит попрежнему, но его внутренняя архитектура теперь чистая и понятная.

Перечислим, что где лежит:

СущностьРоль
Task, TaskDraft, TaskFilterмодели (типы данных)
TaskServiceхранилище + операции + производные
Компонентыотображение и связывание
Это основа для урока 7. Когда список начнёт приходить из API, модели останутся теми же, а TaskService получит методы загрузки/сохранения. Слой данных не изменится по структуре — изменится источник.
Урок 6 • Раздел 28.12

28.12. Иерархическая DI: почему inject() находит сервис

Аналогия. Дерево инжекторов — как матрёшка этажей: ищешь нужное у себя на этаже, нет — поднимаешься выше, пока не найдёшь на самом верху.

Определение. Angular строит дерево инжекторов, которое повторяет дерево компонентов. Когда компонент вызывает inject(TaskService), инжектор поднимается вверх, пока не найдёт провайдера.

Контекст. Схема поиска: от вложенного компонента вверх до корня.

RootInjector  →  providedIn: 'root'  (виден всем)
   │
AppComponent
   │
TaskPage (providers: [AuthService])
   │
TaskList  → inject(AuthService)  // находит выше
   │
TaskItem  → inject(TaskService)  // находит в RootInjector

Разбор по шагам:

  1. TaskList запрашивает AuthService; своего нет — поднимается и находит у TaskPage.
  2. TaskItem запрашивает TaskService; поднимается до корня и находит там.
  3. Поиск всегда идёт «снизу вверх» и останавливается у первого найденного.

Что увидит пользователь. На экране — ничего нового; это объясняет, почему сервис доступен даже в глубоко вложенных компонентах.

Поиск идёт снизу вверх по дереву. Первый найденный инжектор, объявивший провайдер, и создаёт/выдаёт экземпляр.

Переопределение. Если объявить один и тот же токен на двух уровнях, ближайший к запросу победит. Это позволяет, например, на конкретном экране подменить глобальный сервис локальной реализацией — полезно для mock-данных или утечки изоляции (раздел 28.13).
Урок 6 • Раздел 28.13

28.13. Провайдеры-формы: useClass, useValue, useFactory

Аналогия. Провайдеры — как меню выдачи: можно выдать «тот же самый предмет» (класс), «готовую вещь» (значение) или «собранную на заказ» (фабрика).

Определение. За токеном может стоять не только класс, но и значение или фабричная функция. Это даёт гибкость в замене реализации.

Контекст. Разные способы описать провайдер в массиве providers.

providers: [
  TaskService,                        // то же, что useClass: TaskService
  { provide: TaskService, useClass: MockTaskService },  // подмена класса
  { provide: 'API_URL', useValue: 'http://localhost:3000' }, // значение
  { provide: TaskService, useFactory: () => buildService(), deps: [HttpClient] },
]

Разбор по шагам:

  1. Просто имя класса — то же, что useClass на себя.
  2. useClass подменяет класс другим (например, фейковым).
  3. useValue отдаёт готовую строку/объект.
  4. useFactory строит экземпляр функцией, которой могут быть нужны зависимости (deps).

Что увидит пользователь. Напрямую — ничего; эта гибкость нужна программисту, чтобы подставлять разные реализации в тестах и разных сборках.

Когда пригодится. useClass удобен для моков на этапе, когда API ещё не готов; useFactory — когда экземпляр зависит от рантайм-данных. Для обычного сервиса достаточно @Injectable({ providedIn: 'root' }).
Урок 6 • Раздел 28.14

28.14. InjectionToken: токен не-классовых зависимостей

Аналогия. InjectionToken — как именной ярлык на ключах: сам по себе не вещь, но однозначно указывает, какой именно ключ взять из связки.

Определение. Если зависимость не класс, токеном может служить InjectionToken — уникальная метка.

Контекст. Как передать строку-настройку (например, адрес сервера) через тот же механизм DI.

export const API_URL = new InjectionToken<string>('API_URL');

// предоставление
providers: [{ provide: API_URL, useValue: 'http://localhost:3000/api' }]

// получение
const url = inject(API_URL);

Разбор по шагам:

  1. Создаём уникальный токен с именем API_URL.
  2. Регистрируем его значение через useValue.
  3. В нужном месте получаем значение через inject(API_URL).

Что увидит пользователь. Само по себе — ничего на экране; но адрес сервера теперь централизованно настраивается и доступен любому сервису.

Токен «не виден» по имени — это просто маркер. Типизация через дженерик защищает от передачи значения неверного типа.

Чтение кода. В учебном коде TaskFlow токен API_URL появится в уроке 7, когда сервис начнёт делать HTTP-запросы. Сейчас запомните: токен = «ключ» для конфигурации, не требующий класса.
Урок 6 • Раздел 28.15

28.15. Декораторы параметров: @Optional и @Host

Аналогия. @Optional() — как договорённость «если зонтика нет, ладно, обойдусь». @Host() — «ищи только в своей комнате, не выходя в коридор».

Определение. В классическом (не сигнальном) DI параметры конструктора можно помечать декораторами, уточняя логику поиска.

Контекст. Пример: сервис логирования может отсутствовать, и мы это разрешаем.

constructor(
  @Optional() private log: Logger | null,
) { }

Разбор по шагам:

  1. @Optional() говорит: если провайдера Logger нет — не падай, подставь null.
  2. Тип Logger | null напоминает нам проверять наличие перед использованием.

Что увидит пользователь. Приложение не «сломается» с ошибкой, даже если логгер не настроен; оно просто не будет писать логи.

Современный взгляд. С сигнальной эрой предпочитают inject(), но декораторы остались в старом коде и библиотеках. Умение их читать — часть грамотности, заложенной в уроке 1 (обзор экосистемы).
Урок 6 • Раздел 28.16

28.16. Локальное и серверное состояние

Состояние делят на локальное (живёт в UI) и серверное (приходит из API). Для TaskFlow:

Тип состоянияПримерГде живёт
Локальноеоткрыто ли меню, значение поля формыкомпонент, ephemeral UI
Серверноесписок задач, статус пользователясервис/глобальное хранилище
Кэшуже загруженные задачи, «подсказки»сервис (загружается по требованию)

Серверное состояние имеет свой жизненный цикл: загрузка → данные → ошибка → обновление. В уроке 6 ключевая идея: не смешивать локальный UI-статус с данными из сети.

// плохо: данные + UI-флаги в одной куче
export const tasks = signal<Task[]>([]);
export const isLoading = signal(false);

// лучше: сервис держит Задачи; компонент держит свои UI-состояния

Контекст. Показываем, почему нельзя смешивать данные задач и экранные флаги в одном месте.

Аналогия из жизни. Как если бы на одном листке писали и рабочее задание, и «у меня открыто окно» — запутаешься, что относится к делу, а что к настроению.

Разбор по шагам:

  1. В «плохом» варианте задачи и флаг загрузки лежат рядом и путаются.
  2. В «лучшем» задачи уходят в сервис, а isLoading остаётся в компоненте.

Что увидит пользователь. Внешне приложение то же, но архитектурно изменения задач и экранного статуса не мешают друг другу — проще развивать и тестировать.

К уроку 7. Когда подключим HttpClient, к списку добавятся сигналы loading() и error(). Именно поэтому важно заранее держать UI-статус отдельно от самих данных.
Урок 6 • Раздел 28.17

28.17. Слой данных и будущий TaskApiClient

Аналогия. Клиент API — как курьер: сервис (диспетчер) говорит ему «принеси посылку», а сам не бегает на склад. Так диспетчер занят только учётом, а доставка — отдельно.

Определение. Чтобы перейти к сети без ломки компонентов, предвосхитим ещё один слой — клиент API.

Контекст. Схема будущих слоёв: транспорт отделён от хранилища.

// задача 7: отделить транспорт от хранилища
TaskApiClient  → fetch/HttpClient, знает URL и JSON
TaskService    → подписывается на данные, хранит их в сигналах
Компоненты     → читают сигналы, вызывают методы сервиса

Разбор по шагам:

  1. TaskApiClient знает только как ходить в сеть (URL, JSON).
  2. TaskService хранит данные и вызывает клиента при необходимости.
  3. Компоненты по-прежнему общаются только с сервисом.

Что увидит пользователь. Пока ничего нового — это проект на будущее (урок 7). Но благодаря такой схеме интерфейс не придётся переписывать.

Сейчас TaskService сам хранит задачи и не знает о сети. В уроке 7 он будет лишь «оборачивать» запросы клиента. Компоненты этого не заметят — граница останется той же.

Принцип. Уже на уроке 6 проектируем так, чтобы замена источника данных (локальный массив → API) не затронула ни одного компонента. Слой данных изолирован от отображения — это и есть награда за сервисы и DI.
Урок 6 • Раздел 28.18

28.18. Мемоизация производных в сервисе

Аналогия. Мемоизация — как заранее посчитанный итог на табло: его не пересчитывают заново при каждом взгляде, а обновляют только когда изменились входные данные.

Определение. computed в сервисе работают как ленивые мемоизированные вычисления: они пересчитываются только когда изменились их зависимости.

Контекст. Пример производного значения, которое Angular кэширует между чтениями.

readonly counts = computed(() => ({
  total:   this.tasks().length,
  active:  this.tasks().filter(t => !t.completed).length,
  done:    this.tasks().filter(t => t.completed).length,
}));

Разбор по шагам:

  1. Пока список tasks() не менялся, повторное чтение counts() возвращает сохранённый результат.
  2. Как только задачи изменились — вычисление запускается один раз и кэшируется.
  3. Это экономит ресурсы при частом чтении из разных компонентов.

Что увидит пользователь. Счётчики всегда корректны, но приложение не тратит лишнее время на пересчёты «вхолостую».

Пока tasks() не менялась, повторные чтения counts() отдают закэшированный результат. Как только массив изменится — вычисление обновится один раз.

Почему важно. Фильтрация filter(...) — это проход по массиву. При большом списке повторный пересчёт на каждый компонент был бы тратой. Мемоизация в сервисе делает производные «один раз на изменение», а не «раз на чтение».
Урок 6 • Раздел 28.19

28.19. Тестируемость сервиса

Аналогия. Тестировать сервис без DOM — как проверять двигатель на стенде, не собирая весь автомобиль: крутится — значит работает.

Определение. Сервис, не зависящий от разметки, легко тестировать: создал экземпляр, вызвал метод, проверил сигнал.

Контекст. Пример теста сервиса, который можно запустить даже без Angular-окружения.

const service = new TaskService();   // без Angular
service.add({ title: 'Изучить DI', priority: 'high' });
expect(service.tasksRO().length).toBe(1);
expect(service.tasksRO()[0].completed).toBe(false);
service.toggle(service.tasksRO()[0].id);
expect(service.tasksRO()[0].completed).toBe(true);

Разбор по шагам:

  1. Создаём сервис напрямую, без браузера.
  2. Добавляем задачу и проверяем, что она появилась и ещё не выполнена.
  3. Переключаем статус и убеждаемся, что completed стал true.

Что увидит пользователь. Вы сами ничего не видите на экране — тест «зеленеет», подтверждая, что сервис работает правильно.

Чистые сервисы с инъекцией зависимостей естественно тестируются без браузера: вы подставляете фейковые зависимости (useValue) и проверяете поведение.

Преимущество границы. Если бизнес-логика в компоненте, её приходится тестировать через DOM. Вынеся её в сервис, вы получаете быстрые и надёжные unit-тесты. Это один из главных аргументов «за» слой данных на уроке 6.
Урок 6 • Раздел 28.20

28.20. Параллель: Angular DI и React Context

Для тех, кто знает React, полезно увидеть мост:

AngularReactНазначение
@Injectable + inject()Context + useContextдоступ к «общему» значению в дереве
providedIn: 'root'провайдер на верхнем уровнеобщий экземпляр на всё приложение
явный провайдер (providers)провайдер на поддеревеизоляция/свои данные для ветки
Инжекторпровайдерный контекстконтейнер значений

Разница: в Angular инжектор — часть системного ядра и умеет фабрики, области, деревья. В React контекст — обычный объект, который вы настраиваете сами. Идея одна: передавать зависимости сверху вниз, а не создавать «на месте».

Урок 6 • Раздел 28.21

28.21. Антипаттерны слоя данных

Соберём, чего стоит избегать, строя сервисы.

АнтипаттернПочему плохоКак правильно
Глобальный «кладовщик» на всёнечитаемо, трудно тестироватьделить сервисы по смыслу
Публичный изменяемый сигналлюбой может испортить состояниеasReadonly + методы
Дублирование фильтра в компонентахрассинхронизация логикивынести computed в сервис
Сервис зависит от DOM/компонентовне переиспользуется и не тестируетсясервис не должен знать о разметке
Хранить HTTP-логику рядом с состояниемсмешение транспорта и кэшаклиент API отдельно (урок 7)
Правило дыры. Если сервис приходится «дублировать» подсистемой запросов к данным — скорее всего, стоит разделить транспорт и хранилище. В уроке 7 мы увидим, как это выглядит на практике.
Урок 6 • Раздел 28.22

28.22. Полный листинг TaskService

Контекст. Соберём весь сервис, чтобы видеть его целиком — от моделей до методов. Это «итоговый чертёж» всего, что мы разбирали в уроке.

Аналогия из жизни. Как сводная схема всего цеха: видно и форму деталей (модели), и сам станок (сервис) с его кнопками (методами).

// task.ts — модели
export type Priority = 'low' | 'medium' | 'high';
export interface Task {
  readonly id: number;       // назначается сервисом/сервером, не меняется
  title: string;
  completed: boolean;        // статус выполнения (НЕ 'done')
  priority: Priority;
}
export type TaskDraft = Pick<Task, 'title' | 'priority'>; // черновик формы
export type TaskFilter = 'all' | 'active' | 'done';        // 'done' — значение фильтра

// task.service.ts — слой данных
@Injectable({ providedIn: 'root' }) // singleton на всё приложение
export class TaskService {
  private readonly tasks = signal<Task[]>(initialTasks); // приватное состояние
  readonly tasksRO = this.tasks.asReadonly();            // чтение наружу

  private readonly filter = signal<TaskFilter>('all');   // приватный фильтр
  readonly filterRO = this.filter.asReadonly();

  // производный список с учётом фильтра
  readonly visibleTasks = computed(() => {
    const f = this.filter();
    if (f === 'active') return this.tasks().filter(t => !t.completed);
    if (f === 'done')   return this.tasks().filter(t => t.completed); // фильтр 'done'
    return this.tasks();
  });

  // производная статистика
  readonly counts = computed(() => ({
    total: this.tasks().length,
    active: this.tasks().filter(t => !t.completed).length,
    done: this.tasks().filter(t => t.completed).length,
  }));

  // добавить задачу из черновика
  add(draft: TaskDraft): void {
    const title = draft.title.trim();
    if (!title) return;                       // guard: пустой ввод
    this.tasks.set([...this.tasks(), {
      id: this.nextId(),                      // сервис назначает id
      ...draft,
      title,
      completed: false,                       // новая задача всегда не выполнена
    }]);
  }
  // переключить completed по id
  toggle(id: number): void {
    this.tasks.update(xs =>
      xs.map(t => t.id === id ? { ...t, completed: !t.completed } : t));
  }
  // удалить по id
  remove(id: number): void {
    this.tasks.update(xs => xs.filter(t => t.id !== id));
  }
  // сменить фильтр
  setFilter(f: TaskFilter): void { this.filter.set(f); }

  // приватный генератор уникального id
  private nextId(): number {
    return this.tasks().reduce((m, t) => Math.max(m, t.id), 0) + 1;
  }
}

Это рабочий «скелет» TaskService. Все методы идут через set/update (неизменяемость урока 5), наружу — только asReadonly() и методы.

Разбор по шагам:

  1. Модели вверху задают форму задачи и черновика; обратите внимание: completed — это поле задачи, а 'done' — значение фильтра.
  2. Сервис прячет tasks и filter в приватных сигналах, отдавая наружу только asReadonly().
  3. Методы add/toggle/remove/setFilter — единственный способ изменить состояние.

Что увидит пользователь. Собранный по этому листингу сервис даёт полноценное приложение со списком, фильтром и статистикой — всё на общих данных.

Урок 6 • Раздел 28.23

28.23. Отладка DI: откуда пришёл сервис?

Когда поведение кажется «не тем», полезно понять, какой инжектор выдал экземпляр.

Приём. Если сомневаетесь, временно добавьте в экземпляр уникальное поле uid (Math.random) и выведите на экран в двух компонентах. Если значения совпали — экземпляр один; разошлись — создаётся несколько, где-то лишний провайдер.
// временная диагностика
private readonly uid = Math.random().toString(36).slice(2);
// вывести {{ s.uid }} в двух компонентах

Контекст. Способ проверить, один ли экземпляр сервиса используют компоненты, или разные.

Аналогия из жизни. Как наклеить на два чемодана одинаковые бирки и посмотреть: если номер совпал — чемодан один, если разный — их два.

Разбор по шагам:

  1. Даём сервису случайный uid при создании.
  2. Выводим его в двух компонентах.
  3. Совпали — экземпляр общий; разошлись — где-то лишний провайдер.

Что увидит пользователь. На экране в двух местах появятся одинаковые случайные строки (если сервис один) или разные (если их несколько).

Урок 6 • Раздел 28.24

28.24. Пошаговая миграция: из компонента в сервис

Если в проекте список уже лежит в компоненте, перенесём его в сервис без рывков.

  1. Создать модели (Task, TaskDraft, TaskFilter) в отдельном файле.
  2. Создать TaskService с providedIn: 'root' и приватным сигналом tasks.
  3. Перенести методы add/toggle/remove/setFilter из компонента в сервис.
  4. Отдать наружу asReadonly() и методы; компонент получает сервис через inject().
  5. Заменить обращения в шаблоне: локальные сигналы → s.tasksRO(), s.visibleTasks() и т.д.
  6. Удалить старое состояние из компонента и проверить, что всё работает так же.
// до: список и методы в компоненте (урок 5)
readonly tasks = signal(initialTasks);
add(title: string) { ... }

// после: всё в сервисе, компонент внедряет зависимость
export class TaskList {
  private readonly s = inject(TaskService);
  readonly tasks = this.s.tasksRO;
  add(title: string) { this.s.add({ title, priority: 'medium' }); }
}

Контекст. Наглядное сравнение «было/стало» при переносе состояния.

Аналогия из жизни. Как перенести личный блокнот в общий архив: раньше добавляли запись сами, теперь просят архивариуса (сервис).

Разбор по шагам:

  1. До: компонент держал tasks и метод add у себя.
  2. После: компонент лишь внедряет сервис и перенаправляет вызовы в него.
  3. Логика add теперь живёт в сервисе, компонент стал «тонким».

Что увидит пользователь. Приложение работает точно так же, но теперь то же состояние доступно и другим компонентам.

Польза шага. Такой рефакторинг безопасен: интерфейс компонента почти не меняется, меняется лишь источник данных. Это готовит почву для урока 7, где источником станет HTTP.
Урок 6 • Раздел 28.25

28.25. Мост к уроку 7: что изменится с HTTP

Подведём черту: как сервисы подготовили переход к сети.

Сейчас (урок 6)После (урок 7)
Начальные данные в коде (константа)данные загружаются по запросу GET
add/toggle/remove меняют сигналметоды шлют POST/PATCH/DELETE и обновляют сигнал
Ошибок сети нетпоявляются сигналы loading/error
Сервис знает все данные сразусервис кэширует ответы API

Главное: компоненты почти не изменятся. Они по-прежнему читают s.tasksRO() и вызывают методы. Меняется только реализация сервиса — слой данных вновь доказывает свою ценность.

// то, что останется неизменным в компоненте (урок 6 и 7)
@if (s.loading()) {
  <div class="loading">Загрузка…</div>
} @else {
  @for (task of s.visibleTasks(); track task.id) {
    <app-task-item [task]="task" />
  }
}

Контекст. Шаблон уже готов к тому, что в уроке 7 появится загрузка из сети.

Аналогия из жизни. Как витрина, у которой предусмотрено место для таблички «Сейчас привезём товар» — даже если пока товар и так на месте.

Разбор по шагам:

  1. Если s.loading() — показываем индикатор загрузки.
  2. Иначе — перебираем видимые задачи и рисуем их.
  3. Сигнал loading() появится в уроке 7, но сам шаблон менять не придётся.

Что увидит пользователь. Сейчас — просто список задач. Позже, при загрузке из сети, на мгновение увидит «Загрузка…», а затем список.

Сигналы loading() и error() появятся в уроке 7, но шаблон уже готов к их использованию. Архитектура настоящего приложения немного «забегает вперёд» — и это нормально, если границы поставлены верно с самого начала.

Итог урока 6. Вы построили изолированный слой данных на сервисах и DI. Он самодостаточен и готов к подключению HTTP в уроке 7 — источник данных заменится, а архитектура останется прежней.
Урок 6 • Раздел 29

29. Самостоятельная работа

Семь заданий на сервисы и DI. Попробуйте сами, затем сверьтесь с подсказками.

Задание 1 — Создать сервис

Сгенерируйте TaskService с приватным сигналом tasks и публичным readonly-чтением.

Задание 2 — Методы хранилища

Добавьте add/toggle/remove. Добавьте guard на пустой title.

Задание 3 — Подключить TaskList

TaskList получает сервис через inject() и показывает список.

Задание 4 — Общий источник

Add StatsBar, читающий тот же сервис; убедитесь, что счётчик обновляется.

Задание 5 — Модель и черновик

Разделите Task и TaskDraft (Pick); сервис принимает черновик.

Задание 6 — Фильтр в сервисе

Перенесите фильтрацию в сервис как computed visibleTasks.

Задание 7 — Инкапсуляция

Сделайте состояние приватным, наружу — только readonly и методы.

Урок 6 • Раздел 30

30. Подсказки к самостоятельной работе

Совет: сначала сформулируйте идею, затем смотрите код.

Задание 1. Приватный signal, публичный asReadonly(). Класс помечен @Injectable({ providedIn: 'root' }).
Задание 2. nextId() через Math.max; guard if (!t) return; в начале add.
Задание 3. private readonly s = inject(TaskService);, затем @for (task of s.tasksRO(); ...).
Задание 4. В StatsBar тоже inject(TaskService) и чтение того же tasksRO. Счётчик обновится сам.
Задание 5. export type TaskDraft = Pick<Task, 'title' | 'priority'>; и add(draft: TaskDraft).
Задание 6. computed с условием по filter(); читайте s.visibleTasks().
Задание 7. Скрыть сигнал, публичными сделать readonly/методы. Никакого set снаружи.
Урок 6 • Раздел 31

31. Частые ошибки DI и сервисов

Соберём типичные ошибки и их решения.

СимптомПричинаРешение
«No provider for TaskService»нет @Injectable/провайдерадобавить providedIn или providers
Разное состояние в разных компонентахлокальный провайдердля общего — 'root'
Компонент пишет в сигнал напрямуюотдан изменяемый сигналasReadonly + методы
inject() вне контекставызов в свободной функциив поле/конструкторе компонента или сервиса
Циркулярная зависимостьдва сервиса внедряют друг другапереработать связь, разорвать цикл
Сервис не обновляет UIизменение необъявленного сигнала/неизменяемостьset/update с новым массивом (урок 5)
«Cannot read properties of undefined»сервис не внедрён, поле undefinedпроверить inject() и порядок инициализации
Повторный код фильтра в нескольких местахлогика не вынесена в сервисдобавить computed в сервис
Проектируйте границы. Не делайте один гигантский сервис, отвечающий за всё. Делить по смыслу: TaskService — задачи, позже AuthService, NotificationService и т.д. Мелкие сервисы легче читать и тестировать.
Урок 6 • Раздел 32

32. Резюме: сервисы как слой данных

Пять правил работы с сервисами.

Дополнительные наблюдения из урока:

Пять правил сервисов и DI1. Состояние в сервисеобщий источник данных2. providedIn: 'root'один экземпляр на всех3. inject() в компонентеполучение зависимости4. readonly наружу + методы5. модели = контракт
Рисунок 3. Пять правил слоя данных TaskFlow.
Урок 6 • Раздел 33

33. Контрольные вопросы

Ответьте своими словами, затем сверьтесь с конспектом.

  1. Зачем выносить данные из компонента в сервис?
  2. Что делает @Injectable({ providedIn: 'root' })?
  3. Что такое Dependency Injection и зачем она?
  4. Как получить сервис в компоненте (современный способ)?
  5. Почему несколько компонентов видят одни и те же данные при 'root'?
  6. В чём разница между моделью и сервисом?
  7. Что такое TaskDraft и когда он нужен?
  8. Как инкапсулировать состояние сервиса?
  9. Что такое локальное и серверное состояние?
  10. Как провайдеры useClass/useValue/useFactory различаются?
  11. Что произойдёт, если нужен отдельный экземпляр на каждый компонент?
  12. Как DI-сервис соотносится с React Context?
  13. Что такое InjectionToken и когда он нужен?
  14. Зачем нужны useClass/useValue/useFactory на практике?
  15. Что означает «дерево инжекторов» и как идёт поиск провайдера?
  16. Как сервис делает приложение тестируемым?
  17. Какие данные стоит держать в сервисе, а какие — в компоненте?
  18. Что произойдёт с сервисом, когда источник данных станет сетью?
Урок 6 • Раздел 34

34. Мини-словарь

ТерминОпределение
СервисКласс для данных и логики, не связанный с отображением.
Dependency InjectionМеханизм передачи зависимостей извне, а не создание их в классе.
@InjectableДекоратор, помечающий класс как внедряемый.
providedIn: 'root'Доступность сервиса во всём приложении (один экземпляр).
inject()Функция получения зависимости в контексте инъекций.
ПровайдерОписание «как создать экземпляр» зависимости.
ИнжекторКонтейнер, хранящий и выдающий зависимости.
МодельТип/интерфейс, описывающий форму данных.
КонтрактСоглашение о форме данных между сервисом и компонентами.
asReadonly()Публичное чтение сигнала без возможности изменить.
ИнкапсуляцияСкрытие внутреннего состояния, доступ только через интерфейс.
useClass / useValue / useFactoryВиды провайдеров для замены реализации.
Иерархическая DIДерево инжекторов, повторяющее дерево компонентов.
InjectionTokenМетка-токен для зависимостей, не являющихся классами.
@Optional / @HostДекораторы, уточняющие поиск зависимости.
Локальное состояниеUI-статус, живущий в компоненте (меню, поле формы).
Серверное состояниеДанные, приходящие из API и кэшируемые в сервисе.
МемоизацияКэширование результата вычисления до изменения зависимостей.
Урок 6 • Раздел 35

35. Источники и продолжение

Для самостоятельного углубления:

Рекомендуемый порядок повторения:

  1. Перечитать разделы 28.1–28.11 (модель, инкапсуляция, инжекторы).
  2. Выполнить задания раздела 29 без подсказок.
  3. Сравнить свой сервис с полным листингом 28.22 и разобрать отличия.
  4. Проследить «путь» сервиса через схему раздела 22 (дерево инжекторов).
Следующий шаг. В уроке 7 мы подключим TaskService к mock API через HttpClient. Сервис останется «лицом» данных, но источником станет сеть, а состояние загрузки/ошибки добавит новые сигналы.
Урок 6 • Раздел 36

36. Чек-лист урока

Готовность к уроку 7. Если вы можете выполнить задания раздела 29 и объяснить, как общий сервис связывает TaskList и StatsBar, — слой данных освоен. Осталось подключить к нему HTTP.

🛠 Практика к уроку 6

Выделите состояние задач в сервис и подключите через DI.

Задание 1. Сервис-хранилище

Контекст. Минимальный сервис-хранилище: приватный список, безопасное чтение и метод переключения статуса.

Аналогия из жизни. Как маленький сейф с одной кнопкой «перевернуть галочку» у выбранной карточки.

@Injectable({ providedIn: 'root' })
export class TaskService {
  private readonly _tasks = signal<Task[]>([]);
  readonly tasks = this._tasks.asReadonly();
  toggle(id: number) {
    this._tasks.update(list =>
      list.map(t => t.id === id
        ? { ...t, completed: !t.completed } : t));
  }
}

Разбор по шагам:

  1. Приватный сигнал _tasks спрятан от внешних правок.
  2. Наружу — только tasks как asReadonly().
  3. toggle находит задачу по id и переворачивает её completed.

Что увидит пользователь. В приложении список задач, у каждой — кнопка, меняющая статус «сделано/не сделано».

Задание 2. Получение через inject()

Контекст. Компонент-страница подключает сервис, чтобы пользоваться им в шаблоне.

Аналогия из жизни. Как взять ключ от общего сейфа и положить его в свой стол.

export class TaskPage {
  protected readonly service = inject(TaskService);
}

Что увидит пользователь. Сам по себе этот класс ничего не рисует, но даёт шаблону доступ к данным сервиса.

Полная версия. Практика: Урок 6 → — модели Task/TaskDraft, CRUD-методы и песочница сервиса.
Куда дальше: практикуй сервисы в практике к уроку 6, а про HTTP и mock API — в Уроке 7.

📖 Глоссарий

ТерминЖизненная аналогияКоротко
Сервис (@Injectable)Общая кофеварка на этаже: одна на всех, наливают из неёКласс, где хранятся данные и логика, общие для всех компонентов
DI (внедрение зависимостей)Кто-то приносит тебе готовый инструмент, не надо собирать самомуAngular сам подставляет нужный сервис туда, где он запрошен
Локальное состояниеЛипкий листок у тебя на столе — видишь только тыДанные одного компонента, не видны остальным
Серверное состояниеДоска объявлений в холле — её видят всеДанные, пришедшие с сервера и общие для приложения
TaskГотовый билет: есть номер (id) и галочка (completed)Полноценная задача с id и полем completed
TaskDraftЧерновик анкеты без номераДанные формы до создания задачи: ещё нет id и completed
КомпонентДеталь конструктора / запчасть машиныЧасть интерфейса, которая показывает данные и реагирует на действия

🧠 Самопроверка

1. Сервис с providedIn: 'root' создаётся отдельно для каждого компонента. Верно или неверно?

2. Dependency Injection — это когда компонент сам пишет new TaskService() внутри себя. Верно или неверно?

3. TaskDraft уже содержит id и completed. Верно или неверно?

Прогресс курса