MakeFirstStep· Frontend + ИИ

Урок 16 · Async и API

Состояния загрузки, ошибки, debounce

Вы уже умеете получать данные с настоящего сервера. Но на реальном сайте интернет бывает медленным, сервер — недоступным, а пользователь — нетерпеливым: он печатает в строке поиска быстрее, чем приходят ответы. Сегодня мы научим приложение вести себя достойно в любой из этих ситуаций: показывать загрузку, объяснять ошибку, не заваливать сервер запросами и отменять ненужное.

  • Различать четыре состояния экрана: загрузка, ошибка, пусто, данные — и рисовать каждое
  • Проверять response.ok и не путать «сервер ответил ошибкой» с «сети нет»
  • Делать skeleton — серые заглушки на месте будущего контента — на чистом CSS
  • Писать debounce с нуля и объяснять, как он работает через замыкание и таймер
  • Отменять запрос через AbortController и не показывать устаревшие ответы
  • Читать и проверять правки, которые ИИ-агент (Cursor) вносит сразу в несколько файлов
Как работать с этой страницей

Код на тёмном фоне с кнопкой ▶ Запустить — живой: меняйте его и смотрите на результат в консоли. В этом уроке «сервер» в примерах нарочно медленный и сделан прямо в коде (через Promise и setTimeout), поэтому результат всегда одинаковый и не зависит от вашего интернета; после запуска подождите секунду — строки в консоли появляются по очереди. Примеры с настоящим API тоже есть, но проверяемые задачи работают без сети. Где есть окошко с превью — это мини-страница: слева код, справа результат. Подсказки спрятаны под замочком 🔒: сначала попробуйте сами.

01 Четыре состояния экрана

Представьте, что вы открыли приложение такси и нажали «Заказать». Что вы видите? Крутилку «ищем машину». Если машин рядом нет — сообщение «Свободных машин нет, попробуйте позже». Если пропал интернет — «Нет соединения» и кнопка «Повторить». И только если всё хорошо — карточку водителя. Один и тот же экран, четыре разных картинки — в зависимости от того, что сейчас происходит с данными.

Новички обычно рисуют только последнюю картинку — «данные пришли». А остальные три забывают. В итоге при медленном интернете пользователь смотрит на пустую страницу и думает, что сайт сломан. Профессионал всегда продумывает все четыре:

loadingзапрос ушёл, ответа ещё неткрутилка или skeleton
errorчто-то пошло не такпонятный текст + «Повторить»
emptyответ пришёл, но он пустой«Ничего не найдено»
successответ пришёл, данные естьсписок, карточки, таблица
Четыре состояния экрана, который показывает данные с сервера. Плюс «idle» — самое начало, когда ещё ничего не запрашивали
Аналогия из жизни

Вы пришли в чойхону и заказали курутоб. Хороший официант не стоит молча: «Готовим, минут десять» (loading); «Курутоб закончился» (error — заказать нельзя); «К сожалению, в меню такого блюда нет» (empty — искали, но не нашли); и наконец — приносит тарелку (success). Плохой официант просто исчезает, и вы сидите и гадаете: заказ принят или нет?

Где хранить состояние

Из урока про состояние (урок 11) вы знаете главное правило: есть данные — есть функция, которая по ним рисует экран. Здесь так же. Заводим один объект state с тремя полями и одну функцию view (или render), которая решает, что показывать.

Состояние и функция, которая по нему рисует (текстом) · Ctrl + Enter
// status: "idle" | "loading" | "success" | "error"
function view(state) {
  if (state.status === "idle") return "Введите название блюда";
  if (state.status === "loading") return "Загрузка…";
  if (state.status === "error") return `Ошибка: ${state.error}`;
  // дальше status === "success"
  if (state.data.length === 0) return "Ничего не найдено";
  return `Нашли: ${state.data.join(", ")}`;
}

console.log(view({ status: "idle", data: [], error: null }));
console.log(view({ status: "loading", data: [], error: null }));
console.log(view({ status: "error", data: [], error: "Нет сети" }));
console.log(view({ status: "success", data: [], error: null }));
console.log(view({ status: "success", data: ["Курутоб"], error: null }));

Обратите внимание: «empty» — это не отдельный статус сервера, а частный случай успеха: запрос прошёл, но список оказался пустым. Хотите — выделяйте его отдельным статусом "empty" (так будет удобнее в разметке), хотите — проверяйте data.length. Главное, чтобы пользователь увидел разный текст.

Порядок проверок важен

Самая частая ошибка — проверять «пусто?» первой. Пока данные грузятся, список тоже пустой, и пользователь видит «Ничего не найдено» вместо «Загрузка…». Сначала спрашиваем «что случилось с запросом?» (loading, error), и только потом — «что внутри ответа?» (пусто или нет).

Пустой список первым

Во время загрузки — «Ничего не найдено». Пользователь думает, что блюда нет.

Можно редактировать · Ctrl + Enter
function view(state) {
  if (state.data.length === 0) return "Ничего не найдено";
  if (state.status === "loading") return "Загрузка…";
  return state.data.join(", ");
}
console.log(view({ status: "loading", data: [] }));
// → Ничего не найдено 😕
Сначала статус

Сначала loading и error, потом разбираемся с содержимым.

Можно редактировать · Ctrl + Enter
function view(state) {
  if (state.status === "loading") return "Загрузка…";
  if (state.data.length === 0) return "Ничего не найдено";
  return state.data.join(", ");
}
console.log(view({ status: "loading", data: [] }));
// → Загрузка… ✓
Частая ошибка

Вместо четырёх состояний — одно булево isLoading и «потом посмотрим». Тогда легко получить невозможные комбинации: isLoading: true и одновременно error: "Нет сети". Один status с понятными значениями такого не допускает: в любой момент он равен ровно одному из них.

Переходы между состояниями

Состояния сменяют друг друга по простому сценарию. Запомните его — он будет в каждом приложении, которое вы напишете:

  1. Пользователь что-то сделал (открыл страницу, нажал «Найти») → ставим status: "loading" и перерисовываем экран.
  2. Сервер ответил → ставим status: "success" (или "empty") и кладём данные.
  3. Что-то сломалось → ставим status: "error" и кладём понятный текст ошибки.
  4. Пользователь нажал «Повторить» → снова шаг 1.
Совет

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

Читать подробнее: MDN: aria-busy — как сообщить о загрузке вспомогательным технологиям · learn.javascript.ru: Fetch

Практика: закрепляем

Пока без сети и без таймеров: учимся мыслить состояниями.

ЗАДАЧА 1.1

Что покажет экран

лёгкая

Предскажите вывод, не запуская код: что напечатает label для каждого из четырёх состояний? Впишите строки в prediction и запустите. Обратите внимание на второе состояние: статус «success», но данных нет.

Можно редактировать · Ctrl + Enter
const states = [
  { status: "loading" },
  { status: "success", data: [] },
  { status: "success", data: ["Курутоб", "Нон"] },
  { status: "error", error: "Нет сети" },
];
function label(s) {
  if (s.status === "loading") return "Загрузка…";
  if (s.status === "error") return `Ошибка: ${s.error}`;
  if (s.data.length === 0) return "Ничего не найдено";
  return `Найдено: ${s.data.length}`;
}
const prediction = ["", "", "", ""];   // ваш прогноз: четыре подписи по порядку
const actual = states.map(label);
console.log(actual.join("|") === prediction.join("|") ? "Верно!" : "Не угадали, подумайте ещё");
Ожидаемый результат
Верно!
Подсказка 1

Идите по состояниям сверху вниз и для каждого находите первую сработавшую проверку в label.

Подсказка 2

У первого состояния нет поля data, но до проверки s.data.length дело не доходит: функция уже вышла на первом return.

ЗАДАЧА 1.2

Текст для каждого состояния

лёгкая

Напишите функцию view(state), которая возвращает текст для экрана поиска блюд: для idle — Введите запрос; для loading — Загрузка…; для error — Ошибка: и текст ошибки; если данных нет — Ничего не найдено по запросу «…» (с запросом из state.query); иначе — Блюда: и названия через запятую.

Можно редактировать · Ctrl + Enter
// function view(state) { ... }

