Студенческий материал. Его можно использовать как самостоятельный конспект, раздаточный материал и справочник после занятия.
В уроке 6 TaskService держал задачи в сигнале, заполняемом из константы. Теперь данные приходят из настоящего (учебного) сервера. Список загружается по GET, создание — по POST, отметка — по PATCH, удаление — по DELETE. Клиент — HttpClient из Angular.
Компоненты (сигналы, шаблон)
│
TaskService (HttpClient + сигналы tasks/loading/error)
│ HTTP
▼
mock API (json-server) на порту 3000
К концу урока TaskFlow работает с сетью: загрузка показывает индикатор, пустой список — заглушку, сбой — сообщение об ошибке.
Зафиксируем точку старта.
| Умеем (урок 6) | Добавим (урок 7) |
|---|---|
| TaskService с сигналом tasks | сигнал наполняется из API |
| add / toggle / remove в памяти | методы шлют запросы и обновляют кэш |
| computed-производные | остаются, но зависят от загруженных данных |
| нет состояния загрузки | появляются loading / error / empty |
| параллель с fetch (урок 2) | Observable/HttpClient — родной инструмент Angular |
Модель Task не изменится: контракт данных тот же. Меняется только то, откуда сервис берёт задачи.
Angular-приложение — это клиент, работающий в браузере. Оно общается с сервером через HTTP по адресу API. В TaskFlow «сервер» на время курса — локальный mock API.
Браузер (Angular) ── HTTP ──► json-server (localhost:3000)
◄── JSON ──
Запросы бывают разные по действию:
GET /tasks — получить список.POST /tasks — создать задачу.PATCH /tasks/3 — обновить поля задачи 3.DELETE /tasks/3 — удалить задачу 3.Mock API — временный сервер, имитирующий настоящий backend. Он отдаёт заранее заготовленные данные и умеет обрабатывать CRUD-запросы. Для курса используем библиотеку json-server.
# запуск (в папке проекта)
npx json-server db.json --port 3000
Файл db.json описывает данные:
{
"tasks": [
{ "id": 1, "title": "Выучить HttpClient", "completed": false, "priority": "high" },
{ "id": 2, "title": "Подключить loading", "completed": false, "priority": "medium" }
]
}
json-server по этому описанию сам предоставляет REST-адреса: /tasks, /tasks/1 и т.д. Имена коллекций берутся из ключей JSON.
Каждый ключ верхнего уровня — коллекция (таблица). Каждый элемент — ресурс с полем id.
db.json
├── tasks: [ { id, title, completed, priority }, ... ]
└── (позже: users, tags, ...)
json-server автоматически генерирует адреса:
| Метод | URL | Описание |
|---|---|---|
| GET | /tasks | список задач |
| GET | /tasks/2 | одна задача |
| POST | /tasks | создать (тело JSON) |
| PATCH | /tasks/2 | обновить часть полей |
| PUT | /tasks/2 | заменить целиком |
| DELETE | /tasks/2 | удалить |
HttpClient отправляет HTTP-запрос и получает ответ, который состоит из кода состояния и тела.
2xx — успех: 200 OK, 201 Created, 204 No Content.4xx — ошибка клиента: 400 Bad Request, 404 Not Found.5xx — ошибка сервера: 500 Internal Server Error.Пользовательский сценарий:
- загрузить задачи → GET 200 → массив
- неверный id → GET /tasks/999 → 404
- сервер упал → 500 → показать ошибку
HttpClient — сервис Angular для HTTP-запросов. Он внедряется через DI, как любой другой сервис.
inject(). Шаг 3 — теперь в this.http живёт наш «курьер».import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class TaskService {
private readonly http = inject(HttpClient);
}
В отличие от fetch (урок 2), HttpClient возвращает Observable, а не Promise. Поэтому отвечает «потоком», который отдаёт результат подписчику, когда приходит ответ.
.json() — всё встроено.JSON.parse для ответа, сам бросает ошибку при статусах 4xx/5xx и возвращает Observable вместо Promise. Вам не нужны await и .json(). А provideHttpClient регистрирует всю HTTP-подсистему в DI — без неё inject(HttpClient) упадёт с «No provider for HttpClient».Чтобы HttpClient был доступен, в корне приложения добавляют провайдер.
provideHttpClient() — как открыть дверь офиса курьерской службы. Пока дверь закрыта, курьер (HttpClient) не может войти и работать.provideHttpClient. Шаг 2 — кладём его в список providers при запуске приложения. Шаг 3 — теперь inject(HttpClient) сработает.// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { App } from './app/app';
bootstrapApplication(App, {
providers: [provideHttpClient()],
});
Без этого вызова inject(HttpClient) упадёт с «No provider for HttpClient».
provideHttpClient — современная функция-провайдер. Она «включает» HTTP-подсистему в standalone-приложении.Внутри сервиса получаем HttpClient тем же способом, что и другие зависимости.
base — адрес склада, http — сам курьер. Метод loadTasks() — задание «сходи на склад и принеси задачи».inject(HttpClient) даёт курьера. Шаг 2 — base хранит адрес сервера. Шаг 3 — loadTasks() шлёт GET по адресу base + '/tasks'.@Injectable({ providedIn: 'root' })
export class HttpService {
private readonly http = inject(HttpClient);
private readonly base = 'http://localhost:3000';
loadTasks() {
return this.http.get<Task[]>(`${this.base}/tasks`);
}
}
Метод get принимает URL и возвращает Observable, типизированный через дженерик <Task[]>.
Самый простой запрос — получение списка.
subscribe — как нажать кнопку «позвонить курьеру». Пока не нажали — заказ не отправлен. Курьер поехал только когда вы позвонили.http.get создаёт задание. Шаг 2 — .subscribe(...) реально отправляет запрос. Шаг 3 — когда пришёл ответ, колбэк кладёт данные в сигнал tasks.readonly tasks = signal<Task[]>([]);
load() {
this.http.get<Task[]>('http://localhost:3000/tasks')
.subscribe(tasks => this.tasks.set(tasks));
}
Пока мы только «подписались» на результат. Когда json-server ответит, колбэк положит данные в сигнал, а шаблон перерисуется — это реактивность урока 5.
Разберём по шагам, что происходит в каждой строке:
http.get<Task[]>(url) — создаёт Observable, который «умеет» отправить GET..subscribe(tasks => ...) — подписывается: реально отправляет запрос.Task[].this.tasks.set(tasks) — кладёт данные в сигнал; шаблон обновляется.subscribe(). Это ключевое отличие от Promise, который стартует сразу.Observable — объект, который со временем может выдать ноль, одно или несколько значений. HTTP-ответ выдаёт одно значение (тело ответа), затем завершается.
http.get(url) ── [данные] ──(завершение)
│ subscribe
▼
колбэк с данными
В уроке 2 вы работали с fetch и Promise. Сравним подходы.
await, во втором — через subscribe.| Аспект | Promise (fetch) | Observable (HttpClient) |
|---|---|---|
| Начало работы | сразу | при subscribe |
| Отмена | сложная | unsubscribe |
| Значений | одно | ноль или больше |
| Преобразование | then | pipe(map) |
| Ошибки | catch | catchError + error-колбэк |
// fetch (урок 2)
const tasks = await fetch('/tasks').then(r => r.json());
// HttpClient (урок 7)
this.http.get<Task[]>('/tasks').subscribe(t => ...);
Обе модели общаются с одним сервером и дают один результат. Angular предпочитает Observable, потому что они встроены в реактивную систему.
Перед подпиской результат можно преобразовать через pipe с операторами.
pipe(map(...)) — как конвейер на заводе: сначала деталь проходит через одну машину (фильтр), потом через другую (счётчик). Каждый map — отдельная «машина».map оставляет только невыполненные. Шаг 2 — второй map считает их. Шаг 3 — в subscribe попадает уже готовое число.activeCount окажется, например, «3 активные задачи».this.http.get<Task[]>(url)
.pipe(
map(tasks => tasks.filter(t => !t.completed)), // сколько активных
)
.subscribe(tasks => this.activeTasks.set(tasks));
map — как Array.map, но для потока: получает значение, возвращает новое. pipe собирает цепочку операторов в порядке слева направо.
Операторов в цепочке может быть несколько — они выполняются по очереди над значением потока.
this.http.get<Task[]>(url).pipe(
map(tasks => tasks.filter(t => !t.completed)), // сначала фильтр
map(active => active.length), // затем посчитать
)
.subscribe(count => this.activeCount.set(count));
Сначала берём только активные, затем считаем их количество. Каждый map получает результат предыдущего — как звенья одной цепи.
pipe(map(f), map(g)) ≈ .then(x => f(x)).then(y => g(y)) в Promise. Просто другой синтаксис той же идеи «преобразуй значение по цепочке».HttpClient умеет объявлять тип ответа через дженерик. Компилятор проверит, как вы используете полученные данные.
<Task[]> говорит TypeScript: «жди массив задач», и подсказки в редакторе работают.this.http.get<Task[]>(url) // массив задач
this.http.get<Task>(`${url}/2`) // одна задача
this.http.post<Task>(url, draft) // ответ — созданная задача
Если ответ не соответствует типу, ошибка будет только в рантайме (это не валидация данных, а подсказка типов). Для строгой проверки на время курса достаточно типизации из урока 2.
// объявим тип ответа явно
interface TaskResponse {
tasks: Task[];
total: number;
}
this.http.get<TaskResponse>(url);
Финальный шаг GET — положить данные в сигнал, который читают компоненты.
subscribe({ next, error }) — как два окошка в двери: в одно приносят посылку (next), в другое — извещение об отказе (error).readonly tasks = signal<Task[]>([]);
load(): void {
this.http.get<Task[]>(this.apiUrl + '/tasks')
.subscribe({
next: t => this.tasks.set(t),
error: e => console.error('Не удалось загрузить', e),
});
}
Передаём объект с колбэками: next — данные, error — ошибка. Так мы готовимся к состояниям, которые разберём далее.
Создание отправляет черновик в теле запроса и получает созданную задачу с id.
http.post шлёт черновик. Шаг 2 — сервер присваивает id. Шаг 3 — в next мы добавляем готовую задачу в конец списка через update.add(draft: TaskDraft): void {
this.http.post<Task>(this.apiUrl + '/tasks', draft)
.subscribe({
next: created => this.tasks.update(xs => [...xs, created]),
error: e => this.handleError(e),
});
}
После успеха добавляем созданную задачу в сигнал неизменяемо (spread, урок 5). id присваивает json-server.
fetch с method:'POST' и JSON-телом. В Angular то же самое делает http.post<Task>(...) (раздел 16).Тoggle меняет одно поле. Отправляем объект с этим полем.
toggle(id: number, completed: boolean): void {
this.http.patch<Task>(`${this.apiUrl}/tasks/${id}`, { completed })
.subscribe({
next: updated => this.tasks.update(xs =>
xs.map(t => t.id === id ? updated : t)),
error: e => this.handleError(e),
});
}
В URL подставляем id задачи, в теле — только изменяемое поле. Полученную задачу кладём на место старой.
Удаление отправляет DELETE по адресу задачи.
http.delete шлёт запрос по адресу с id. Шаг 2 — сервер удаляет. Шаг 3 — в next мы фильтруем список, убирая задачу с этим id.remove(id: number): void {
// DELETE удаляет ресурс; тело ответа пустое — тип void
this.http.delete<void>(`${this.apiUrl}/tasks/${id}`)
.subscribe({
next: () => this.tasks.update(xs => xs.filter(t => t.id !== id)), // убираем из списка
error: e => this.handleError(e),
});
}
Ответ DELETE обычно пуст (204), поэтому тип <void>. После успеха убираем задачу из сигнала фильтрацией.
<void> — читается как «мне не нужны данные ответа».При работе с сетью состояние не сводится к данным. Есть фазы:
loading → data (или empty) → error
В сервисе заведём сигналы состояния.
loading.set(true), loadError.set(null). Шаг 2 — отправляем GET. Шаг 3 — в next кладём задачи и гасим loading; в error пишем сообщение и тоже гасим loading.readonly loading = signal(false);
readonly loadError = signal<string | null>(null);
load(): void {
this.loading.set(true);
this.loadError.set(null);
this.http.get<Task[]>(url).subscribe({
next: t => { this.tasks.set(t); this.loading.set(false); },
error: e => { this.loadError.set('Не удалось загрузить'); this.loading.set(false); },
});
}
Обратите внимание: «пусто» — это не отдельный сигнал. Пустота выводится из данных: если tasks пуст и нет ошибки — список пуст. Так мы не держим три конфликтующих флага.
computed — как вывод на калькуляторе: вы не набираете «пусто» вручную, оно само получается из формулы «не загружаемся И нет ошибки И задач 0».// пустота = производное состояние
readonly isEmpty = computed(() => !this.loading() && !this.loadError() && this.tasks().length === 0);
tasks().length === 0) → данные. «Пустота» не хранится отдельным флагом, она выводится из сигнала задач, поэтому состояний ровно три, а не четыре конфликтующих.HttpClient через ng serve): показывает loading → success → error при работе с публичным API.RxJS позволяет перехватить ошибку прямо в потоке оператором catchError.
pipe срабатывает catchError. Шаг 2 — мы записываем понятное сообщение в loadError. Шаг 3 — throwError прокидывает ошибку дальше в error-колбэк, чтобы там погасить loading.import { catchError, throwError } from 'rxjs';
load(): void {
this.http.get<Task[]>(url).pipe( // создаём поток GET
catchError(err => { // ловим сбой внутри pipe
console.error(err);
this.loadError.set(this.messageFrom(err)); // показываем сообщение
return throwError(() => err); // пробрасываем дальше в error-колбэк
}),
).subscribe({ // подписка запускает запрос
next: t => { this.tasks.set(t); },
error: e => this.loading.set(false), // завершаем UI-состояние
});
}
catchError перехватывает сбой, делает побочное действие и возвращает новый поток. throwError создаёт поток, который «упадёт» — так ошибка дойдёт до error-колбэка subscribe.
У subscribe есть три колбэка — по числу исходов потока:
.subscribe({
next: d => { /* успех: есть данные */ },
error: e => { /* сбой: показать сообщение */ },
complete: () => { /* поток завершился (необязательно) */ },
});
next — обычный результат.error — ошибка; поток на этом завершается.complete — успешное завершение без ошибки (опционально).Для единичных HTTP-ответов чаще всего хватает next и error.
catch (в Angular — catchError). Запустите и посмотрите, как ловится сбой.Чтобы сообщение было точным, полезно смотреть на статус ошибки. HttpErrorResponse хранит его в поле status.
status — как ответ продавца в магазине: «такого товара нет» (404), «не тот отдел» (400), «склад закрыт — приходите позже» (500).function messageFrom(err: any): string {
if (err.status === 404) return 'Задача не найдена';
if (err.status >= 500) return 'Сервер недоступен, попробуйте позже';
return 'Произошла ошибка запроса';
}
| Статус | Смысл | Сообщение |
|---|---|---|
| 400 | неверный запрос | проверьте данные |
| 401/403 | нет доступа | нужна авторизация |
| 404 | не найдено | ресурс отсутствует |
| 500 | ошибка сервера | повторите позже |
Интерцептор — функция, через которую проходят все HTTP-запросы и ответы. Удобно для логов, единых заголовков, обработки ошибок.
@Injectable()
export class LoggingInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<unknown>, next: HttpHandler) {
console.log(req.method, req.url);
return next.handle(req);
}
}
// включить в провайдере
provideHttpClient(
withInterceptors([(req, next) => {
console.log(req.method, req.url);
return next.handle(req);
}]),
)
Адрес API лучше выносить в конфигурацию, а не зашивать в каждый запрос. Используем InjectionToken из урока 6.
export const API_URL = new InjectionToken<string>('API_URL');
// в провайдерах
providers: [
provideHttpClient(),
{ provide: API_URL, useValue: 'http://localhost:3000' },
]
// в сервисе
private readonly apiUrl = inject(API_URL);
Теперь сменить адрес на прод можно в одном месте.
Быстрая смена источника данных: константа → HTTP-запрос.
// было (урок 6): сигнал с константой
private readonly tasks = signal<Task[]>(initialTasks);
// стало (урок 7): сигнал наполняется из API
readonly tasks = signal<Task[]>([]);
load(): void { ... this.tasks.set(...) }
Компоненты не заметят изменений: они по-прежнему читают s.tasksRO(). Меняется только «внутренности» сервиса.
Что именно меняется внутри:
initialTasks — пустой массив + вызов load().Модель Task остаётся контрактом между сервером и клиентом.
export interface Task {
readonly id: number;
title: string;
completed: boolean;
priority: Priority;
}
Поля должны совпадать с тем, что отдаёт json-server. Если сервер даёт, например, done вместо completed, стоит выровнять модель или преобразовать ответ.
map перекладывает одно в другое..pipe(map(task => ({ ...task, completed: task.done })))
Отображение «формы сервера → формы клиента» называют адаптацией ответа. Оно полезно, когда:
.pipe(map(raw => raw.map(t => ({ id: t.id, title: t.title, completed: t.done, priority: t.priority }))))
Несколько быстрых запросов могут «перегнать» друг друга. Например, два load подряд: ответ может прийти в обратном порядке.
// плохо: две подписки могут перезаписать данные
load();
// лучше: сбрасывать насыщение loading в начале каждого
load(): void {
this.loading.set(true);
// ...
}
Для простых CRUD-операций сервис обычно выполняет один запрос за раз. При росте сложности используют отмену (раздел 28.17) или стабилизацию.
Создаём базу и запускаем mock API.
# db.json в корне проекта
{
"tasks": [
{ "id": 1, "title": "Настроить HTTP", "completed": false, "priority": "high" },
{ "id": 2, "title": "Загрузить список", "completed": true, "priority": "medium" },
{ "id": 3, "title": "Обработать ошибку", "completed": false, "priority": "low" }
]
}
# запуск
npx json-server db.json --port 3000
Откройте http://localhost:3000/tasks в браузере — увидите JSON со списком.
/tasks/1 покажет одну задачу, POST-запрос в /tasks создаст новую.Включаем HTTP в корне приложения и внедряем его в сервис.
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { App } from './app/app';
bootstrapApplication(App, {
providers: [provideHttpClient()],
});
// task.service.ts
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class TaskService {
private readonly http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000';
}
После этого все методы сервиса могут делать HTTP-запросы. Ниже — детальная разборка каждого действия.
src/app/services/tasks.service.ts с сервисом из раздела 28.6 и подключите HTTP в src/main.ts через provideHttpClient() (раздел 8). HttpClient внедряется одной строкой: private readonly http = inject(HttpClient);Полностью типизированная загрузка.
readonly tasks = signal<Task[]>([]);
readonly loading = signal(false);
readonly loadError = signal<string | null>(null);
load(): void {
this.loading.set(true); // начинаем: показываем индикатор
this.loadError.set(null); // сбрасываем старую ошибку
this.http.get<Task[]>(this.apiUrl + '/tasks').pipe( // GET-запрос к API
catchError(err => { // перехват сбоя в потоке
this.loadError.set('Не удалось загрузить задачи');
return throwError(() => err); // пробрасываем дальше в error
}),
).subscribe({ // подписка реально отправляет запрос
next: t => { this.tasks.set(t); this.loading.set(false); }, // успех: данные + выключить loading
error: () => this.loading.set(false), // сбой: тоже выключить loading
});
}
Дженерик <Task[]> гарантирует, что в колбэке next мы работаем с массивом задач.
Создание задачи с отправкой черновика.
add(draft: TaskDraft): void {
const { title, priority } = draft;
if (!title.trim()) return; // guard: не шлём пустой запрос
// POST создаёт ресурс; тело — те поля, что ждёт сервер
this.http.post<Task>(this.apiUrl + '/tasks', { title, completed: false, priority })
.subscribe({
next: created => this.tasks.update(xs => [...xs, created]), // id приходит из ответа
error: e => this.handleError(e), // централизованная обработка ошибки
});
}
Сервер присвоит id. Полученную задачу добавляем в конец списка.
Детали, на которые стоит обратить внимание:
title, completed, priority.<Task> содержит созданную задачу уже с id — её и добавляем.Отмечаем задачу выполненной/активной.
toggle(id: number): void {
const task = this.tasks().find(t => t.id === id);
if (!task) return; // guard: нет задачи — не шлём запрос
// PATCH меняет только переданное поле completed
this.http.patch<Task>(`${this.apiUrl}/tasks/${id}`, { completed: !task.completed })
.subscribe({
next: updated => this.tasks.update(xs => // заменяем элемент ответом сервера
xs.map(t => t.id === id ? updated : t)),
error: e => this.handleError(e),
});
}
Находим текущее значение, отправляем противоположное, заменяем в списке ответом сервера.
Шаги операции:
completed.Удаление задачи.
remove(id: number): void {
// DELETE удаляет ресурс; тело ответа пустое — тип void
this.http.delete<void>(`${this.apiUrl}/tasks/${id}`)
.subscribe({
next: () => this.tasks.update(xs => xs.filter(t => t.id !== id)), // убираем из списка
error: e => this.handleError(e),
});
}
После серверного удаления убираем id из сигнала. Если сервер вернул ошибку, задача останется — UI не рассинхронизируется с данными.
Пояснение к типам и ожиданиям:
204 No Content — поэтому тип <void>.Вынесем вспомогательный обработчик ошибок, чтобы не дублировать.
private handleError(err: HttpErrorResponse): void {
this.loading.set(false);
if (err.status >= 500) {
this.loadError.set('Сервер недоступен');
} else if (err.status === 404) {
this.loadError.set('Задача не найдена');
} else {
this.loadError.set(`Ошибка запроса (${err.status || 'сеть'})`);
}
}
Единая точка превращает любой сбой в понятное пользователю сообщение.
Обратите внимание на порядок проверки:
status >= 500 — серверная ошибка, повторять бессмысленно сейчас.status === 404 — ресурс не найден.status === 0 означает «нет сети/сервер не запущен».Если несколько запросов должны вызывать один и тот же «переключатель загрузки», удобнее не ставить loading.set(false) в каждом колбэке, а воспользоваться finalize (раздел 28.16).
Собираем все кусочки в один сервис.
@Injectable({ providedIn: 'root' })
export class TaskService {
private readonly http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3000';
readonly tasks = signal<Task[]>([]);
readonly loading = signal(false);
readonly loadError = signal<string | null>(null);
private readonly filter = signal<TaskFilter>('all');
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();
});
load(): void { ... }
add(draft: TaskDraft): void { ... }
toggle(id: number): void { ... }
remove(id: number): void { ... }
setFilter(f: TaskFilter): void { this.filter.set(f); }
}
Это и есть TaskService с сетью: хранилище, производные и транспорт в одном месте (дальше можно разделить — раздел 28.13).
Компонент-представление ничего не знает о сети.
@Component({
selector: 'app-task-list',
standalone: true,
template: `
@if (s.loading()) {
<div class="loading">Загрузка…</div>
} @else if (s.loadError()) {
<div class="error">{{ s.loadError() }}</div>
} @else if (s.tasks().length === 0) {
<div class="empty">Задач пока нет</div>
} @else {
@for (task of s.visibleTasks(); track task.id) {
<app-task-item [task]="task" />
}
}
`,
})
export class TaskList {
readonly s = inject(TaskService);
ngOnInit() {
this.s.load();
}
}
Запрос load запускаем один раз при создании компонента — например, в ngOnInit.
import { Component, OnInit, inject } from '@angular/core';
export class TaskList implements OnInit {
ngOnInit() {
this.s.load();
}
}
ngOnInit срабатывает после привязки входных свойств — подходящее место для первичной загрузки.
Почему именно здесь:
input()) уже заполнены.Полный переключатель состояний с кнопкой повтора.
@if (s.loading()) {
<div class="state loading">Загрузка задач…</div>
} @else if (s.loadError()) {
<div class="state error">
<p>{{ s.loadError() }}</p>
<button (click)="s.load()">Повторить</button>
</div>
} @else if (s.tasks().length === 0) {
<div class="state empty">Пока нет задач. Добавьте первую!</div>
} @else {
<app-task-list />
}
Кнопка «Повторить» перезапускает load — удобный приём для сетевых ошибок.
Разберём порядок проверки в шаблоне:
Порядок @else-if задаёт приоритет: загрузка и ошибка важнее пустоты. Так пользователь видит именно актуальное состояние.
Вкладка Network браузера показывает каждый HTTP-запрос: метод, URL, статус, время, тело.
На что смотреть в каждой строке Network:
| Поле | Что говорит |
|---|---|
| Name | фактический URL запроса |
| Method | GET/POST/PATCH/DELETE |
| Status | код ответа (200, 404, 500, 0 — сеть) |
| Payload | тело запроса (для POST/PATCH) |
| Response | тело ответа (JSON) |
| Time | сколько занял запрос |
Пройдём сценарий пользователя от начала до конца.
1. load() → GET /tasks → список на экране
2. add(draft) → POST /tasks → новая в списке
3. toggle(id) → PATCH /tasks/:id → отмечена
4. remove(id) → DELETE /tasks/:id → исчезла
Проверьте в Network, что каждый шаг соответствует своему HTTP-методу и URL.
Связь сценариев пользователя (урок 1, раздел 34) и HTTP-операций:
| Сценарий | HTTP | Сигнал |
|---|---|---|
| Увидеть список | GET /tasks | tasks.set |
| Создать задачу | POST /tasks | добавить в конец |
| Отметить выполненной | PATCH /tasks/:id | заменить элемент |
| Удалить задачу | DELETE /tasks/:id | отфильтровать |
Холодный Observable создаёт новую «работу» для каждой подписки; горячий — один поток для всех.
// HTTP-запрос: холодный
const obs = this.http.get<Task[]>(url);
obs.subscribe(); // запрос 1
obs.subscribe(); // запрос 2 (ещё один GET)
Два subscribe сделали два GET-запроса. Для одиночного HTTP-ответа это нормально. Для общего потока данных иногда нужен shareReplay, чтобы не дублировать подписки.
Чтобы разделить транспорт и хранилище, выделим отдельный сервис-клиент.
@Injectable({ providedIn: 'root' })
export class TaskApi {
private readonly http = inject(HttpClient);
private readonly apiUrl = inject(API_URL);
list(): Observable<Task[]> {
return this.http.get<Task[]>(this.apiUrl + '/tasks');
}
create(draft): Observable<Task> {
return this.http.post<Task>(this.apiUrl + '/tasks', draft);
}
update(id, patch): Observable<Task> {
return this.http.patch<Task>(`${this.apiUrl}/tasks/${id}`, patch);
}
delete(id): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/tasks/${id}`);
}
}
TaskService внедряет TaskApi и превращает Observable в сигналы. Компоненты по-прежнему видят только сигналы.
Иногда запросу нужны дополнительные параметры: заголовки, query-параметры.
import { HttpHeaders, HttpParams } from '@angular/common/http';
const options = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
params: new HttpParams().set('_sort', 'priority'),
};
this.http.get<Task[]>(url, options);
Заголовки описывают запрос (формат, токен), query-параметры — фильтры сервера. json-server понимает _sort, _limit, q и др.
Разберём, как catchError сочетается с error-колбэком.
this.http.get<Task[]>(url).pipe(
catchError(err => {
this.loadError.set(this.messageFrom(err));
return throwError(() => err); // продолжить как ошибку
}),
).subscribe({
next: d => this.tasks.set(d),
error: e => this.loading.set(false), // также сработает после catchError
});
catchError выполняет побочное действие (ставит сообщение), а throwError «прокидывает» ошибку в error-колбэк, где завершаем UI-состояние.
Действие «в любом случае» (успех или ошибка) удобно положить в finalize.
import { catchError, finalize, throwError } from 'rxjs';
load(): void {
this.loading.set(true);
this.http.get<Task[]>(url).pipe(
catchError(err => { this.loadError.set(...); return throwError(() => err); }),
finalize(() => this.loading.set(false)),
).subscribe({
next: d => this.tasks.set(d),
});
}
finalize срабатывает при завершении потока в любом исходе — удобно для выключения loading в одном месте.
loading.set(false) в next и error достаточно одного finalize. Меньше дублирования — меньше ошибок.Если компонент уничтожен, подписка на Observable всё ещё жива. Хорошая практика — отписываться.
private sub?: Subscription;
ngOnInit() { this.sub = this.s.load$().subscribe(...); }
ngOnDestroy() { this.sub?.unsubscribe(); }
Для сервисных запросов, которые живут на уровне приложения, отписка нужна реже. Но внутри компонента — важно, чтобы не проводить работу после уничтожения.
Сведём наблюдения: тот же запрос двумя способами.
// fetch (урок 2) — асинхронный/ожидающий
async function loadTasks() {
const res = await fetch('/tasks');
if (!res.ok) throw new Error(res.status);
return res.json();
}
// HttpClient — потоковый
loadTasks() {
return this.http.get<Task[]>('/tasks');
}
| Сценарий | fetch/Promise | HttpClient/Observable |
|---|---|---|
| Простой запрос | await + res.json() | get().subscribe |
| Проверка статуса | res.ok / res.status | ошибка при 4xx/5xx сама |
| Типизация | вручную | generic get<Task[]> |
HttpClient «встроен» в Angular и интегрирован с реактивностью. fetch — универсальный и тоже валидный выбор. Понимание обоих — цель курса.
Соберём полную схему нашего приложения после урока 7.
Шаблон (computed, @if/@for)
│ читает сигналы
Компоненты
│ inject
TaskService (хранилище: tasks/loading/error, computed)
│ вызывает методы API
TaskApi (транспорт: HttpClient, URL, JSON)
│ HTTP
json-server :3000
Компоненты не знают о сети. TaskService не знает об URL-деталях. TaskApi не знает о шаблоне. Каждый слой отвечает за своё.
Когда json-server не запущен или недоступен, браузер не получает никакого HTTP-кода — соединение просто не устанавливается.
GET http://localhost:3000/tasks → (failed) net::ERR_CONNECTION_REFUSED
HttpClient при этом даёт ошибку с status = 0. Именно это значение отличает «нет связи» от «сервер вернул ошибку».
if (err.status === 0) {
this.loadError.set('Сервер не отвечает. Проверьте, запущен ли json-server');
}
404 «не найдено» бывает по двум причинам:
GET /tasks → 200 (список)
GET /task → 404 (описка — нет коллекции task)
GET /tasks/999 → 404 (нет такой задачи)
Проверьте имя коллекции в db.json и точность URL в коде. В Network видно, по какому фактическому адресу ушёл запрос.
db.json содержит ключ tasks.Чтобы увидеть состояние loading в действии, добавьте задержку на ответ сервера.
// json-server: задержка 1000 мс на все ответы
npx json-server db.json --port 3000 --delay 1000
С такими настройками spinner «Загрузка…» станет заметным, и вы убедитесь, что блоки @if переключаются верно.
@if (s.loading()) {
<div class="state loading">Загрузка…</div>
}
json-server поддерживает параметры «на стороне сервера»: сортировку, лимит, страницы.
GET /tasks?_page=1&_limit=10 // первая страница по 10
GET /tasks?_sort=priority&_order=desc
GET /tasks?completed=true // фильтр по полю
GET /tasks?q=keyword // поиск по строкам
const params = new HttpParams()
.set('_sort', 'priority')
.set('_order', 'desc');
this.http.get<Task[]>(url, { params });
В TaskFlow фильтрацию по статусу мы пока делаем локально через computed (урок 6). Серверные query-параметры пригодятся для больших наборов.
CORS (Cross-Origin Resource Sharing) — политика браузера, ограничивающая запросы на другой домен/порт.
Angular (localhost:4200) → API (localhost:3000) // другой порт = другой origin
Браузер блокирует ответ, если сервер не разрешает origin. json-server по умолчанию разрешает и добавляет заголовки CORS — поэтому локально всё работает.
Access-Control-Allow-Origin. В реальном проекте это настройка backend; на курсе json-server делает это сам.Переносим TaskFlow на HTTP без риска сломать интерфейс.
Если несколько компонентов подписываются на один GET, каждый subscribe сделает новый запрос. Чтобы делить один ответ, используют shareReplay.
import { shareReplay, Observable } from 'rxjs';
private readonly cache$?: Observable<Task[]>;
list(): Observable<Task[]> {
if (!this.cache$) {
this.cache$ = this.http.get<Task[]>(url).pipe(
shareReplay(1),
);
}
return this.cache$;
}
Первый subscribe запускает запрос; остальные получают тот же результат, не обращаясь к сети повторно.
Вместо дублирования catchError в каждом методе можно обработать ошибку централизованно через интерцептор.
export function apiErrorsInterceptor(req: HttpRequest<unknown>, next: HttpHandler) {
return next.handle(req).pipe(
catchError((err) => {
const msg = err.status === 0
? 'Нет связи с сервером'
: `Ошибка ${err.status}`;
console.error(msg);
return throwError(() => err);
}),
);
}
bootstrapApplication(App, {
providers: [provideHttpClient(withInterceptors([apiErrorsInterceptor]))],
});
Теперь любая ошибка HTTP-запроса проходит через наш код, но поток по-прежнему падает, и каждая подписка может обработать её по-своему.
Реальные API часто требуют заголовок авторизации: токен, который показывает, кто вы.
const headers = new HttpHeaders({
'Authorization': 'Bearer ' + token,
});
this.http.get<Task[]>(url, { headers });
Добавлять его в каждый запрос вручную утомительно — удобней через интерцептор, читающий токен из сервиса.
Иногда API отдаёт не массив, а объект-обёртку с метаданными.
{
"items": [ ... ],
"total": 120,
"page": 1
}
interface Page<T> {
items: T[];
total: number;
page: number;
}
this.http.get<Page<Task>>(url).pipe(
map(page => page.items),
).subscribe(items => this.tasks.set(items));
Дженерик помогает описать любую форму ответа. Оператор map достаёт нужную часть перед записью в сигнал.
Сетевые запросы могут «теряться». Если результат неизменен при повторении (идемпотентная операция), повторная попытка безопасна.
import { retry } from 'rxjs';
this.http.get<Task[]>(url).pipe(
retry(1), // одна повторная попытка при сбое
);
GET безопасно повторить. POST/PATCH/DELETE повторять рискованно — можно создать дубликат. Поэтому сначала учимся повторять только чтения.
Покажем, как бы выглядел тот же TaskService на fetch, чтобы увидеть разницу (подробно — React-урок 18).
// fetch-версия (Promise)
async loadTasks() {
const res = await fetch('/tasks');
if (!res.ok) throw new Error('HTTP ' + res.status);
this.tasks.set(await res.json());
}
// HttpClient-версия (Observable)
loadTasks() {
return this.http.get<Task[]>('/tasks')
.subscribe(t => this.tasks.set(t));
}
Обе решают одну задачу. Разница в интеграции: Observable вписан в реактивную систему Angular, Promise — универсальный стандарт JS.
В TaskFlow фильтрация «все/активные/выполненные» остаётся локальной — computed в сервисе. Почему?
Серверный фильтр (query-параметры) имел бы смысл для тысяч задач, когда скачивать всё дорого. Пока — локально.
Соберём типовую связку двух сервисов, глядя на неё целиком.
// task-api.ts — транспорт
@Injectable({ providedIn: 'root' })
export class TaskApi {
private readonly http = inject(HttpClient);
private readonly url = inject(API_URL) + '/tasks';
list() { return this.http.get<Task[]>(this.url); }
create(d) { return this.http.post<Task>(this.url, d) }
patch(id, p) { return this.http.patch<Task>(`${this.url}/${id}`, p); }
delete(id) { return this.http.delete<void>(`${this.url}/${id}`); }
}
// task.service.ts — хранилище
@Injectable({ providedIn: 'root' })
export class TaskService {
private readonly api = inject(TaskApi);
readonly tasks = signal<Task[]>([]);
readonly loading = signal(false);
readonly loadError = signal<string | null>(null);
load() {
this.loading.set(true);
this.api.list().pipe(
catchError(e => { this.loadError.set(this.msg(e)); return throwError(() => e); }),
finalize(() => this.loading.set(false)),
).subscribe(t => this.tasks.set(t));
}
// add / toggle / remove — аналогично через this.api
}
Компоненты видят только TaskService и его сигналы. Эта структура станет эталоном и для других курсовых сценариев.
Восемь заданий на HTTP и клиента API. Сначала сделайте сами, затем сверьтесь с подсказками.
Создайте db.json с тремя задачами и запустите json-server на порту 3000. Откройте /tasks в браузере.
Добавьте provideHttpClient в bootstrap и внедрите HttpClient в TaskService.
Реализуйте load() с GET и типизацией Task[], сохраняя результат в сигнал.
Реализуйте add() через POST, добавьте созданную задачу в список.
Реализуйте toggle() (PATCH) и remove() (DELETE).
Добавьте сигналы loading и loadError; покажите их в шаблоне с ветками @if.
Остановите json-server и нажмите Повторить — убедитесь, что показывается сообщение об ошибке.
Пройдите CRUD-цикл и сверьте метод и URL каждого запроса во вкладке Network.
Совет: сначала сформулируйте идею, затем смотрите код.
npx json-server db.json --port 3000. В браузере откройте http://localhost:3000/tasks.bootstrapApplication добавьте providers: [provideHttpClient()]; в сервисе private readonly http = inject(HttpClient).this.http.get<Task[]>(apiUrl + '/tasks').subscribe(t => this.tasks.set(t)).this.tasks.update(xs => [...xs, created]).loading.set(true); в next/error — false. В шаблоне ветки @if загружается/пусто/ошибка/список.err.status равен 0 — сообщение «сервер не отвечает».Соберём типичные проблемы и их решения.
| Симптом | Причина | Решение |
|---|---|---|
| «No provider for HttpClient» | нет provideHttpClient | добавить в провайдеры bootstrap |
| Запрос не уходит | нет subscribe (Observable ленивый) | подписаться на поток |
| 404 на /tasks | сервер не запущен или другой порт | проверить Network и запуск json-server |
| Данные не попали в шаблон | сигнал не записан после ответа | вызвать set/update в next |
| Ошибка в консоли | ошибка не обработана | добавить catchError и error-колбэк |
| loading не выключается | set(false) не во всех ветках | использовать finalize |
| Тип ответа неверный | модель не совпадает с JSON | выровнять Task с данными сервера |
Пять правил работы с HTTP в Angular.
provideHttpClient, клиент получают через inject(HttpClient).subscribe.get<Task[]>, post<Task>.Дополнительные наблюдения из урока:
Ответьте своими словами, затем сверьтесь с конспектом.
| Термин | Определение |
|---|---|
| HttpClient | Сервис Angular для HTTP-запросов, возвращает Observable. |
| provideHttpClient | Функция-провайдер, включающая HTTP-подсистему. |
| Observable | Поток значений во времени; HTTP-ответ — одно значение. |
| subscribe | Начало подписки; «запускает» выполнение потока. |
| pipe / map | Преобразование значений в цепочке потока. |
| catchError | Оператор перехвата ошибки в потоке. |
| throwError | Создание потока, который «упадёт» с ошибкой. |
| finalize | Действие при завершении потока в любом исходе. |
| CRUD | Create, Read, Update, Delete — четыре операции с данными. |
| REST | Стиль API: ресурс + метод (GET/POST/PATCH/DELETE). |
| json-server | Mock API на основе JSON-файла (db.json). |
| HttpErrorResponse | Объект ошибки с полем status и телом ответа. |
| Интерцептор | Точка расширения на пути всех HTTP-запросов. |
| API_URL (InjectionToken) | Токен для адреса API, отделяющий конфигурацию. |
| Холодный Observable | Запускается заново на каждую подписку (HTTP). |
| shareReplay | Оператор для разделения одного результата между подписками. |
| finalize | Действие при завершении потока в любом исходе. |
| CORS | Политика браузера, ограничивающая запросы между origin. |
| HttpParams | Объект для query-параметров запроса. |
| HttpHeaders | Объект для заголовков запроса. |
| HTTP-статусы | Код ответа: 2xx успех, 4xx ошибка клиента, 5xx сервер. |
Для самостоятельного углубления:
Рекомендуемый порядок повторения:
Подключите сервис к mock API через HttpClient и обработайте состояния UI.
getAll() { return this.http.get<Task[]>(this.base); }
create(d) { return this.http.post<Task>(this.base, d); }
update(t) { return this.http.patch<Task>(`${this.base}/${t.id}`, t); }
remove(id) { return this.http.delete<void>(`${this.base}/${id}`); }
return this.http.get<Task[]>(this.base).pipe(
catchError(err => { console.error(err); return of<Task[]>([]); })
);
| Термин | Жизненная аналогия | Коротко |
|---|---|---|
| HTTP-запрос | Заказ в магазине | Ты послал заявку на сервер и ждёшь либо посылку с данными, либо отказ. |
| HTTP-статус | Ответ продавца | 200 — «есть», 404 — «такого нет», 500 — «на складе авария». Код говорит, чем закончился заказ. |
| fetch / HttpClient | Курьер | Сам едет на склад (сервер), забирает данные и привозит их тебе. Ты никуда не ходишь. |
| catchError | Сетка безопасности | Ловит падение запроса, чтобы программа не разбилась, и возвращает аккуратный результат. |
| JSON | Этикетка на посылке | Пишет «поле: значение» парами, чтобы браузер понял, что лежит внутри ответа. |
| loading / empty / error | Три состояния посылки | В пути (крутится спиннер) / приехала пустой / потерялась (ошибка). |
| signal | Группа в мессенджере | Поменял значение — и все подписчики сразу получили сообщение и обновили экран. |
1. HTTP-статус 404 означает, что сервер нашёл нужные данные. Верно или неверно?
Ответ: Неверно. 404 — это как ответ продавца «такого товара нет». Нашёл бы — вернул бы 200 («вот, забирайте»).
2. catchError похож на сетку безопасности: он ловит ошибку запроса, чтобы приложение не упало. Верно или неверно?
Ответ: Верно. Как сетка под трапецией в цирке: акробат (запрос) упал, но зритель видит аккуратный финал, а не катастрофу.
3. HttpClient в Angular работает как курьер: сам ходит на сервер и привозит данные, а ты остаёшься на месте. Верно или неверно?
Ответ: Верно. Ты даёшь ему адрес и задание, а он едет на склад и привозит посылку — ты никуда не бегаешь.