Студенческий материал. Его можно использовать как самостоятельный конспект, раздаточный материал и справочник после занятия.
Компонент должен заботиться об отображении и взаимодействии, а не хранить и обрабатывать все данные приложения. Ответственность за данные берёт на себя сервис.
Аналогия. Представьте, что список задач — это общий календарь в офисе. Если каждый сотрудник заведёт свой личный календарь, они быстро разойдутся: кто-то запишет встречу, а кто-то — нет. Поэтому кофеварка должна быть одна на всех, и все наливают из неё. Сервис — это и есть та самая «общая кофеварка на этаже» для данных приложения (один экземпляр на всех).
В уроке 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);
}
Разбор по шагам:
tasks объявлено прямо внутри класса TaskList — только этот компонент его и видит.TaskService, помеченный @Injectable.TaskList больше не создаёт данные сам, а получает готовый сервис через inject(TaskService).Что увидит пользователь. На экране пока ничего не меняется — список выглядит так же. Но теперь любой другой компонент (например, счётчик задач) сможет прочитать те же самые задачи, и они не разойдутся.
Переход от «состояние в компоненте» к «состояние в сервисе» — это шаг от одиночного экрана к приложению, где данные переиспользуются между частями.
В уроке 5 мы научились строить реактивное состояние. Урок 6 переносит его в сервис и подключает через DI.
Аналогия. Представьте, что до сих пор каждая комната (компонент) хранила свои запасы в шкафу. Теперь мы строим общую кофеварку (сервис) на этаже, и все комнаты берут оттуда. Таблица ниже — план этого переезда.
| Что было | Что станет в уроке 6 |
|---|---|
signal/computed в компоненте | тот же реактивный код в сервисе |
| Один компонент владеет данными | сервис — общий хозяин данных |
| Передача через входы | получение через inject() |
| Модель Task из урока 2 | официальный контракт сервиса |
| — | разделение «локального» и «серверного» состояния |
Аналогия. Сервис — как общая кофеварка на этаже. Она одна на всех, стоит на кухне, и любой сотрудник может прийти и налить кофе. Никто не собирает свою кофеварку на рабочем столе. Так же и сервис: это одно место, где «варятся» данные и логика, и любой компонент берёт оттуда готовое.
Определение. Сервис — это обычный 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[]>([]);
}
Разбор по шагам:
import { Injectable, signal } — берём из Angular нужные инструменты.@Injectable({ providedIn: 'root' }) — говорим Angular: «создай один такой объект на всё приложение и раздавай его».readonly tasks = signal<Task[]>([]) — внутри хранится пустой список задач, обёрнутый в сигнал (реактивную переменную).Что увидит пользователь. Сам по себе этот код ничего на экране не рисует — он лишь готовит «кухню». Задачи появятся, когда компонент прочитает tasks() и выведет их.
Что делает @Injectable? Он сообщает Angular: этот класс можно внедрять как зависимость. Опция providedIn: 'root' означает, что сервис доступен из любого места приложения (точнее — из корневого инжектора).
Сервис создаётся командой:
Контекст. Angular умеет генерировать файл сервиса сам, чтобы не писать каркас вручную.
Аналогия из жизни. Это как заказать готовый шкаф у сборщика вместо того, чтобы пилить доски самим: каркас приедет уже с нужными креплениями.
ng generate service task
# создаёт task.service.ts
Что увидит пользователь. В папке появится файл task.service.ts с заготовкой класса. Пока на экране ничего не меняется — это просто новый файл в проекте.
Сгенерированный файл уже содержит @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 {
// один экземпляр на всё приложение
}
Аналогия. 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);
}
Разбор по шагам:
inject(TaskService) — то есть «дай мне сервис задач».Что увидит пользователь. На экране — как и раньше, список задач. Но теперь за кулисами все компоненты работают с одним источником, поэтому, добавив задачу в одном месте, вы увидите её во всех.
DI связывает компоненты и сервисы «через контракт», а не напрямую по коду. Это упрощает замену реализации и тестирование.
new TaskService() вручную и следить за тем, чтобы это был один и тот же объект.Аналогия. 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; // сигнал
// или использовать напрямую
}
Разбор по шагам:
import { Component, inject } — подключаем функцию внедрения.inject(TaskService) — получаем общий сервис.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() проще и работает в полях, поэтому наш курс использует его. Оба подхода функционируют; выбор стиля — личный или командный.
Аналогия. Один сервис с '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; // тот же источник
}
Разбор по шагам:
inject(TaskService) — и получают один и тот же объект.TaskList берёт сигнал списка, а StatsBar — сигнал счётчика сделанных.Что увидит пользователь. Добавили задачу в списке — и счётчик в StatsBar тут же вырос. Никакой ручной синхронизации писать не нужно.
Когда TaskList добавляет задачу через s.task(), StatsBar видит изменение автоматически, потому что оба читают один и тот же сигнал.
providedIn: 'root' Angular создаёт единственный экземпляр сервиса на всё приложение. Поэтому в нём хранят «общее» состояние. Именно это превращает локальный список из урока 5 в глобальный источник задач.Аналогия. Модель (интерфейс) — как анкета для пропуска: в ней заранее нарисованы графы «имя», «номер», «статус». Кто бы ни заполнял, структура одина и та же, и на входе билетера не бывает сюрпризов.
Определение. Модель описывает форму данных, которой придерживается сервис. Это «контракт» между сервисом, компонентами и (в будущем) API.
Контекст. Файл task.ts задаёт, из каких полей состоит задача. Сервис и компоненты будут строить задачи строго по этой форме.
Аналогия из жизни. Поле completed — как галочка «выполнено» в чек-листе: либо стоит, либо нет, третьего не дано.
// task.ts
export type Priority = 'high' | 'medium' | 'low';
export interface Task {
id: number;
title: string;
completed: boolean;
priority: Priority;
}
Разбор по шагам:
Priority — перечисление допустимых важностей задачи (только три варианта).Task — «чертеж» задачи: у неё есть номер id, заголовок, статус completed и важность.Что увидит пользователь. Сама по себе модель невидима — она работает «за сценой», не давая программисту случайно создать задачу без заголовка или с неправильной важностью.
Сервис опирается на модель при объявлении сигнала и методов:
@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 — как кассир: он сам пробивает новый номер задачи и выдаёт карточку с правильно заполненными графами. Вы не пишете номер ручкой — сервис делает это за вас.
Разбор по шагам:
id через nextId().Task со статусом completed: false (новая задача всегда не сделана).update — не меняя старый массив, а создавая новый (неизменяемость).Что увидит пользователь. Когда форма вызовет add(...), в списке появится новая задача с правильным номером и статусом «не выполнено».
src/app/models/task.model.ts с интерфейсами Task, TaskDraft и типом Priority из этого раздела.Аналогия. 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 } — как переписывание строки в чек-листе на чистый листок с одной поправкой, а старый листок оставляем нетронутым. Никто не правит «поверх», поэтому история понятна.
Разбор по шагам:
update берёт текущий список cur.map проходит по каждой задаче; у нужной (совпал id) меняет только completed на противоположное.Что увидит пользователь. Галочка у задачи переключится: была пустая — стала заполненной (и наоборот).
Покажем пошаговый перенос списка задач из компонента (урок 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(() => ...);
}
Разбор по шагам:
tasks и метод toggle.inject(TaskService) и «прокидываем» нужное наружу.visibleTasks пока остаётся в компоненте, так как он зависит от экрана.Что увидит пользователь. Внешне ничего не меняется — задачи отображаются как раньше. Но теперь тот же список доступен и другим компонентам.
Шаблон TaskList почти не меняется, но данные теперь живут в сервисе и доступны другим компонентам.
Аналогия. Сервис-хранилище — как шкаф с инвентарём и инструкцией: вещи лежат внутри, а брать их можно только через ручки (методы). Нельзя залезть внутрь руками и переложить что попало.
Определение. Хороший сервис-хранилище объединяет состояние и операции над ним в одном месте. Компоненты вызывают методы, а не лезут «внутрь» сигнала напрямую.
Контекст. Полноценный сервис с добавлением, переключением и удалением задач.
Аналогия из жизни. Каждый метод — как кнопка на кофеварке: «налить», «выключить», «слить». Вы нажимаете кнопку, а внутренняя механика спрятана.
@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;
}
}
Разбор по шагам:
add: сначала проверяет, что заголовок не пустой (guard), затем назначает id и кладёт новую задачу в начало списка.toggle: находит задачу по id и переворачивает её статус completed.remove: оставляет в списке все задачи, кроме той, чей id совпал (через filter).nextId приватный — внешние компоненты его не вызывают, он нужен только самому сервису.Что увидит пользователь. В приложении появляются кнопки «добавить», «готово», «удалить», и список живо реагирует на каждую. Пустая строка просто игнорируется — задача не создаётся.
Компоненты не обновляют tasks через update() сами — они зовут s.add(), s.toggle(). Это централизует логику и делает её переиспользуемой.
src/app/services/tasks.service.ts с классом TaskService и методами add / toggle / remove из этого раздела.ng serve). Объект создаётся один раз и рассылает изменения всем подписчикам, как это делает DI-сервис.Контекст песочницы. Это упрощённая копия сервиса на чистом JavaScript, чтобы вы могли нажать «Запустить» и пощёлкать кнопки без установки Angular.
Аналогия из жизни. Объект store — как дежурный у доски объявлений: вы просите его повесить/снять листок, а он сам обновляет доску и кричит всем «посмотрите, изменилось!».
Что увидит пользователь. Список задач под кнопками. Впишите текст и нажмите «Добавить» — появится новая строка с ⬜. Нажмите «Toggle #1» — первая задача сменит ⬜ на ✅.
Аналогия. Внешний 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 { ... }
}
Разбор по шагам:
private readonly tasks — внутреннее хранилище, снаружи его нет.tasksReadonly — копия «только для чтения», которую компоненты могут смотреть.pendingCount — производное значение (сколько задач ещё не сделано), считается автоматически.add/toggle/remove — единственный способ что-то изменить.Что увидит пользователь. Приложение работает как раньше, но теперь архитектурно никто не может случайно «испортить» список напрямую.
Компоненты читают tasksReadonly(), считают через pendingCount() и изменяют только через методы. Это инкапсуляция: внутреннее состояние не «светится» наружу для произвольной записи.
tasks и вызовет set(), это обойдёт логику сервиса. Используйте asReadonly() для чтения и методы для записи.Аналогия. Локальное состояние — как липкий листок на мониторе: нужен только вам прямо сейчас, оторвали — и забыли. Серверное состояние — как доска объявлений в холле: её видят все, и она «главнее», потому что оттуда берут данные многие.
Определение. Локальное состояние — данные одного экрана (открыто ли меню, что в поле ввода). Серверное состояние — данные, которые пришли (или придут) из API и нужны многим компонентам.
Не всё состояние принадлежит сервису. Различают два типа (урок 1, раздел 32).
| Тип | Примеры | Где живёт |
|---|---|---|
| Локальный UI-статус | открыта ли панель, активная вкладка, фокус | компонент |
| Серверное состояние | список задач из API | сервис (позже — с HTTP) |
Контекст. Пример: открытое меню фильтра — это чисто экранная мелочь, а список задач — общие данные.
Аналогия из жизни. Меню фильтра — как липкий листок у тебя на столе (видно только вам). Список задач — как доска объявлений в холле (видят все).
// локальный UI-статус — остаётся в компоненте
export class FilterBar {
isDropdownOpen = false; // никому кроме себя не нужно
}
// данные задач — сервис
export class TaskService {
readonly tasks = signal<Task[]>([]);
}
Разбор по шагам:
isDropdownOpen объявлено прямо в компоненте FilterBar — оно нужно только ему для показа/скрытия меню.tasks лежит в сервисе — потому что задачи нужны списку, статистике и форме одновременно.Что увидит пользователь. Меню фильтра открывается/закрывается только на этом экране. А задачи видны везде, где их вывели компоненты.
В уроке 7 список задач придёт из API — это серверное состояние. Тогда сервис будет загружать, кэшировать и обновлять его. Сейчас он хранит данные локально как «муляж» будущего серверного состояния.
Аналогия. Контейнер — как официант: он ходит на кухню (сервис), берет заказ и относит вам. Представление — как тарелка: на ней просто лежит еда, она не знает, как её готовили.
Определение. Компонент-контейнер работает с данными и сервисом. Компонент-представление только показывает то, что ему передали, и не знает о сервисе.
Контекст. Контейнер берёт данные из сервиса и передаёт их вниз; представление получает их через входы.
Аналогия из жизни. Вход [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>();
}
Разбор по шагам:
TaskPage внедряет сервис и получает список.app-task-list как вход [tasks].TaskList просто объявляет вход и выход, не зная про сервис вообще.Что увидит пользователь. На экране — тот же список задач. Разница видна программисту: представление можно переиспользовать с любыми данными.
Контейнер «знает» сервис и бизнес-логику. Представление — «чистый»: только входы и выходы (урок 4). Такое разделение облегчает тестирование и переиспользование.
Аналогия. 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'>;
Разбор по шагам:
Task — полная запись, включая id и completed.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 до отправки в сервис.
id (его выдаст сервис/сервер) и не выбран финальный статус. Если бы форма работала с типом Task напрямую, пришлось бы врать: подставлять фиктивный id и статус completed. TaskDraft честно говорит: «здесь только то, что ввёл пользователь», а сервис превращает черновик в настоящую задачу.Pick<Task, 'title' | 'priority'> берёт из Task только указанные поля. Удобно, когда полей немного; при сложной форме создают отдельный интерфейс (урок 8 — формы).ng serve). Показывает разницу: данные с сервера уже содержат id и completed, а черновик формы — только title и priority, которые сервис потом дополняет.Контекст песочницы. Показывает на живом примере, чем отличаются полная запись (Task) и черновик (TaskDraft).
Аналогия из жизни. В поле вывода вы увидите «до» и «после»: сырую записку и готовую задачу с номером.
Что увидит пользователь. Введите название, выберите важность, нажмите «Создать задачу» — в блоке появится JSON полной задачи (с id и completed:false) и JSON черновика (без них).
Аналогия. Инкапсуляция — как термос: внутри горячий чай, снаружи — только крышка с кнопкой. Вы пьёте через кнопку, но не можете засунуть руку внутрь и перемешать чай.
Определение. Инкапсуляция означает: скрыть внутренности, показать только нужный интерфейс. В сервисе это реализуется так.
Контекст. Внутренний сигнал прячем за 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 — как замок на складе: сами туда не зайдёте, только через кассира (методы).
Разбор по шагам:
private readonly tasks — недоступно снаружи класса.tasksRO — безопасная «витрина» для чтения.Что увидит пользователь. Приложение работает как прежде, но программист защищён от ошибок: случайно испортить список из компонента нельзя.
Компонент не может случайно сделать s.tasks().push(...) или s.tasks.set(...) — ему доступны только tasksRO и методы.
Аналогия. Провайдер — как рецепт блюда. Инжектор — как шкаф с готовыми порциями. Дерево инжекторов — как этажи здания: на каждом этаже свой шкаф, а если на своём нет нужного, идёте на этаж выше.
Определение. Провайдер — запись «как создать экземпляр». Инжектор — контейнер, хранящий экземпляры. Дерево инжекторов — иерархия от корня приложения до вложенных компонентов.
Разберём три понятия DI: провайдер, инжектор и дерево зависимостей.
providedIn: 'root' уже провайдер по умолчанию.inject().Когда компонент вызывает inject(TaskService), Angular ищет провайдер от текущего уровня вверх по дереву, до корня.
Контекст. Схема поиска сервиса в дереве компонентов.
Аналогия из жизни. Спускаетесь по этажам вниз — на каждом спрашиваете шкаф; дошли до крыши (корня) — там точно есть общий.
App(корневой инжектор: TaskService)
└── TaskPage
└── TaskList // inject(TaskService) → нашёл в корне
Что увидит пользователь. На экране — ничего нового; это внутренняя логика Angular. Но понимание помогает, когда сервис вдруг «не находится».
Аналогия. Провайдеры — как разные способы выдать инструмент: 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 — как собрать кофеварку прямо в момент выдачи.
Разбор по шагам:
Что увидит пользователь. Само по себе — ничего; это настройка «под капотом». Но в тестах вы, например, увидите фейковые данные вместо реальных.
Провайдеры можно указывать в массиве providers компонента или на уровне роутера/приложения.
@Component({
selector: 'app-task-page',
standalone: true,
providers: [
{ provide: TaskService, useClass: MockTaskService },
],
})
Контекст. Здесь провайдер объявлен внутри конкретного компонента, поэтому замена касается только его и его детей.
Что увидит пользователь. Для TaskPage и его детей сервис теперь будет «фейковым» (MockTaskService), а не настоящим — полезно при отладке и тестах.
providedIn: 'root', но знание провайдеров пригодится в уроке 13 и тестах (урок 12).Аналогия. 'root' — как городская площадь, где один фонтан на всех. Провайдер в компоненте — как личный кувшин у каждого дома: у соседа свой, и вода может быть другой.
Определение. Где объявлен провайдер — определяет, один ли экземпляр сервиса на всё приложение или по одному на компонент.
Контекст. Два способа объявить область видимости сервиса.
// один экземпляр на всё приложение
@Injectable({ providedIn: 'root' })
export class TaskService { }
// НОВЫЙ экземпляр на каждый компонент TaskPage
@Component({
selector: 'app-task-page',
standalone: true,
providers: [TaskService], // локальный провайдер
})
Аналогия из жизни. В первом случае все смотрят в одно табло. Во втором — у каждого TaskPage свой личный список, независимый от остальных.
Разбор по шагам:
providedIn: 'root' Angular создаёт сервис один раз и раздаёт всем.providers: [TaskService] в компоненте Angular создаёт новый экземпляр для каждого такого компонента.Что увидит пользователь. При 'root' задача, добавленная в одном месте, видна везде. При локальном провайдере — только внутри своего экрана.
В первом случае все компоненты делят одно состояние. Во втором — каждый TaskPage получает собственный экземпляр и собственное состояние.
| Где провайдер | Экземпляров | Состояние |
|---|---|---|
providedIn: 'root' | 1 на приложение | общее для всех |
| в компоненте | по 1 на компонент | своё на экран |
'root'. Если у каждого независимого экрана должен быть свой набор (например, изолированный фильтр) — локальный провайдер. Не смешивайте без понимания.Контекст. Соберём весь слой данных 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 здесь — значение фильтра, а не поле задачи.
Разбор по шагам:
tasks (все задачи) и filter (текущий фильтр).visibleTasks считает, какие задачи показать, в зависимости от фильтра.counts считает статистику (всего / активных / сделанных).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" />
Аналогия из жизни. Шаблон — как витрина диспетчерской: на ней развешаны фильтр, список и счётчики, все берут данные из одного пульта.
Разбор по шагам:
setFilter.visibleTasks() — уже отфильтрованные задачи.toggle или remove сервиса.counts().Что увидит пользователь. Полноценный экран TaskFlow: фильтр, список с кнопками «готово»/«удалить» и панель статистики.
Теперь весь слой данных — один сервис, а компоненты — тонкие обёртки вокруг него.
В React общее состояние обычно передают через Context, который выполняет роль, близкую к DI-сервису Angular.
| Задача | Angular | React |
|---|---|---|
| Общий источник данных | Сервис + DI | Context + Provider |
| Получить в компоненте | inject(TaskService) | useContext(TasksContext) |
| Сделать доступным | providedIn: 'root' | обернуть в <Provider value> |
| Изменить состояние | метод сервиса | функция из context |
// React (урок 21, условно)
const TasksContext = createContext(null);
const s = useContext(TasksContext);
Контекст. Так в React получают тот же «общий» объект данных, что в Angular даёт inject(TaskService).
Аналогия из жизни. Оба подхода — как один и тот же общий чайник для двух разных кухонь: механика разная, но цель одна — налить всем общий «чай».
Что увидит пользователь. В обоих фреймворках результат один: данные, общие для нескольких компонентов, без ручной передачи «по цепочке».
Разберём типичные проблемы, которые возникают при работе с сервисами и 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).Начнём с генерации и простого сервиса.
Контекст. Команда генерации и минимальный сервис с парой начальных задач для демонстрации.
Аналогия из жизни. Как открыть новый «цех» и положить туда две первые заготовки, чтобы сразу было что показывать.
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();
}
Разбор по шагам:
ng generate.tasksRO для безопасного чтения.Что увидит пользователь. При подключении сервиса к списку на экране сразу появятся две задачи: «Создать сервис» и «Подключить DI».
Сервис готов: приватное состояние и публичное чтение. Убедитесь, что файл task.service.ts создан в правильной папке и импорты верны.
Контекст. Добавляем операции 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;
}
Разбор по шагам:
add чистит текст, проверяет пустоту, назначает id и кладёт задачу в начало.toggle переворачивает статус нужной задачи.remove оставляет все, кроме совпавшей по id.nextId находит максимальный номер и прибавляет 1.Что увидит пользователь. В приложении заработают кнопки добавления, отметки «готово» и удаления.
Проверьте логику: guard для пустого названия, уникальный id, неизменяемые обновления (spread/map/filter). Всё это — паттерны уроков 2 и 5, применённые к сервису.
Контекст. Компонент получает сервис через 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>
}
Разбор по шагам:
inject(TaskService).tasksRO наружу компонента.Что увидит пользователь. Список задач из сервиса: «Создать сервис — ⬜», «Подключить DI — ⬜».
Теперь TaskList показывает список из сервиса. Никакого signal() в компоненте — данные приходят извне через DI.
Контекст. Добавляем 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>
Разбор по шагам:
TaskService, что и TaskList.Что увидит пользователь. Если в TaskList добавить задачу, число «Всего:» в StatsBar вырастет само — без дополнительного кода.
Если в TaskList что-то добавить, количество в StatsBar обновится само. Проверьте: оба читают один сигнал tasksRO.
Контекст. Свяжем форму с сервисом через черновик 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('');
}
}
Разбор по шагам:
newTitle.submit проверяет пустоту и вызывает s.add(title) — сервис сам создаёт задачу.Что увидит пользователь. Вписал текст, нажал «Добавить» — задача появилась в общем списке, а поле ввода стёрлось.
Форма может звать метод сервиса напрямую (а не через событие наверх), потому что сервис — общий. Это упрощает связку по сравнению с уроком 4, где TaskForm сообщал родителю через output().
<input [value]="newTitle()" (input)="onInput($event)" />
<button (click)="submit()">Добавить</button>
Контекст. Шаблон формы связывает поле ввода и кнопку с логикой выше.
Что увидит пользователь. Поле ввода и кнопка «Добавить», готовые принимать текст и отправлять его в сервис.
output(), а сервис вызывать у контейнера. Оба допустимы; для простоты TaskFlow зовём метод напрямую.Контекст. Перенесём вычисления в сервис, чтобы компоненты не дублировали логику фильтра и подсчёта.
Аналогия из жизни. Раньше каждый официант сам считал чек; теперь один кассир (сервис) считает за всех, и цифры везде одинаковые.
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,
}));
Разбор по шагам:
visibleTasks смотрит на текущий фильтр и возвращает подходящие задачи.counts считает три числа: всего, активных, сделанных (done — значение фильтра/категория, а не поле задачи).Что увидит пользователь. В приложении список подстраивается под фильтр, а счётчики показывают актуальные числа без дополнительного кода в компонентах.
Компоненты читают готовые производные:
@for (task of s.visibleTasks(); track task.id) { ... }
<app-stats-bar [total]="s.counts().total" [active]="s.counts().active" />
Контекст. Компоненты просто читают уже готовые производные значения сервиса.
Что увидит пользователь. Те же список и счётчики, но теперь логика живёт в одном месте — сервисе.
Логика фильтра и статистики теперь живёт в одном месте — сервисе. Разные компоненты используют один и тот же вычисленный результат.
Аналогия. Модель — как чертёж детали, а сервис — как станок, который по этому чертежу детали делает. Чертёж не крутит гайки, а станок не рисует чертежи.
Определение. Модель (интерфейс) и сервис (класс) выполняют разные роли. Модель описывает форму данных, сервис — хранение и логику.
Контекст. Показываем рядом «что такое данные» (типы) и «что с ними делают» (класс).
// 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). Компоненты импортируют и то, и другое, но не смешивают роли.Аналогия. Типы у методов — как подписи на дверях склада: «сюда приносят только коробки», «отсюда выдают только по списку». Ошибёшься с грузом — дверь не откроется.
Определение. Методы сервиса стоит типизировать явно: что ожидают на вход и что возвращают.
Контекст. Объявляем, какие данные принимает и отдаёт каждый метод.
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;
}
Разбор по шагам:
findById ищет задачу по id внутри сигнала.null (оператор ??).Task | null честно говорит вызывающему: задачи может не быть.Что увидит пользователь. Приложение работает как прежде, но программист защищён от ошибок: нельзя случайно передать в add число вместо строки.
Task | null честно говорит: задача может не найтись. Компонент обработает null (guard-стиль урока 2).
Аналогия. Фильтр в сервисе — как один общий фильтр для воды на кухне. Все пьют из него, и никто не таскает свой личный.
Определение. Если фильтрацию используют несколько компонентов, перенесите её в сервис как публичный 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
);
Разбор по шагам:
visibleTasks зависит от фильтра и возвращает подходящие задачи.doneCount считает только сделанные (статус completed === true).Что увидит пользователь. Список и счётчик «готово» показывают одинаково верные данные во всех частях экрана.
Компоненты читают s.visibleTasks() и s.doneCount(), а не пересчитывают сами. Это устраняет дублирование и гарантирует согласованность.
filter(...)` в двух и более компонентах — это сигнал вынести вычисление в общий сервис. Один источник производных — меньше шансов на рассинхронизацию.Контекст. Дочерние и вложенные компоненты тоже могут внедрять сервис — 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);
}
}
Разбор по шагам:
task.s.toggle(this.task().id) — меняет общий список.Что увидит пользователь. Нажали на галочку у конкретной задачи — она переключилась, и счётчик тут же обновился.
Оба подхода (событие наверх vs прямой вызов сервиса) работают. Прямой вызов короче, событие — чище для переиспользуемых компонентов. Выбирайте по контексту.
Аналогия. Сервис без providedIn — как инструмент без постоянного места: вы достаёте его только тогда, когда сами кладёте в конкретный ящик.
Определение. Иногда сервис не указывает providedIn — тогда его нужно объявить провайдером вручную.
Контекст. Когда нужен отдельный экземпляр на каждый экран, а не один на всё приложение.
// без providedIn
@Injectable()
export class TaskService { }
Что увидит пользователь. Сам по себе сервис ничего не делает, пока вы не «включите» его в нужном месте через провайдер.
// объявить вручную в компоненте
@Component({
selector: 'app-task-page',
standalone: true,
providers: [TaskService],
})
export class TaskPage { }
Разбор по шагам:
@Injectable() без области.providers мы явно говорим «создай здесь свой экземпляр».Что увидит пользователь. У каждого TaskPage будет свой независимый список задач, не связанный с другими экранами.
Теперь TaskService виден TaskPage и его дочерним, но не всему приложению.
'root'.Аналогия. Корневой сервис — как дворник всего здания: пришёл с открытием офиса и уходит с закрытием. А локальный сервис компонента — как сменный рабочий: появляется, когда открыли кабинет, и уходит, когда закрыли.
Определение. Инжектор управляет жизнью сервиса. Понимание этого помогает избегать утечек.
'root') создаётся один раз и живёт, пока живо приложение.effect()) в сервисе живут вместе с ним.Контекст. Пример: сервис сам сохраняет задачи в браузере при каждом изменении.
@Injectable({ providedIn: 'root' })
export class TaskService {
constructor() {
// эффект живёт, пока жив сервис (всё приложение)
effect(() => {
localStorage.setItem('tasks', JSON.stringify(this.tasks()));
});
}
}
Разбор по шагам:
effect.tasks() он записывает список в localStorage.Что увидит пользователь. Если перезагрузить страницу, задачи восстановятся из сохранённой копии (в уроке 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);
});
});
Разбор по шагам:
TestBed.inject(TaskService) даёт тот же сервис, что и в приложении.Что увидит пользователь. Вы сами ничего на экране не видите — это проверка для программиста, которая запускается автоматически и говорит «всё работает».
TestBed.inject(TaskService) даёт тот же экземпляр, что получили бы компоненты. Проверяем поведение методов, не трогая UI.
Аналогия. 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);
Разбор по шагам:
add обновляет состояние неизменяемо (через Spread).useContext — аналог Angular-овского inject().Что увидит пользователь. Тот же результат, что в Angular: данные общие, и изменения видны везде.
| Аспект | Angular DI-сервис | React Context |
|---|---|---|
| Объявление | класс + @Injectable | createContext + Provider |
| Получение | inject(Service) | useContext(Context) |
| Область | root или компонент | под Provider |
| Производные | computed в сервисе | useMemo / вычисление |
Аналогия. Отладка через сервис — как искать протечку в одной трубе, а не в десяти разведённых по дому. Всё течёт через сервис, поэтому смотрим только на него.
Определение. Когда события больше не «поднимаются» вручную, а вызывают методы сервиса, отладка меняется: ищите, какой метод сервиса вызван и как он меняет состояние.
Контекст. Временно добавляем вывод в консоль, чтобы понять, что происходит при клике.
// временная диагностика в сервисе
toggle(id: number) {
console.log('toggle', id, this.tasks().map(t => t.id));
this.tasks.update(...);
}
Разбор по шагам:
id пришёл и какие задачи сейчас есть.id не тот или список пуст — понятно, где искать ошибку.Что увидит пользователь. На экране — как обычно, список. А программист в консоли видит диагностические сообщения и понимает, срабатывает ли метод.
Шаги при проблеме:
Контекст. Соберём сервис, который объединяет состояние, фильтры, поиск и статистику — всё в одном источнике.
Аналогия из жизни. Это как диспетчер, который одновременно ведёт журнал задач, отбирает нужные по запросу и считает статистику — один «организатор» вместо пяти разрозненных.
@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 { ... }
}
Разбор по шагам:
visibleTasks сначала отбирает по фильтру, затем — по слову поиска.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)" />
Контекст. Шаблон подключает поле поиска и панель фильтра к методам сервиса.
Что увидит пользователь. При вводе текста список тут же отфильтровывается, а переключение фильтра меняет набор показанных задач.
Объединение фильтра и поиска в один сервис гарантирует, что все компоненты видят одинаковый результат.
Аналогия. Слой данных — как почта: письма (модели) имеют форму, почтальон (сервис) их сортирует и разносит, а жильцы (компоненты) только получают и читают.
Определение. К концу урока у TaskFlow появился явный слой данных. Опишем его словами и схемой.
Контекст. Схема направления зависимостей: от показа к хранилищу и дальше к типам данных.
Компоненты (отображение)
│ читают / вызывают
▼
TaskService (хранилище + логика)
│ использует
▼
Модели Task, TaskDraft, TaskFilter (контракт)
Разбор по шагам:
TaskService держит состояние и логику.Что увидит пользователь. На экране приложение выглядит попрежнему, но его внутренняя архитектура теперь чистая и понятная.
Перечислим, что где лежит:
| Сущность | Роль |
|---|---|
| Task, TaskDraft, TaskFilter | модели (типы данных) |
| TaskService | хранилище + операции + производные |
| Компоненты | отображение и связывание |
Аналогия. Дерево инжекторов — как матрёшка этажей: ищешь нужное у себя на этаже, нет — поднимаешься выше, пока не найдёшь на самом верху.
Определение. Angular строит дерево инжекторов, которое повторяет дерево компонентов. Когда компонент вызывает inject(TaskService), инжектор поднимается вверх, пока не найдёт провайдера.
Контекст. Схема поиска: от вложенного компонента вверх до корня.
RootInjector → providedIn: 'root' (виден всем)
│
AppComponent
│
TaskPage (providers: [AuthService])
│
TaskList → inject(AuthService) // находит выше
│
TaskItem → inject(TaskService) // находит в RootInjector
Разбор по шагам:
TaskList запрашивает AuthService; своего нет — поднимается и находит у TaskPage.TaskItem запрашивает TaskService; поднимается до корня и находит там.Что увидит пользователь. На экране — ничего нового; это объясняет, почему сервис доступен даже в глубоко вложенных компонентах.
Поиск идёт снизу вверх по дереву. Первый найденный инжектор, объявивший провайдер, и создаёт/выдаёт экземпляр.
Аналогия. Провайдеры — как меню выдачи: можно выдать «тот же самый предмет» (класс), «готовую вещь» (значение) или «собранную на заказ» (фабрика).
Определение. За токеном может стоять не только класс, но и значение или фабричная функция. Это даёт гибкость в замене реализации.
Контекст. Разные способы описать провайдер в массиве providers.
providers: [
TaskService, // то же, что useClass: TaskService
{ provide: TaskService, useClass: MockTaskService }, // подмена класса
{ provide: 'API_URL', useValue: 'http://localhost:3000' }, // значение
{ provide: TaskService, useFactory: () => buildService(), deps: [HttpClient] },
]
Разбор по шагам:
useClass на себя.useClass подменяет класс другим (например, фейковым).useValue отдаёт готовую строку/объект.useFactory строит экземпляр функцией, которой могут быть нужны зависимости (deps).Что увидит пользователь. Напрямую — ничего; эта гибкость нужна программисту, чтобы подставлять разные реализации в тестах и разных сборках.
useClass — другой класс с тем же интерфейсом (подмена на тестах).useValue — готовый объект/примитив (конфигурация).useFactory — функция, создающая экземпляр, с доступом к зависимостям через deps.@Injectable({ providedIn: 'root' }).Аналогия. 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);
Разбор по шагам:
API_URL.useValue.inject(API_URL).Что увидит пользователь. Само по себе — ничего на экране; но адрес сервера теперь централизованно настраивается и доступен любому сервису.
Токен «не виден» по имени — это просто маркер. Типизация через дженерик защищает от передачи значения неверного типа.
API_URL появится в уроке 7, когда сервис начнёт делать HTTP-запросы. Сейчас запомните: токен = «ключ» для конфигурации, не требующий класса.Аналогия. @Optional() — как договорённость «если зонтика нет, ладно, обойдусь». @Host() — «ищи только в своей комнате, не выходя в коридор».
Определение. В классическом (не сигнальном) DI параметры конструктора можно помечать декораторами, уточняя логику поиска.
Контекст. Пример: сервис логирования может отсутствовать, и мы это разрешаем.
constructor(
@Optional() private log: Logger | null,
) { }
Разбор по шагам:
@Optional() говорит: если провайдера Logger нет — не падай, подставь null.Logger | null напоминает нам проверять наличие перед использованием.Что увидит пользователь. Приложение не «сломается» с ошибкой, даже если логгер не настроен; оно просто не будет писать логи.
@Optional() — если провайдера нет, сохранит null, не падая с ошибкой.@Host() — искать только в текущем инжекторе, не поднимаясь выше.@Self() / @SkipSelf() — ограничить поиск самим/родительским инжекторами.@Inject(...) — явно указать токен, если имя не совпадает.inject(), но декораторы остались в старом коде и библиотеках. Умение их читать — часть грамотности, заложенной в уроке 1 (обзор экосистемы).Состояние делят на локальное (живёт в UI) и серверное (приходит из API). Для TaskFlow:
| Тип состояния | Пример | Где живёт |
|---|---|---|
| Локальное | открыто ли меню, значение поля формы | компонент, ephemeral UI |
| Серверное | список задач, статус пользователя | сервис/глобальное хранилище |
| Кэш | уже загруженные задачи, «подсказки» | сервис (загружается по требованию) |
Серверное состояние имеет свой жизненный цикл: загрузка → данные → ошибка → обновление. В уроке 6 ключевая идея: не смешивать локальный UI-статус с данными из сети.
// плохо: данные + UI-флаги в одной куче
export const tasks = signal<Task[]>([]);
export const isLoading = signal(false);
// лучше: сервис держит Задачи; компонент держит свои UI-состояния
Контекст. Показываем, почему нельзя смешивать данные задач и экранные флаги в одном месте.
Аналогия из жизни. Как если бы на одном листке писали и рабочее задание, и «у меня открыто окно» — запутаешься, что относится к делу, а что к настроению.
Разбор по шагам:
isLoading остаётся в компоненте.Что увидит пользователь. Внешне приложение то же, но архитектурно изменения задач и экранного статуса не мешают друг другу — проще развивать и тестировать.
loading() и error(). Именно поэтому важно заранее держать UI-статус отдельно от самих данных.Аналогия. Клиент API — как курьер: сервис (диспетчер) говорит ему «принеси посылку», а сам не бегает на склад. Так диспетчер занят только учётом, а доставка — отдельно.
Определение. Чтобы перейти к сети без ломки компонентов, предвосхитим ещё один слой — клиент API.
Контекст. Схема будущих слоёв: транспорт отделён от хранилища.
// задача 7: отделить транспорт от хранилища
TaskApiClient → fetch/HttpClient, знает URL и JSON
TaskService → подписывается на данные, хранит их в сигналах
Компоненты → читают сигналы, вызывают методы сервиса
Разбор по шагам:
TaskApiClient знает только как ходить в сеть (URL, JSON).TaskService хранит данные и вызывает клиента при необходимости.Что увидит пользователь. Пока ничего нового — это проект на будущее (урок 7). Но благодаря такой схеме интерфейс не придётся переписывать.
Сейчас TaskService сам хранит задачи и не знает о сети. В уроке 7 он будет лишь «оборачивать» запросы клиента. Компоненты этого не заметят — граница останется той же.
Аналогия. Мемоизация — как заранее посчитанный итог на табло: его не пересчитывают заново при каждом взгляде, а обновляют только когда изменились входные данные.
Определение. 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,
}));
Разбор по шагам:
tasks() не менялся, повторное чтение counts() возвращает сохранённый результат.Что увидит пользователь. Счётчики всегда корректны, но приложение не тратит лишнее время на пересчёты «вхолостую».
Пока tasks() не менялась, повторные чтения counts() отдают закэшированный результат. Как только массив изменится — вычисление обновится один раз.
filter(...) — это проход по массиву. При большом списке повторный пересчёт на каждый компонент был бы тратой. Мемоизация в сервисе делает производные «один раз на изменение», а не «раз на чтение».Аналогия. Тестировать сервис без 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);
Разбор по шагам:
completed стал true.Что увидит пользователь. Вы сами ничего не видите на экране — тест «зеленеет», подтверждая, что сервис работает правильно.
Чистые сервисы с инъекцией зависимостей естественно тестируются без браузера: вы подставляете фейковые зависимости (useValue) и проверяете поведение.
Для тех, кто знает React, полезно увидеть мост:
| Angular | React | Назначение |
|---|---|---|
| @Injectable + inject() | Context + useContext | доступ к «общему» значению в дереве |
| providedIn: 'root' | провайдер на верхнем уровне | общий экземпляр на всё приложение |
| явный провайдер (providers) | провайдер на поддереве | изоляция/свои данные для ветки |
| Инжектор | провайдерный контекст | контейнер значений |
Разница: в Angular инжектор — часть системного ядра и умеет фабрики, области, деревья. В React контекст — обычный объект, который вы настраиваете сами. Идея одна: передавать зависимости сверху вниз, а не создавать «на месте».
Соберём, чего стоит избегать, строя сервисы.
| Антипаттерн | Почему плохо | Как правильно |
|---|---|---|
| Глобальный «кладовщик» на всё | нечитаемо, трудно тестировать | делить сервисы по смыслу |
| Публичный изменяемый сигнал | любой может испортить состояние | asReadonly + методы |
| Дублирование фильтра в компонентах | рассинхронизация логики | вынести computed в сервис |
| Сервис зависит от DOM/компонентов | не переиспользуется и не тестируется | сервис не должен знать о разметке |
| Хранить HTTP-логику рядом с состоянием | смешение транспорта и кэша | клиент API отдельно (урок 7) |
Контекст. Соберём весь сервис, чтобы видеть его целиком — от моделей до методов. Это «итоговый чертёж» всего, что мы разбирали в уроке.
Аналогия из жизни. Как сводная схема всего цеха: видно и форму деталей (модели), и сам станок (сервис) с его кнопками (методами).
// 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() и методы.
Разбор по шагам:
completed — это поле задачи, а 'done' — значение фильтра.tasks и filter в приватных сигналах, отдавая наружу только asReadonly().add/toggle/remove/setFilter — единственный способ изменить состояние.Что увидит пользователь. Собранный по этому листингу сервис даёт полноценное приложение со списком, фильтром и статистикой — всё на общих данных.
Когда поведение кажется «не тем», полезно понять, какой инжектор выдал экземпляр.
providers на нескольких уровнях — тогда работает ближайший.'root'; разное — ручной провайдер где-то в ветке.uid (Math.random) и выведите на экран в двух компонентах. Если значения совпали — экземпляр один; разошлись — создаётся несколько, где-то лишний провайдер.// временная диагностика
private readonly uid = Math.random().toString(36).slice(2);
// вывести {{ s.uid }} в двух компонентах
Контекст. Способ проверить, один ли экземпляр сервиса используют компоненты, или разные.
Аналогия из жизни. Как наклеить на два чемодана одинаковые бирки и посмотреть: если номер совпал — чемодан один, если разный — их два.
Разбор по шагам:
uid при создании.Что увидит пользователь. На экране в двух местах появятся одинаковые случайные строки (если сервис один) или разные (если их несколько).
Если в проекте список уже лежит в компоненте, перенесём его в сервис без рывков.
providedIn: 'root' и приватным сигналом tasks.asReadonly() и методы; компонент получает сервис через inject().s.tasksRO(), s.visibleTasks() и т.д.// до: список и методы в компоненте (урок 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' }); }
}
Контекст. Наглядное сравнение «было/стало» при переносе состояния.
Аналогия из жизни. Как перенести личный блокнот в общий архив: раньше добавляли запись сами, теперь просят архивариуса (сервис).
Разбор по шагам:
tasks и метод add у себя.add теперь живёт в сервисе, компонент стал «тонким».Что увидит пользователь. Приложение работает точно так же, но теперь то же состояние доступно и другим компонентам.
Подведём черту: как сервисы подготовили переход к сети.
| Сейчас (урок 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 появится загрузка из сети.
Аналогия из жизни. Как витрина, у которой предусмотрено место для таблички «Сейчас привезём товар» — даже если пока товар и так на месте.
Разбор по шагам:
s.loading() — показываем индикатор загрузки.loading() появится в уроке 7, но сам шаблон менять не придётся.Что увидит пользователь. Сейчас — просто список задач. Позже, при загрузке из сети, на мгновение увидит «Загрузка…», а затем список.
Сигналы loading() и error() появятся в уроке 7, но шаблон уже готов к их использованию. Архитектура настоящего приложения немного «забегает вперёд» — и это нормально, если границы поставлены верно с самого начала.
Семь заданий на сервисы и DI. Попробуйте сами, затем сверьтесь с подсказками.
Сгенерируйте TaskService с приватным сигналом tasks и публичным readonly-чтением.
Добавьте add/toggle/remove. Добавьте guard на пустой title.
TaskList получает сервис через inject() и показывает список.
Add StatsBar, читающий тот же сервис; убедитесь, что счётчик обновляется.
Разделите Task и TaskDraft (Pick); сервис принимает черновик.
Перенесите фильтрацию в сервис как computed visibleTasks.
Сделайте состояние приватным, наружу — только readonly и методы.
Совет: сначала сформулируйте идею, затем смотрите код.
signal, публичный asReadonly(). Класс помечен @Injectable({ providedIn: 'root' }).nextId() через Math.max; guard if (!t) return; в начале add.private readonly s = inject(TaskService);, затем @for (task of s.tasksRO(); ...).inject(TaskService) и чтение того же tasksRO. Счётчик обновится сам.export type TaskDraft = Pick<Task, 'title' | 'priority'>; и add(draft: TaskDraft).filter(); читайте s.visibleTasks().set снаружи.Соберём типичные ошибки и их решения.
| Симптом | Причина | Решение |
|---|---|---|
| «No provider for TaskService» | нет @Injectable/провайдера | добавить providedIn или providers |
| Разное состояние в разных компонентах | локальный провайдер | для общего — 'root' |
| Компонент пишет в сигнал напрямую | отдан изменяемый сигнал | asReadonly + методы |
| inject() вне контекста | вызов в свободной функции | в поле/конструкторе компонента или сервиса |
| Циркулярная зависимость | два сервиса внедряют друг друга | переработать связь, разорвать цикл |
| Сервис не обновляет UI | изменение необъявленного сигнала/неизменяемость | set/update с новым массивом (урок 5) |
| «Cannot read properties of undefined» | сервис не внедрён, поле undefined | проверить inject() и порядок инициализации |
| Повторный код фильтра в нескольких местах | логика не вынесена в сервис | добавить computed в сервис |
Пять правил работы с сервисами.
@Injectable({ providedIn: 'root' }), если нужен один общий экземпляр.inject().Дополнительные наблюдения из урока:
Ответьте своими словами, затем сверьтесь с конспектом.
@Injectable({ providedIn: 'root' })?| Термин | Определение |
|---|---|
| Сервис | Класс для данных и логики, не связанный с отображением. |
| Dependency Injection | Механизм передачи зависимостей извне, а не создание их в классе. |
| @Injectable | Декоратор, помечающий класс как внедряемый. |
| providedIn: 'root' | Доступность сервиса во всём приложении (один экземпляр). |
| inject() | Функция получения зависимости в контексте инъекций. |
| Провайдер | Описание «как создать экземпляр» зависимости. |
| Инжектор | Контейнер, хранящий и выдающий зависимости. |
| Модель | Тип/интерфейс, описывающий форму данных. |
| Контракт | Соглашение о форме данных между сервисом и компонентами. |
| asReadonly() | Публичное чтение сигнала без возможности изменить. |
| Инкапсуляция | Скрытие внутреннего состояния, доступ только через интерфейс. |
| useClass / useValue / useFactory | Виды провайдеров для замены реализации. |
| Иерархическая DI | Дерево инжекторов, повторяющее дерево компонентов. |
| InjectionToken | Метка-токен для зависимостей, не являющихся классами. |
| @Optional / @Host | Декораторы, уточняющие поиск зависимости. |
| Локальное состояние | UI-статус, живущий в компоненте (меню, поле формы). |
| Серверное состояние | Данные, приходящие из API и кэшируемые в сервисе. |
| Мемоизация | Кэширование результата вычисления до изменения зависимостей. |
Для самостоятельного углубления:
Рекомендуемый порядок повторения:
@Injectable.inject().providedIn: 'root'.Выделите состояние задач в сервис и подключите через DI.
Контекст. Минимальный сервис-хранилище: приватный список, безопасное чтение и метод переключения статуса.
Аналогия из жизни. Как маленький сейф с одной кнопкой «перевернуть галочку» у выбранной карточки.
@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));
}
}
Разбор по шагам:
_tasks спрятан от внешних правок.tasks как asReadonly().toggle находит задачу по id и переворачивает её completed.Что увидит пользователь. В приложении список задач, у каждой — кнопка, меняющая статус «сделано/не сделано».
Контекст. Компонент-страница подключает сервис, чтобы пользоваться им в шаблоне.
Аналогия из жизни. Как взять ключ от общего сейфа и положить его в свой стол.
export class TaskPage {
protected readonly service = inject(TaskService);
}
Что увидит пользователь. Сам по себе этот класс ничего не рисует, но даёт шаблону доступ к данным сервиса.
| Термин | Жизненная аналогия | Коротко |
|---|---|---|
Сервис (@Injectable) | Общая кофеварка на этаже: одна на всех, наливают из неё | Класс, где хранятся данные и логика, общие для всех компонентов |
| DI (внедрение зависимостей) | Кто-то приносит тебе готовый инструмент, не надо собирать самому | Angular сам подставляет нужный сервис туда, где он запрошен |
| Локальное состояние | Липкий листок у тебя на столе — видишь только ты | Данные одного компонента, не видны остальным |
| Серверное состояние | Доска объявлений в холле — её видят все | Данные, пришедшие с сервера и общие для приложения |
| Task | Готовый билет: есть номер (id) и галочка (completed) | Полноценная задача с id и полем completed |
| TaskDraft | Черновик анкеты без номера | Данные формы до создания задачи: ещё нет id и completed |
| Компонент | Деталь конструктора / запчасть машины | Часть интерфейса, которая показывает данные и реагирует на действия |
1. Сервис с providedIn: 'root' создаётся отдельно для каждого компонента. Верно или неверно?
Ответ: Неверно. При 'root' Angular создаёт один экземпляр на всё приложение — как одна общая кофеварка на этаже, из которой наливают все.
2. Dependency Injection — это когда компонент сам пишет new TaskService() внутри себя. Верно или неверно?
Ответ: Неверно. Это было бы «без DI». При DI кто-то (Angular) приносит тебе уже готовый инструмент, а компонент лишь говорит inject(TaskService).
3. TaskDraft уже содержит id и completed. Верно или неверно?
Ответ: Неверно. TaskDraft — это черновик анкеты без номера: в нём только то, что ввёл пользователь. Номер и статус completed добавляет сервис, превращая черновик в готовый билет Task.