console.log(view({ status: "idle", query: "", data: [], error: null }));
console.log(view({ status: "loading", query: "нон", data: [], error: null }));
console.log(view({ status: "error", query: "нон", data: [], error: "Сервер не отвечает" }));
console.log(view({ status: "success", query: "борщ", data: [], error: null }));
console.log(view({ status: "success", query: "н", data: ["Курутоб", "Нон"], error: null }));
Ожидаемый результат
Введите запрос
Загрузка…
Ошибка: Сервер не отвечает
Ничего не найдено по запросу «борщ»
Блюда: Курутоб, Нон
Подсказка 1

Цепочка if с ранним return (урок 5): сначала idle, loading, error, и только потом проверка длины state.data.

Подсказка 2

Названия через запятую с пробелом: state.data.join(", "). Запрос в кавычках-ёлочках вставляется шаблонной строкой: `... «${state.query}»`.

ЗАДАЧА 1.3

Найди баг: «Ничего не найдено» вместо загрузки

средняя

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

Можно редактировать · Ctrl + Enter
function view(state) {
  if (state.data.length === 0) return "Ничего не найдено";
  if (state.status === "loading") return "Загрузка…";
  if (state.status === "error") return `Ошибка: ${state.error}`;
  return state.data.join(", ");
}

console.log(view({ status: "loading", data: [], error: null }));
console.log(view({ status: "error", data: [], error: "Нет сети" }));
console.log(view({ status: "success", data: [], error: null }));
console.log(view({ status: "success", data: ["Нон"], error: null }));
Ожидаемый результат
Загрузка…
Ошибка: Нет сети
Ничего не найдено
Нон
Подсказка 1

Какая проверка срабатывает для loading, если список пуст? Первая же — и функция выходит.

Подсказка 2

Перенесите строку с data.length === 0 на третье место: после loading и error.

ЗАДАЧА 1.4

Переходы между состояниями

средняя

Напишите функцию next(state, event): она получает текущее состояние и событие и возвращает новое состояние (старый объект не меняем). События: start → status: "loading", error: null, прежние данные остаются; done → status: "success", в data — event.data, ошибки нет; fail → status: "error", в error — event.error, прежние данные остаются.

Можно редактировать · Ctrl + Enter
// function next(state, event) { ... }

let s = { status: "idle", data: [], error: null };
const events = [
  { type: "start" },
  { type: "done", data: ["Нон", "Курутоб"] },
  { type: "start" },
  { type: "fail", error: "Нет сети" },
];
for (const e of events) {
  s = next(s, e);
  console.log(e.type, "→", s.status, s.data.length, s.error);
}
Ожидаемый результат
start → loading 0 null
done → success 2 null
start → loading 2 null
fail → error 2 Нет сети
Подсказка 1

Три if по event.type, в каждом — return нового объекта. Чтобы сохранить прежние данные, скопируйте старое состояние: { ...state, status: "loading" }.

Подсказка 2

Для done копировать старое состояние не нужно — все три поля задаются заново: { status: "success", data: event.data, error: null }.

02 Запрос, ошибки и «медленный сервер»

Теперь подключаем реальную загрузку. Из прошлых уроков вы знаете схему: async-функция, await fetch(...), try / catch. Добавим к ней состояния: до запроса ставим loading, после — success или error, а finally нам тут почти не нужен: каждое состояние ставится явно.

Настоящий API: dummyjson.com (нужен интернет) · Ctrl + Enter
async function searchProducts(query) {
  console.log("loading");
  try {
    const res = await fetch(`https://dummyjson.com/products/search?q=${query}&limit=3`);
    const data = await res.json();
    console.log(`success: ${data.products.length} из ${data.total}`);
  } catch (err) {
    console.log(`error: ${err.message}`);
  }
}

searchProducts("phone");

Две разные ошибки — и fetch различает их не так, как вы думаете

Это ловушка, в которую попадает почти каждый новичок. У запроса бывает два разных вида неудачи:

Сети нет
Интернет пропал, сервер не найден, запрос не дошёл. Тогда fetch отклоняет промис — управление уходит в catch.
Сервер ответил «нет»
Запрос дошёл, но сервер вернул код 404 («не найдено») или 500 («у нас всё сломалось»). Для fetch это успех: ответ получен! Промис выполняется, в catch мы не попадаем.

Поэтому после fetch всегда проверяйте свойство res.ok — оно равно true для кодов 200–299. А если нет — бросайте ошибку сами:

Настоящий API: товара 999999 нет → код 404 (нужен интернет) · Ctrl + Enter
async function loadProduct(id) {
  try {
    const res = await fetch(`https://dummyjson.com/products/${id}`);
    console.log("status:", res.status, "ok:", res.ok);
    if (!res.ok) throw new Error(`Сервер ответил кодом ${res.status}`);
    const product = await res.json();
    console.log("success:", product.title);
  } catch (err) {
    console.log("error:", err.message);
  }
}

loadProduct(999999);

Попробуйте заменить 999999 на 1: код станет 200, и вы увидите название товара.

Аналогия из жизни

Вы отправили курьера в магазин на Зелёном базаре. Если дорогу перекрыли и курьер не дошёл — это «сети нет» (catch). А если курьер дошёл и вернулся со словами «этого товара нет в продаже» — он вернулся успешно, но результат плохой. Именно так fetch и думает: курьер вернулся — значит, успех. Проверять, что именно он принёс, должны вы.

«Медленный сервер» в нашем коде

Настоящий API отвечает то быстро, то медленно, то не отвечает вовсе. Чтобы уроки и задачи были честными и одинаковыми у всех, мы сделаем себе «ненастоящий» сервер — функцию fakeSearch. Она ищет блюдо в небольшом массиве, отвечает через заданное время и умеет ломаться по запросу «ошибка». Так делают и профессионалы: пока настоящего сервера нет (или он нестабилен), интерфейс проверяют на заглушке.

Разберём заглушку

Читать её целиком не обязательно, но есть три важных места. setTimeout имитирует задержку сети (ms миллисекунд). Если запрос пустили с названием "ошибка" — промис отклоняется, как при обрыве сети. Параметр signal нужен для отмены — о нём в разделе 5; пока его можно не замечать. Во всех примерах ниже блок «сервер» один и тот же — просто копируйте его целиком.

Наш медленный «сервер» · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

fakeSearch("самбуса").then((dishes) => console.log("Нашли:", dishes.join(", ")));
fakeSearch("борщ").then((dishes) => console.log("Нашли по «борщ»:", dishes.length));
fakeSearch("ошибка").catch((err) => console.log("Сбой:", err.message));

Полный цикл: loading → success / empty / error

Склеим всё в одну функцию. Каждая строка состояния — это одна «картинка» экрана. Следите за порядком в консоли:

Три исхода одного запроса · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

async function load(query) {
  console.log("loading…");
  try {
    const items = await fakeSearch(query);
    if (items.length === 0) {
      console.log("empty: ничего не найдено");
    } else {
      console.log(`success: ${items.length} шт.`);
    }
  } catch (err) {
    console.log(`error: ${err.message}`);
  }
}

async function main() {
  await load("курутоб");
  await load("борщ");
  await load("ошибка");
}
main();
Ловушка

Бросить всё в один catch и написать пользователю err.message как есть — плохая идея. Тексты вроде Failed to fetch или Unexpected token < in JSON пугают и ничего не объясняют. Показывайте человеческую фразу («Не удалось загрузить меню. Проверьте интернет и повторите»), а «технический» текст оставьте для console.error — он нужен вам, а не пользователю.

Повтор при сбое

Раз ошибка — частое дело, пользователю нужна кнопка «Повторить». В коде это просто ещё один вызов той же load. А иногда повтор делают автоматически — например, до трёх попыток подряд. Мы потренируем это в задаче 2.4.

Читать подробнее: learn.javascript.ru: Fetch · MDN: Response.ok

Практика: закрепляем

Во всех задачах ниже блок «сервер» уже вставлен — не меняйте его.

ЗАДАЧА 2.1

Первый запрос к заглушке

лёгкая

Допишите async-функцию main: дождитесь ответа fakeSearch("самбуса") и выведите названия найденных блюд через запятую с пробелом.

Можно редактировать · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

async function main() {
  // await fakeSearch(...) и console.log
}
main();
Ожидаемый результат
Самбуса с мясом, Самбуса с тыквой
Подсказка 1

const dishes = await fakeSearch("самбуса"); — await разрешён внутри async-функции.

Подсказка 2

dishes — массив строк. Склеить его в одну строку через запятую поможет join(", ").

ЗАДАЧА 2.2

Функция load с тремя исходами

средняя

Напишите async function load(query). Она сначала выводит loading; затем запрашивает блюда; если нашлось хотя бы одно — выводит success: N (N — сколько нашли); если список пуст — empty; если запрос упал — error: и текст ошибки. Вызовы в main уже готовы.

Можно редактировать · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

// async function load(query) { ... }

async function main() {
  await load("курутоб");
  await load("ошибка");
  await load("борщ");
}
main();
Ожидаемый результат
loading
success: 1
loading
error: Нет соединения с сервером
loading
empty
Подсказка 1

Каркас: console.log("loading"); try { const items = await fakeSearch(query); ... } catch (err) { ... }.

Подсказка 2

Внутри try проверьте items.length === 0 — это empty, иначе success. Текст ошибки лежит в err.message.

ЗАДАЧА 2.3

Сервер ответил ошибкой: проверяем ok

средняя

Функция fakeHttp(status) ведёт себя как настоящий fetch: всегда возвращает ответ с полями ok и status, даже если статус 404 или 500. Напишите async function getJson(status): получите ответ; если res.ok ложно — бросьте Error с текстом HTTP 404 (с настоящим кодом); иначе верните результат res.json(). В main результат каждого вызова оформляется в консоли.

Можно редактировать · Ctrl + Enter
const fakeHttp = (status) =>
  Promise.resolve({
    ok: status >= 200 && status < 300,
    status,
    json: () => Promise.resolve({ dishes: 3 }),
  });

// async function getJson(status) { ... }

async function main() {
  for (const status of [200, 404, 500]) {
    try {
      const data = await getJson(status);
      console.log(`${status} → ${data.dishes} блюда`);
    } catch (err) {
      console.log(`${status} → ошибка: ${err.message}`);
    }
  }
}
main();
Ожидаемый результат
200 → 3 блюда
404 → ошибка: HTTP 404
500 → ошибка: HTTP 500
Подсказка 1

Без проверки ok строки с 404 и 500 покажут «3 блюда»: для «fetch» это успех.

Подсказка 2

if (!res.ok) throw new Error(`HTTP ${res.status}`); — и после этой проверки можно спокойно возвращать res.json().

ЗАДАЧА 2.4

Три попытки

сложная

Сеть в маршрутке нестабильна. flakySearch первые два раза падает с ошибкой Нет сети, а на третий раз работает. Напишите async function searchWithRetry(maxAttempts): она пробует запрос в цикле; при ошибке выводит попытка N: ошибка (текст) и идёт на следующий круг; при успехе выводит попытка N: успех, найдено K и возвращает результат. Если все попытки исчерпаны — выводит сдаёмся и возвращает null.

Можно редактировать · Ctrl + Enter
let calls = 0;
function flakySearch() {
  calls++;
  return new Promise((resolve, reject) =>
    setTimeout(() => (calls < 3 ? reject(new Error("Нет сети")) : resolve(["Курутоб"])), 100)
  );
}

// async function searchWithRetry(maxAttempts) { ... }

searchWithRetry(4);
Ожидаемый результат
попытка 1: ошибка (Нет сети)
попытка 2: ошибка (Нет сети)
попытка 3: успех, найдено 1
Подсказка 1

Цикл for (let attempt = 1; attempt <= maxAttempts; attempt++), внутри него — try / catch. Успех — return прямо из try, он сразу завершает функцию и цикл.

Подсказка 2

В catch только выводим строку и ничего не возвращаем — цикл сам перейдёт к следующей попытке. Строка «сдаёмся» и return null стоят после цикла: сюда доходим, только если ни одна попытка не удалась.

03 Skeleton: серые заглушки вместо пустоты

Самое простое состояние загрузки — надпись «Загрузка…» или крутилка. Но у неё есть недостаток: страница «прыгает». Сначала на экране маленькая надпись, потом вдруг появляется длинный список, и всё содержимое съезжает вниз. Решение — skeleton («скелет»): на месте будущих карточек показываем серые прямоугольники такого же размера. Пользователь заранее видит, как будет выглядеть страница, а когда приходят данные — заглушки просто заменяются настоящими карточками на тех же местах.

Аналогия из жизни

Вы пришли в чойхону на свадьбу, а стол ещё не накрыт. Но скатерть уже расстелена, а на ней расставлены пустые тарелки — по числу гостей. Вы сразу видите, где будете сидеть и сколько мест. Потом официанты просто кладут еду на готовые тарелки — стол не переставляют. Skeleton — это те самые пустые тарелки.

Skeleton на чистом CSS

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

CSS для skeleton
.skeleton {
  /* светло-серый фон с более светлой полосой посередине */
  background: linear-gradient(90deg, #e6e9ef 25%, #f4f6fa 37%, #e6e9ef 63%);
  background-size: 400% 100%;      /* градиент в 4 раза шире блока */
  animation: shimmer 1.2s ease infinite;
  border-radius: 8px;
  color: transparent;              /* на случай, если внутри окажется текст */
}

@keyframes shimmer {
  0%   { background-position: 100% 0; }
  100% { background-position: 0 0; }
}

/* уважаем пользователей, которым неприятна анимация */
@media (prefers-reduced-motion: reduce) {
  .skeleton { animation: none; }
}
  1. Градиент шире блока. background-size: 400% делает фон в четыре раза шире самого блока — виден только его кусочек.
  2. Анимация двигает фон. @keyframes shimmer меняет background-position — «светлая полоса» медленно проезжает по блоку.
  3. Размеры задаёт карточка. Заглушка должна быть той же высоты и ширины, что и настоящая карточка, — иначе страница всё равно «подпрыгнет».
  4. Не забудьте про доступность. На контейнер ставим aria-busy="true" пока идёт загрузка и "false" — когда закончилась: читалки с экрана поймут, что содержимое обновляется.

Посмотрите на готовый пример. Нажмите «Запустить» — сначала вы увидите мерцающие серые карточки, а через 0,8 секунды — настоящие блюда.

Skeleton → карточки · Ctrl + Enter
<div id="list" class="list" aria-busy="true"></div>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Когда skeleton, а когда крутилка

Skeleton хорош, когда вы знаете, как будет выглядеть результат: список карточек, таблица, профиль. Крутилка (спиннер) или короткое «Загрузка…» — когда результат неопределённый или маленький (например, кнопка «Сохранить»). Пользуясь skeleton, показывайте столько заглушек, сколько карточек обычно помещается на экране, — не больше.

Skeleton — это не единственное «красивое» состояние

Остальные состояния тоже стоит оформить, а не оставлять «голый» текст:

  • Пусто (empty): короткая фраза и подсказка, что делать: «Ничего не найдено. Попробуйте другое название».
  • Ошибка (error): понятная причина и кнопка «Повторить». Красный цвет — не обязательно, главное — чтобы было ясно, что можно сделать дальше.
  • Начало (idle): приглашение к действию: «Введите название блюда».
Частая ошибка

Забыть убрать заглушки. Если при ответе сервера вы добавляете карточки через append, а не заменяете содержимое через replaceChildren, серые блоки остаются на странице, а настоящие карточки появляются под ними. Всегда заменяйте контент целиком.

Читать подробнее: MDN: использование CSS-анимаций · MDN: prefers-reduced-motion

Практика: закрепляем

В каждой задаче работает мини-страница: код слева (вкладки index.html, style.css, script.js), результат справа, проверочные строки — в консоли. CSS для skeleton уже готов.

ЗАДАЧА 3.1

Три заглушки

лёгкая

Допишите функцию showSkeleton(count): она заменяет содержимое #list на count блоков <div class="card skeleton"></div> и ставит на #list атрибут aria-busy="true". Проверочные строки внизу выведут число блоков, число заглушек и значение атрибута.

showSkeleton · Ctrl + Enter
<div id="list" class="list"></div>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
3
3
true
Подсказка 1

Обычный цикл for на count шагов: на каждом — document.createElement("div"), затем className = "card skeleton" и list.append(...).

Подсказка 2

Чтобы в начале убрать старое, вызовите list.replaceChildren(). Атрибут ставится так: list.setAttribute("aria-busy", "true").

ЗАДАЧА 3.2

Заглушки заменяются карточками

средняя

Допишите showCards(items): она полностью заменяет содержимое #list карточками div.card (по одной на блюдо, название — в textContent) и ставит aria-busy="false". После вызова fakeSearch вы должны увидеть, что заглушек не осталось.

Skeleton → cards · Ctrl + Enter
<div id="list" class="list"></div>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
скелетонов: 3
скелетонов: 0, карточек: 2
false
Подсказка 1

Внутри showCards превратите массив названий в массив карточек через items.map(...) — так же, как showSkeleton делает со своими блоками.

Подсказка 2

Замена содержимого целиком — list.replaceChildren(...cards). Тогда заглушки исчезают сами, и вручную их удалять не нужно.

ЗАДАЧА 3.3

Четыре состояния в разметке

сложная

Допишите функцию render(state), которая рисует содержимое #box для любого состояния. Сначала она очищает #box и ставит box.dataset.state: это "loading", "error" или "success" — но если статус success, а данных нет, то "empty". Затем рисует: для loading — три блока div.skeleton; для error — <p class="error">Не удалось загрузить</p> и кнопку <button id="retry">Повторить</button>; для empty — <p class="empty">Ничего не найдено</p>; для success — по одному div.dish на каждый элемент data.

render(state) · Ctrl + Enter
<div id="box" class="box"></div>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
loading 3 false
error 2 true
empty 1 false
success 3 false
Подсказка 1

Сначала вычислите «вид» состояния: const kind = state.status === "success" && state.data.length === 0 ? "empty" : state.status; — и положите его в box.dataset.state.

Подсказка 2

Дальше — цепочка if / else if по kind. В каждой ветке создавайте элементы через createElement, задавайте className и textContent, а в конце — box.append(...). Кнопке нужен id = "retry".

04 Debounce: не запускать функцию на каждый чих

Строка поиска «на лету»: пользователь печатает «плов», а результаты меняются уже в процессе. Самое простое решение — слушать событие input и на каждую букву отправлять запрос. Но человек печатает быстро: «п», «пл», «пло», «плов» — это четыре запроса за секунду, из которых нужен только последний. Сервер нагружается, трафик тратится, а ответы приходят вперемешку (об этом — в разделе 5).

Решение называется debounce — «подавление дребезга»: запускаем функцию только тогда, когда пользователь перестал её дёргать на заданное время. Каждый новый вызов сбрасывает ожидание. Пока человек печатает — ничего не происходит; остановился на 300 мс — запрос уходит.

Аналогия из жизни

Маршрутка на остановке. Водитель не едет сразу, как только зашёл первый пассажир. Он ждёт: зашёл второй — ждёт ещё, зашёл третий — снова ждёт. И только когда несколько секунд никто не заходит, трогается. Каждый новый пассажир сбрасывает таймер ожидания. Debounce — это водитель, который тронется лишь после паузы.

ввод
ожидание
запрос
Серые метки — нажатия клавиш (п, пл, пло, плов). Жёлтая полоса — ожидание паузы. Зелёные метки — запросы: только после тишины и только с последним значением

Пишем debounce с нуля

Внутри всего три идеи, которые вы уже знаете: функция возвращает функцию, замыкание хранит таймер между вызовами, а clearTimeout отменяет предыдущее ожидание.

debounce целиком
function debounce(fn, wait) {
  let timer;                         // один таймер на все вызовы — живёт в замыкании
  return function (...args) {
    clearTimeout(timer);             // новый вызов: отменяем прошлое ожидание
    timer = setTimeout(() => {
      fn(...args);                   // пауза выдержана — запускаем настоящую функцию
    }, wait);
  };
}
  1. Мы получаем функцию fn и паузу wait и возвращаем новую функцию — её и будем вешать на событие.
  2. Переменная timer объявлена снаружи возвращаемой функции. Поэтому все вызовы видят одну и ту же переменную (замыкание, урок 5).
  3. При каждом вызове сначала clearTimeout(timer) убирает ожидание от прошлого вызова, потом setTimeout заводит новое.
  4. Сработает только последнее ожидание — то, которое никто не сбросил за wait миллисекунд.
  5. ...args собирает все аргументы в массив, а fn(...args) раскладывает обратно — так debounce подходит к функции с любым числом параметров.

Проверяем на «печатающем» человеке

Чтобы результат не зависел от вашей скорости печати, мы запланируем нажатия по расписанию через setTimeout. Расписание: «п» в 0 мс, «пл» в 40 мс, «пло» в 80 мс (быстро печатает), потом пауза и «плов» в 300 мс. Пауза debounce — 100 мс. В консоли видно, что реально сработало.

Debounce в действии · Ctrl + Enter
function debounce(fn, wait) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

const search = debounce((q) => console.log(`поиск: ${q}`), 100);

[[0, "п"], [40, "пл"], [80, "пло"], [300, "плов"]].forEach(([at, q]) => {
  setTimeout(() => {
    console.log(`ввод: ${q}`);
    search(q);
  }, at);
});

Разберите вывод. Четыре «нажатия» — но всего два запроса. Первые три нажатия шли с промежутками по 40 мс (меньше 100), поэтому каждое сбрасывало таймер; после третьего прошло 100 мс тишины — и сработал поиск «пло». Потом пауза до 300 мс — и «плов» ушёл отдельным запросом.

Частая ошибка

Объявить let timer внутри возвращаемой функции. Тогда каждый вызов получает свой собственный timer, а clearTimeout отменяет пустоту — ничего не сбрасывается, и сработают все вызовы, просто с задержкой. Переменная для таймера должна жить снаружи, в замыкании.

Какую паузу выбрать

ПаузаКак ощущается
100–200 мсПочти мгновенно, но при быстрой печати запросы всё равно уходят часто
300–500 мсЗолотая середина для поиска: обычно столько и берут
1000 мс и большеИнтерфейс кажется «зависшим» — пользователь не понимает, работает ли поиск
Debounce и throttle — не одно и то же

Debounce ждёт паузы и срабатывает один раз после серии вызовов (поиск, автосохранение). Throttle («дроссель») срабатывает не чаще раза в N мс во время серии (прокрутка страницы, изменение размера окна). В поиске нам нужен именно debounce.

Когда debounce не нужен

Для действий по кнопке («Найти», «Отправить») пауза не нужна: человек нажимает осознанно и один раз. Debounce — лекарство от потока событий: input, scroll, resize, mousemove.

Отменить отложенный вызов: cancel

Бывает, что нужно не просто отложить, а отменить ждущий вызов: пользователь очистил поле или ушёл со страницы. Функции в JavaScript — тоже объекты, им можно «приклеить» свойство. Добавим метод cancel:

JavaScript
function debounce(fn, wait) {
  let timer;
  function debounced(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  }
  debounced.cancel = () => clearTimeout(timer);   // метод у функции
  return debounced;
}

Читать подробнее: learn.javascript.ru: декораторы и переадресация вызова · MDN: clearTimeout

Практика: закрепляем

Расписание «нажатий» в каждой задаче уже написано — вам нужен только debounce. Каждый запуск занимает меньше секунды.

ЗАДАЧА 4.1

Поиск срабатывает на каждую букву

лёгкая

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

Можно редактировать · Ctrl + Enter
function debounce(fn, wait) {
  return function (arg) {
    let timer;
    clearTimeout(timer);
    timer = setTimeout(() => fn(arg), wait);
  };
}

const search = debounce((q) => console.log(`поиск: ${q}`), 100);

[[0, "п"], [40, "пл"], [80, "пло"]].forEach(([at, q]) => {
  setTimeout(() => {
    console.log(`ввод: ${q}`);
    search(q);
  }, at);
});
Ожидаемый результат
ввод: п
ввод: пл
ввод: пло
поиск: пло
Подсказка 1

Запустите как есть: поиск сработает три раза. Значит, clearTimeout ничего не отменяет. Почему? Посмотрите, где объявлена переменная timer.

Подсказка 2

Каждый вызов создаёт свою новую timer. Перенесите строку let timer; наверх — в функцию debounce, но перед return function.

ЗАДАЧА 4.2

Пишем debounce сами

средняя

Напишите функцию debounce(fn, wait) с нуля — без подглядывания в текст урока, если получится. Возвращаемая функция принимает один аргумент arg и передаёт его в fn после паузы wait. Расписание ниже: быстрая серия нажатий, пауза, потом ещё одно нажатие.

Можно редактировать · Ctrl + Enter
// function debounce(fn, wait) { ... }

const search = debounce((q) => console.log(`поиск: ${q}`), 100);

[[0, "п"], [40, "пл"], [80, "пло"], [300, "плов"]].forEach(([at, q]) => {
  setTimeout(() => {
    console.log(`ввод: ${q}`);
    search(q);
  }, at);
});
Ожидаемый результат
ввод: п
ввод: пл
ввод: пло
поиск: пло
ввод: плов
поиск: плов
Подсказка 1

Три шага: объявить переменную timer снаружи, вернуть функцию, а внутри неё сначала отменить прошлый таймер, затем завести новый.

Подсказка 2

return function (arg) { clearTimeout(timer); timer = setTimeout(() => fn(arg), wait); }; — переменная timer объявлена строкой выше, вне return.

ЗАДАЧА 4.3

Слишком короткая пауза

лёгкая

Человек печатает медленно: один символ раз в 150 мс. Сначала предскажите, сколько поисков сработает при паузе 200 мс. Запустите и проверьте. Затем поменяйте паузу так, чтобы поиск срабатывал после каждой буквы — то есть чтобы debounce фактически «не работал».

Можно редактировать · Ctrl + Enter
function debounce(fn, wait) {
  let timer;
  return function (arg) {
    clearTimeout(timer);
    timer = setTimeout(() => fn(arg), wait);
  };
}

const search = debounce((q) => console.log(`поиск: ${q}`), 200);

[[0, "п"], [150, "пл"], [300, "пло"]].forEach(([at, q]) => {
  setTimeout(() => {
    console.log(`ввод: ${q}`);
    search(q);
  }, at);
});
Ожидаемый результат после правки
ввод: п
поиск: п
ввод: пл
поиск: пл
ввод: пло
поиск: пло
Подсказка 1

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

Подсказка 2

Поставьте паузу заметно меньше 150 — например, 60. Тогда таймер срабатывает через 60 мс, до того как придёт следующая буква и сбросит его.

ЗАДАЧА 4.4

Debounce с несколькими аргументами

средняя

Поиск теперь принимает запрос и номер страницы: find(query, page). Текущий debounce передаёт дальше только первый аргумент. Перепишите его с ...args так, чтобы доходили все аргументы последнего вызова.

Можно редактировать · Ctrl + Enter
function debounce(fn, wait) {
  let timer;
  return function (arg) {
    clearTimeout(timer);
    timer = setTimeout(() => fn(arg), wait);
  };
}

const find = debounce((query, page) => console.log(`поиск: ${query}, страница ${page}`), 100);

find("п", 1);
setTimeout(() => find("пл", 1), 40);
setTimeout(() => find("плов", 2), 80);
Ожидаемый результат после правки
поиск: плов, страница 2
Подсказка 1

Сейчас функция объявлена как function (arg) — она принимает один параметр, поэтому page теряется (получится undefined).

Подсказка 2

Замените параметр на ...args, а в вызове fn(arg) напишите fn(...args).

ЗАДАЧА 4.5

Кнопка «отмена» у debounce

средняя

Допишите к debounce метод cancel, который отменяет ожидающий вызов. Расписание: два быстрых вызова, затем отмена, затем — спустя время — ещё один вызов. Сработать должен только последний.

Можно редактировать · Ctrl + Enter
function debounce(fn, wait) {
  let timer;
  function debounced(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  }
  // добавьте метод debounced.cancel
  return debounced;
}

const find = debounce((q) => console.log(`поиск: ${q}`), 100);

setTimeout(() => { console.log("вызов: п"); find("п"); }, 0);
setTimeout(() => { console.log("вызов: пл"); find("пл"); }, 40);
setTimeout(() => { console.log("отмена"); find.cancel(); }, 80);
setTimeout(() => { console.log("вызов: плов"); find("плов"); }, 300);
Ожидаемый результат
вызов: п
вызов: пл
отмена
вызов: плов
поиск: плов
Подсказка 1

Функция в JavaScript — объект, поэтому ей можно присвоить свойство: debounced.cancel = .... Присвоенное значение — стрелочная функция.

Подсказка 2

debounced.cancel = () => clearTimeout(timer); — она видит timer через замыкание, как и сама debounced.

05 AbortController: отмена запроса

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

Проблема 1. Лишняя работа

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

Проблема 2. Гонка запросов (race condition)

Пользователь набрал «с» — ушёл запрос №1. Через 50 мс дописал «са» — ушёл запрос №2. Сервер обрабатывает запросы по-разному долго: ответ на первый (его результатов много) пришёл через 400 мс, а на второй — через 100 мс. Что получится на экране?

Баг: устаревший ответ затирает новый · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

let shown = "";

function onType(query, ms) {
  fakeSearch(query, { ms }).then(() => {
    shown = query;                      // «рисуем» ответ
    console.log(`пришёл ответ на «${query}»`);
  });
}

onType("с", 400);                        // старый запрос — медленный
setTimeout(() => onType("са", 100), 50); // новый запрос — быстрый
setTimeout(() => console.log(`на экране: «${shown}»`), 600);

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

Аналогия из жизни

Вы заказали плов, через минуту передумали и позвонили: «Лучше самбусу». Если в чойхоне не отменят первый заказ, к вам придут оба курьера — причём плов, возможно, раньше или позже самбусы. Правильно — позвонить и отменить первый заказ. Именно это делает AbortController: он «звонит» запросу и говорит «стоп».

Как это работает: controller и signal

AbortController
«Пульт»: объект, у которого есть кнопка abort(). Создаётся так: new AbortController().
controller.signal
«Провод» от пульта: сигнал, который мы передаём запросу. Запрос слушает его и останавливается, когда нажали кнопку.
controller.abort()
Нажали кнопку — запрос отменён, его промис отклоняется с ошибкой по имени AbortError.
Отмена запроса: у «сервера» есть signal · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

const controller = new AbortController();

// запрос на 500 мс, к нему «подключен» пульт
fakeSearch("самбуса", { signal: controller.signal, ms: 500 })
  .then((items) => console.log("получили", items.length))
  .catch((err) => console.log("ошибка:", err.name));

// через 100 мс пользователь нажал «Отмена»
setTimeout(() => {
  console.log("нажали abort()");
  controller.abort();
}, 100);

Запрос, который должен был идти 500 мс, остановился через 100. Ответ не пришёл — вместо него сработал catch с err.name === "AbortError".

Настоящий fetch понимает signal

Наша заглушка устроена так же, как настоящий fetch: сигнал передаётся вторым аргументом, в объекте настроек. Если вызвать abort() сразу, запрос даже не начнёт скачивание:

Настоящий fetch + abort() · Ctrl + Enter
const controller = new AbortController();

const request = fetch("https://dummyjson.com/products?limit=100", {
  signal: controller.signal,
});

controller.abort();   // отменяем сразу

request.catch((err) => console.log("fetch отменён:", err.name));

Не терпит дольше — таймаут одной строкой

Нередко нужно не отменять вручную, а подождать не дольше N миллисекунд. Для этого есть готовая функция AbortSignal.timeout(мс): она сама нажмёт «abort» через заданное время. Ошибка при этом называется не AbortError, а TimeoutError — так вы отличите «отменили мы» от «не дождались».

Таймаут: ждём не больше 1 мс (нужен интернет) · Ctrl + Enter
fetch("https://dummyjson.com/products?limit=100", { signal: AbortSignal.timeout(1) })
  .then(() => console.log("успели"))
  .catch((err) => console.log("ошибка:", err.name));

Паттерн: «каждый новый запрос отменяет прошлый»

Именно так починить гонку из начала раздела. Храним контроллер в переменной снаружи функции; перед новым запросом отменяем старый и заводим новый контроллер.

Исправление: отменяем предыдущий запрос · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

let shown = "";
let controller = null;                     // контроллер последнего запроса

function onType(query, ms) {
  if (controller) controller.abort();      // 1. отменяем прошлый запрос (если он идёт)
  controller = new AbortController();      // 2. заводим новый «пульт»

  fakeSearch(query, { signal: controller.signal, ms })
    .then(() => {
      shown = query;
      console.log(`пришёл ответ на «${query}»`);
    })
    .catch((err) => {
      if (err.name !== "AbortError") console.log("ошибка:", err.message);   // 3.
    });
}

onType("с", 400);
setTimeout(() => onType("са", 100), 50);
setTimeout(() => console.log(`на экране: «${shown}»`), 600);
  1. Один «пульт» на один запрос. Контроллер одноразовый: после abort() он «сломан» навсегда, поэтому на каждый запрос заводим новый.
  2. Вызываем abort() на старом перед тем, как создать новый — в этот момент старый запрос ещё может быть в пути.
  3. В catch молча пропускаем AbortError. Отмена — это не ошибка сервера, и пугать пользователя красным текстом «Ошибка!» из-за того, что он дописал букву, нельзя.
Частая ошибка

Не обработать AbortError. Отменённый запрос отклоняет промис, и если в catch просто написано «показать ошибку» — на экране на долю секунды мигает «Ошибка: The user aborted a request». Всегда проверяйте err.name и для "AbortError" выходите молча.

Есть и второй способ

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

Читать подробнее: learn.javascript.ru: Fetch: прерывание запроса · MDN: AbortController

Практика: закрепляем

«Сервер» уже вставлен в каждую задачу. Он понимает signal и при отмене отклоняет промис с тем самым signal.reason.

ЗАДАЧА 5.1

Пользователь ушёл со страницы

лёгкая

Запрос идёт 500 мс, но через 100 мс пользователь уходит. Допишите отмену: внутри setTimeout после строки «пользователь ушёл» отмените запрос.

Можно редактировать · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

const controller = new AbortController();

fakeSearch("самбуса", { signal: controller.signal, ms: 500 })
  .then((r) => console.log("получили", r.length))
  .catch((e) => console.log("ошибка:", e.name));

setTimeout(() => {
  console.log("пользователь ушёл");
  // отмените запрос здесь
}, 100);
Ожидаемый результат
пользователь ушёл
ошибка: AbortError
Подсказка 1

Нужный контроллер уже создан — он называется controller и подключён к запросу через signal.

Подсказка 2

Одна строка: controller.abort();

ЗАДАЧА 5.2

Гонка запросов

средняя

Сначала предскажите, что выведет код ниже (посмотрите на раздел «Проблема 2»), потом запустите. Затем исправьте onType так, чтобы старый запрос отменялся, а ответ на него не появлялся. Ошибку отмены нужно молча пропустить.

Можно редактировать · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

let shown = "";

function onType(q, ms) {
  fakeSearch(q, { ms }).then(() => {
    shown = q;
    console.log(`пришёл ответ на «${q}»`);
  });
}

onType("с", 400);
setTimeout(() => onType("са", 100), 50);
setTimeout(() => console.log(`на экране: «${shown}»`), 600);
Ожидаемый результат после исправления
пришёл ответ на «са»
на экране: «са»
Подсказка 1

Снаружи функции заведите let controller = null;. В начале onType: если controller уже есть — вызовите у него abort(), затем создайте новый AbortController.

Подсказка 2

Передайте signal: controller.signal в настройки fakeSearch и добавьте к промису .catch((e) => { if (e.name !== "AbortError") console.log("ошибка:", e.message); }).

ЗАДАЧА 5.3

Ждать не дольше лимита

средняя

Напишите async function searchWithTimeout(query, limit, ms): она вызывает fakeSearch с сигналом AbortSignal.timeout(limit) и задержкой ms. Если ответ пришёл — возвращает строку Найдено: N. Если сработал таймаут (err.name === "TimeoutError") — Сервер отвечает слишком долго. Любая другая ошибка — Ошибка: текст.

Можно редактировать · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

// async function searchWithTimeout(query, limit, ms) { ... }

async function main() {
  console.log(await searchWithTimeout("самбуса", 500, 200));
  console.log(await searchWithTimeout("курутоб", 100, 400));
}
main();
Ожидаемый результат
Найдено: 2
Сервер отвечает слишком долго
Подсказка 1

Вторым аргументом fakeSearch передайте объект: { signal: AbortSignal.timeout(limit), ms }. Обращение к функции — внутри try.

Подсказка 2

В catch (err) сначала проверьте err.name === "TimeoutError" и верните нужную строку, иначе верните `Ошибка: ${err.message}`.

ЗАДАЧА 5.4

createSearcher: поиск, который сам себя отменяет

сложная

Напишите функцию createSearcher(), которая возвращает функцию search(query). Каждый новый вызов отменяет предыдущий запрос. Отменённый вызов возвращает null (а не бросает ошибку), успешный — массив блюд. Остальные ошибки пробрасываются дальше.

Можно редактировать · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

// function createSearcher() { ... }

const search = createSearcher();

const p1 = search("самбуса");
const p2 = new Promise((resolve) => setTimeout(() => resolve(search("курутоб")), 50));

Promise.all([p1, p2]).then(([a, b]) => {
  console.log("первый:", a === null ? "отменён" : a.join(", "));
  console.log("второй:", b === null ? "отменён" : b.join(", "));
});
Ожидаемый результат
первый: отменён
второй: Курутоб
Подсказка 1

Переменная controller живёт в замыкании — в createSearcher, до return. Возвращаемая search — async-функция: так проще использовать try / catch.

Подсказка 2

В search: отменить прошлый (если есть) → новый контроллер → try { return await fakeSearch(query, { signal: controller.signal }); } catch (e) { if (e.name === "AbortError") return null; throw e; }. Без await перед fakeSearch ошибка не попадёт в catch.

06 Что покажет преподаватель: Cursor

До сих пор ИИ в нашем курсе — это чат: вы пишете вопрос, получаете текст, вы сами копируете код и вставляете в файл. Сегодня преподаватель покажет следующий уровень — ИИ-агента внутри редактора кода. Это демонстрация на экране: ставить Cursor на свои компьютеры не нужно. Главное в этом разделе — не «вау, как быстро», а как этот результат проверять.

Правила курса, которые особенно важны сегодня

1. Сначала 15 минут пробуете сами. 2. Промпт — по формуле: контекст → задача → ограничения → формат ответа → проверка. 3. Не вставляйте в проект код, который не можете объяснить строка за строкой. 4. Агент пишет быстро и много — значит, и проверять приходится быстро и много. Ответственность за код всегда на вас, а не на агенте.

Чат и агент — в чём разница

ИИ-чат (ChatGPT, Claude, Gemini)ИИ-агент в редакторе (Cursor)
Что видитТолько то, что вы вставили в окноВесь проект: сам открывает и ищет нужные файлы
Что делаетОтвечает текстомПредлагает план и правит сразу несколько файлов; по разрешению может запускать команды
Кто вносит кодВы — копированиемАгент — вы просматриваете правки и принимаете или отклоняете
Главный рискНе проверили ответНажали «принять всё» и не прочитали правки в пяти файлах
Важно про интерфейс

Cursor — это редактор на основе VS Code с ИИ внутри, и он быстро развивается: названия режимов и кнопок меняются от версии к версии. Поэтому здесь мы описываем суть и порядок действий, а не «нажмите третью кнопку справа». На демонстрации смотрите на логику: что агент прочитал, что предложил, что изменил.

Сценарий демонстрации

Преподаватель берёт мини-приложение из прошлых уроков — поиск по API со списком и пагинацией — разложенное на несколько файлов: index.html, style.css, api.js (запросы), ui.js (рисование) и main.js (события). Задача — ровно та, что мы решаем сегодня руками: добавить состояния загрузки, пустоты и ошибки, debounce и отмену запросов.

  1. Сохраняем копию. Перед демо папку проекта копируют (Git мы изучим на уроке 19, пока — обычная копия). Если агент напортачит, всегда можно вернуться.
  2. Формулируем задачу по формуле. Промпт зачитывается вслух (он ниже) — каждый его кусок отвечает за свою часть формулы.
  3. Смотрим, что агент читает. Агент не «угадывает»: он открывает файлы проекта. Обратите внимание, какие файлы он открыл — пропустил ли нужный.
  4. Читаем план — до правок. Просим агента сначала написать план: какие файлы и что изменит. Уже на этом шаге видно, понял ли он задачу.
  5. Просматриваем правки файл за файлом. В редакторе видно разницу: что добавлено, что удалено. Принимаем по одному файлу, а не «всё сразу».
  6. Запускаем и пробуем сломать. Быстро печатаем в поиске; в DevTools на вкладке Network включаем медленную сеть и режим Offline; ищем запрос, которого нет в API.
  7. Находим упущенное. Почти всегда что-то остаётся: забыли сбросить страницу на первую при новом запросе, пропустили AbortError, оставили console.log. Это исправляется следующим промптом или руками.

Промпт для демо

Обратите внимание, как он устроен: сначала контекст, потом задача, потом ограничения, затем что показать и как проверить. Сравните с «плохим» вариантом:

добавь загрузку и поиск с задержкой в мой проект
Контекст: учебное приложение на чистом JS без библиотек — поиск блюд по API с пагинацией. Файлы: index.html, style.css, api.js (fetch), ui.js (рисует список), main.js (события). Задача: 1) добавь три состояния — загрузка (skeleton из трёх серых блоков), пусто («Ничего не найдено») и ошибка (понятный текст и кнопка «Повторить»); 2) добавь debounce 400 мс на ввод в строке поиска; 3) при новом запросе отменяй предыдущий через AbortController и молча игнорируй AbortError; 4) при новом поисковом запросе сбрасывай страницу на первую. Ограничения: не трогай логику пагинации и названия существующих CSS-классов, не добавляй библиотеки, не меняй файлы, которые не нужны для этой задачи. Формат: сначала напиши план — какие файлы изменишь и зачем; затем внеси правки и перечисли изменённые файлы. Проверка: напиши, как мне вручную проверить каждое из четырёх изменений.

На что смотреть во время демонстрации

Держите эти вопросы в голове, пока смотрите на экран, — и задавайте их вслух:

  • Сколько файлов изменено и все ли они нужны? Правка в style.css уместна (skeleton). А вот изменения в чужих функциях, которые мы не просили трогать, — повод остановиться.
  • Прочитан ли весь diff? Красное — удалено. Не исчезла ли рабочая логика (например, пагинация) в «рефакторинге»?
  • Нет ли выдуманного? Поле JSON, которого нет в реальном ответе API, или функция, которой нет в браузере. Сверяем с настоящим ответом во вкладке Network.
  • Обработаны ли все четыре состояния? Особенно путаница «пусто» и «ошибка» — ИИ часто их смешивает.
  • Есть ли AbortError в catch? Без него на экране мигает «ошибка» при каждой новой букве.
  • Могу ли я объяснить каждую строку? Если нет — код не принимаем (правило курса №3).

Риски: что может пойти не так

Опасные привычки
  • Нажимать «принять всё», не читая правки
  • Просить «исправь всё» без ограничений: агент переписывает то, что работало
  • Верить, что «агент сам проверил» — запуск и проверка остаются за вами
  • Давать агенту секреты: ключи API, пароли, файлы с личными данными (.env)
  • Перестать читать код — со временем навык теряется
Здоровые привычки
  • Маленькая задача — маленькие правки — проверка
  • Всегда просить план до правок и список изменённых файлов после
  • Чётко писать, что нельзя трогать
  • Хранить копию проекта (позже — коммит в Git) до начала работы агента
  • Проверять в браузере: быстро, медленно, офлайн, пусто
Конфиденциальность

Код, который вы показываете агенту, отправляется на сервер ИИ-сервиса. Для учебных проектов это нормально, но рабочий код заказчика, пароли и ключи туда отправлять нельзя без разрешения. Привыкайте думать об этом заранее: на практике это вопрос доверия и иногда — закона.

А вы — через ИИ-чат: разбираем документацию API

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

как получить данные из этого api
Вот документация API поиска товаров: [вставьте текст или ссылку]. Мне нужно получить первые 5 результатов по запросу из поля ввода и обработать случай, когда ничего не найдено. Какой эндпоинт, какие параметры, какой пример запроса на fetch? Чистый JS, без библиотек. Ответь так: 1) таблица параметров; 2) пример кода; 3) что проверить на реальном ответе, чтобы убедиться, что поля названы правильно.
Проверка ответа ИИ: сверяем с реальным API

Откройте адрес запроса прямо в браузере (например, https://dummyjson.com/products/search?q=phone&limit=5) или посмотрите ответ во вкладке Network. Совпадают ли названия полей (products, total) с тем, что написал ИИ? Если нет — ИИ выдумал или перепутал; верим только настоящему ответу сервера.

Практика: ревью работы «агента»

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

ЗАДАЧА 6.1

Ревью кода от агента: три бага

сложная

В коде ниже три ошибки; из-за них поиск получает лишние запросы и показывает неверные состояния. Расписание «нажатий» в конце: «к», «ку», «кур» — быстрая серия; потом «самбуса», пока предыдущий запрос ещё идёт; потом «борщ» (в меню такого нет). Запустите код, посмотрите на беспорядок в консоли, найдите ошибки и исправьте. После исправления состояния должны идти ровно в таком порядке: загрузка, загрузка, успех, загрузка, пусто.

Можно редактировать · Ctrl + Enter
// ↓ «сервер»: медленный поиск по меню — не меняйте
const DISHES = ["Оши палав", "Самбуса с мясом", "Самбуса с тыквой", "Курутоб", "Шакароб", "Нон"];

function fakeSearch(query, { signal, ms = 200 } = {}) {
  return new Promise((resolve, reject) => {
    if (signal && signal.aborted) return reject(signal.reason);
    const timer = setTimeout(() => {
      if (query === "ошибка") return reject(new Error("Нет соединения с сервером"));
      resolve(DISHES.filter((d) => d.toLowerCase().includes(query.toLowerCase())));
    }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    }
  });
}

let state = { status: "idle", data: [], error: null };
let timer = null;
let controller = null;

function setState(patch) {
  state = { ...state, ...patch };
  console.log(`состояние: ${state.status}`);
}

async function runSearch(query) {
  controller = new AbortController();
  setState({ status: "loading" });
  try {
    const items = await fakeSearch(query, { signal: controller.signal });
    setState({ status: "success", data: items });
  } catch (e) {
    setState({ status: "error", error: e.message });
  }
}

function onInput(query) {
  timer = setTimeout(() => runSearch(query), 50);
}

[[0, "к"], [20, "ку"], [40, "кур"], [150, "самбуса"], [550, "борщ"]].forEach(([at, q]) =>
  setTimeout(() => onInput(q), at)
);
Ожидаемый результат
состояние: loading
состояние: loading
состояние: success
состояние: loading
состояние: empty
Подсказка 1

Три дефекта: (1) в onInput нет clearTimeout — это не debounce; (2) runSearch не отменяет предыдущий запрос; (3) пустой ответ получает статус success вместо empty.

Подсказка 2

Исправляя второй дефект, вы столкнётесь с четвёртым: отменённый запрос попадёт в catch и покажет error. В catch нужно сначала проверить e.name === "AbortError" и выйти через return без состояния.

07 Практика на уроке: три состояния и debounce

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

  1. Этап 1. Состояния. Один объект state и функция render, которая по state.status рисует: подсказку, skeleton, карточки, «пусто» или ошибку с кнопкой «Повторить». Функция load переводит статус: loading → success / empty / error.
  2. Этап 2. Debounce. Пишем debounce, вешаем на input: запрос уходит только после паузы.
  3. Этап 3. Отмена. Перед новым запросом отменяем старый, AbortError молча пропускаем.
  4. Этап 4. В своём приложении. То же самое переносим в проект из уроков 14–15 — ниже есть план.

Практика: меню чайханы

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

ЗАДАЧА 7.1

Меню чайханы: четыре состояния

средняя

Допишите render и load. Для каждого статуса нужна своя разметка: idle — <p class="hint">Начните вводить название блюда</p>; loading — три div.skeleton; success — по div.dish на блюдо; empty — <p class="empty">Ничего не найдено</p>; error — <p class="error"> с текстом ошибки и <button class="retry">Повторить</button>. Функция load(query) ставит loading (и сразу перерисовывает), затем — по результату запроса — success, empty или error, и снова перерисовывает.

Меню чайханы: состояния · Ctrl + Enter
<h3 class="title">Меню чайханы</h3>
<input id="q" class="search" placeholder="Что ищем?" autocomplete="off">
<div id="result" class="result"></div>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
loading 3
success 2
empty Ничего не найдено
error true
Подсказка 1

В render — цепочка if / else if по state.status. Ветка loading: цикл на три шага с document.createElement("div") и className = "skeleton". Ветка success: state.items.map(...) и result.append(...cards).

Подсказка 2

В load: state = { status: "loading", items: [], error: null }; render(); — затем try { const items = await fakeSearch(query); ... } catch (err) { ... }. Статус "empty" ставьте, если items.length === 0. Не забывайте render() после каждого изменения state.

ЗАДАЧА 7.2

Меню чайханы: debounce и отмена

сложная

Состояния уже готовы (render и load из прошлой задачи). Допишите четыре места: (1) функцию debounce(fn, wait); (2) в начале load — отмену предыдущего запроса и создание нового контроллера; (3) в catch — молчаливый выход при AbortError; (4) слушатель события input у поля поиска, который с debounce 100 мс вызывает load(input.value.trim()). Проверка сама «набирает» текст: быструю серию «с», «са», «сам», потом «кур» и сразу после — «курутоб». Лишние запросы должны отсекаться, а устаревший ответ — не рисоваться.

Меню чайханы: debounce и abort · Ctrl + Enter
<h3 class="title">Меню чайханы</h3>
<input id="q" class="search" placeholder="Что ищем?" autocomplete="off">
<div id="result" class="result"></div>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
запросов: 1
запросов: 3
успешных отрисовок: 2
success 1
Подсказка 1

Три нажатия подряд («с», «са», «сам») должны дать один запрос — значит, debounce работает. Если в консоли «запросов: 3» уже в первой строке — слушатель вызывает load напрямую, без debounce.

Подсказка 2

Если «успешных отрисовок» получилось 3, а не 2 — отмена не работает: ответ на «кур» нарисовался, хотя пользователь уже ввёл «курутоб». Проверьте, что abort() вызывается до создания нового контроллера и что AbortError в catch заканчивается return.

Этап 4. Переносим в своё приложение

Ваше приложение из уроков 14–15 уже умеет искать и листать страницы. Добавляем в него то же самое, шаг за шагом. После каждого шага запускайте и пробуйте:

  1. Заведите state с полями status, items, error (и page, если у вас есть пагинация).
  2. Вынесите рисование в одну функцию render(), которая по state.status показывает нужную картинку. Больше нигде DOM не трогайте.
  3. Оберните запрос: сначала loading, потом success / empty / error. Не забудьте if (!res.ok) throw ....
  4. Skeleton: скопируйте CSS из раздела 3, добавьте нужное число заглушек в состоянии loading.
  5. Debounce: скопируйте debounce, оберните обработчик input — пауза 300–400 мс.
  6. AbortController: передайте signal в свой fetch, отменяйте прошлый запрос, пропускайте AbortError.
  7. Проверьте в DevTools (вкладка Network): включите медленную сеть (Slow 4G) и режим Offline — все три состояния должны увидеться вживую.
Каркас для своего приложения (с настоящим API, нужен интернет) · Ctrl + Enter
let state = { status: "idle", items: [], error: null };
let controller = null;

async function load(query) {
  if (controller) controller.abort();
  controller = new AbortController();
  state = { status: "loading", items: [], error: null };
  console.log("состояние:", state.status);          // здесь будет render()
  try {
    const url = `https://dummyjson.com/products/search?q=${encodeURIComponent(query)}&limit=5`;
    const res = await fetch(url, { signal: controller.signal });
    if (!res.ok) throw new Error(`Сервер ответил кодом ${res.status}`);
    const data = await res.json();
    state = { status: data.products.length ? "success" : "empty", items: data.products, error: null };
  } catch (err) {
    if (err.name === "AbortError") return;           // отмена — не ошибка
    state = { status: "error", items: [], error: err.message };
  }
  console.log("состояние:", state.status, "| найдено:", state.items.length);
}

load("phone");
Не забудьте про пагинацию

Если в приложении есть страницы, при новом поисковом запросе возвращайтесь на первую страницу. А пока идёт загрузка — делайте кнопки «Назад» и «Вперёд» неактивными (disabled), иначе пользователь успеет нажать их несколько раз подряд.

08 Домашнее задание

Довести UX приложения: загрузка, пустой результат, ошибка сети. Берёте своё приложение из уроков 14–15 (список или поиск по настоящему API) и делаете его вежливым к пользователю.

Что нужно сделать

  1. Загрузка. Пока идёт запрос, показывается skeleton (3–6 заглушек нужной формы). На контейнере — aria-busy.
  2. Пусто. Если ничего не нашлось — понятный текст («Ничего не найдено по запросу «…»») и подсказка, что делать.
  3. Ошибка. Проверка res.ok; понятное сообщение по-человечески (не «Failed to fetch») и кнопка «Повторить», которая повторяет тот же запрос.
  4. Debounce. Поиск «на лету» с debounce 300–500 мс, написанный вами (не из библиотеки).
  5. Отмена. Новый запрос отменяет предыдущий через AbortController; AbortError не показывается как ошибка.
  6. Проверка в DevTools. Сделайте три скриншота: загрузка при медленной сети, режим Offline с ошибкой, пустой результат.
  7. (Бонус.) При новом запросе страница сбрасывается на первую; кнопки пагинации отключены на время загрузки.

Что сдавать

  • Ссылку на репозиторий в GitHub (или папку проекта — как договоритесь с преподавателем).
  • Три скриншота состояний (загрузка, ошибка, пусто).
  • Короткое описание на 3–5 предложений: что было сложнее всего и где использовали ИИ. Если использовали — каждую строку нужно уметь объяснить.

Как оцениваем (0–10)

ЧтоБаллы
Загрузка: skeleton или другая понятная индикация, содержимое не «прыгает»2
Пусто и ошибка: разные тексты, проверка res.ok, работает «Повторить»2
Debounce написан самостоятельно и работает (один запрос на серию нажатий)2
AbortController: старый запрос отменяется, AbortError не показывается2
Скриншоты и описание; студент объясняет код и решения на защите2
Перед сдачей

Попробуйте сломать своё приложение: печатайте быстро, нажимайте «Найти» много раз подряд, включите Offline, введите запрос, которого нет. Если оно держится — оно готово.

✓ Шпаргалка урока

  • Четыре состояния: loading (грузим), error (сломалось + «Повторить»), empty (ответ пуст), success (данные). Плюс idle — начало. Один status вместо кучи булевых.
  • Порядок проверок: сначала loading и error, потом «пусто или нет». Иначе во время загрузки показывается «Ничего не найдено».
  • fetch и ошибки: сети нет → промис отклонён (catch); сервер ответил 404/500 → успех для fetch. Всегда if (!res.ok) throw new Error(...).
  • Пользователю — понятный текст и «Повторить»; технические детали — в console.error.
  • Skeleton: серые блоки той же формы + background-size: 400% и анимация background-position; aria-busy; prefers-reduced-motion; заменять содержимое целиком (replaceChildren).
  • debounce(fn, wait): let timer снаружи, внутри clearTimeout(timer); timer = setTimeout(() => fn(...args), wait). Пауза для поиска — 300–500 мс. Debounce — для потока событий, не для кнопок.
  • AbortController: const c = new AbortController(); fetch(url, { signal: c.signal }); c.abort(); — промис отклоняется с AbortError. Контроллер одноразовый; на новый запрос — новый.
  • Гонка запросов: новый запрос отменяет прошлый; AbortError в catch молча пропускаем. AbortSignal.timeout(мс) — таймаут (TimeoutError).
  • ИИ-агент (Cursor): видит весь проект, правит несколько файлов. Просим план до правок, читаем diff файл за файлом, не жмём «принять всё», проверяем в браузере (быстро, медленно, офлайн, пусто); секреты агенту не даём.