Интернет-магазин не хранит тысячи товаров в HTML — он спрашивает их у сервера: «покажи шесть товаров, пропусти первые двенадцать, только из категории „телефоны“». Сегодня вы научитесь задавать такие вопросы через параметры запроса и превращать ответ в живой список с поиском, фильтрами и страницами — ровно так устроены каталоги, ленты и поиск на настоящих сайтах.
Собирать адрес запроса через URLSearchParams, не думая про пробелы и кириллицу
Читать документацию API и находить в ответе нужные поля
Рисовать список из ответа сервера, не дублируя карточки
Делать поиск без лишних запросов: debounce и защита от «гонки»
Фильтровать и сортировать — на сервере и на клиенте
Считать страницы по limit / skip и управлять кнопками «Назад» / «Вперёд»
Как работать с этой страницей
Код на тёмном фоне с кнопкой ▶ Запустить — живой: меняйте и запускайте. Примеры с fetch ходят в настоящие публичные API, поэтому нужен интернет. Блоки с вкладками index.html / style.css / script.js — это мини-страница: слева код, под ним предпросмотр и консоль. Задачи сами проверяются по тому, что вы выводите в консоль. Если серверу «плохо» (редкий случай), вы увидите ошибку сети — просто запустите ещё раз. Подсказки под замочком 🔒: сначала сами, потом подсказка.
В уроке три публичных API без ключей: DummyJSON (товары), Rick and Morty API (персонажи) и немного Open-Meteo (погода) в домашнем задании.
01 Параметры запроса: URL и URLSearchParams
Любой запрос к API — это адрес. Но адрес умеет не только «куда пойти», а ещё и «что именно принести». Для этого после знака ? дописывают параметры запроса (query string) в виде пар ключ=значение, разделённых &.
Путь отвечает на вопрос «что?», параметры — «сколько, какие, в каком порядке?»
Аналогия из жизни
Заказ в чайхоне. «Курутоб» — это путь: что вы заказываете. А «без лука, порцию побольше, две пиалы чая» — это параметры: уточнения к тому же блюду. Блюдо одно, а заказы разные. Так и с API: путь /products один, а параметры limit, skip, q меняют, что именно вам принесут.
Почему нельзя склеивать строки руками
Новички собирают адрес так: "…?q=" + text + "&limit=" + limit. Пока в text одно слово латиницей, всё работает. Но стоит пользователю ввести пробел, &, # или русскую букву — адрес ломается. У URL строгие правила: такие символы нужно кодировать (пробел → + или %20, & → %26, кириллица → %D0…).
Кодировать самим — муторно и легко ошибиться. Для этого в браузере (и в Node.js) есть готовый класс URLSearchParams.
URLSearchParams собирает и кодирует · Ctrl + Enter
const params = newURLSearchParams();
params.set("q", "плов & чай"); // пробелы, &, кириллица — не наша забота
params.set("limit", 5); // число станет строкой само
params.set("skip", 10);
console.log(params.toString());
// q=%D0%BF%D0%BB%D0%BE%D0%B2+%26+%D1%87%D0%B0%D0%B9&limit=5&skip=10const url = "https://dummyjson.com/products/search?" + params;
console.log(url); // в строку объект превращается автоматически
Создать URLSearchParams можно сразу из объекта или из готовой строки — удобно, чтобы разобрать чужой адрес:
1.params.get("limit") + 1 даст "101", а не 11: значения — строки. Нужен Number(...). 2. Склейка строк ломается на пробелах и &. 3. Проверка if (value) при сборке адреса выбросит skip=0 — ноль «ложный», но для сервера это важное значение. Проверяйте === undefined, а не «правдивость».
Склейка строк ломает адрес · Ctrl + Enter
const text = "rock & roll";
const bad = "https://dummyjson.com/products/search?q=" + text + "&limit=5";
console.log(bad);
// сервер увидит: q="rock ", параметр " roll" без значения и limit=5 — искать будет не то
console.log(newURL(bad).searchParams.get("q"));
Собираем и разбираем адреса. Все задачи проверяются выводом в консоль.
ЗАДАЧА 1.1
Три параметра для поиска
лёгкая
Создайте URLSearchParams и задайте через set три параметра: q = samsa, limit = 5, skip = 10. Выведите итоговую строку параметров.
Можно редактировать · Ctrl + Enter
const params = newURLSearchParams();
// задайте q, limit и skip// выведите params.toString()
Ожидаемый результат
q=samsa&limit=5&skip=10
Подсказка 1
params.set("q", "samsa"); — по одной строке на каждый параметр, в том порядке, в котором они в ответе.
Подсказка 2
Числа можно передавать как есть: params.set("limit", 5) — класс сам превратит их в строки.
ЗАДАЧА 1.2
Предскажи вывод: строки и числа
лёгкая
Сначала, не запуская, запишите прогноз для первых пяти строк. Запустите и сверьтесь. Потом допишите внизу: добавьте через append второе значение limit = 20 и выведите getAll("limit") через запятую (.join(",")).
Плюс со строкой — склейка: "10" + 1 это "101". А вот умножение само превращает строку в число.
Подсказка 2
params.append("limit", 20); и console.log(params.getAll("limit").join(","));
ЗАДАЧА 1.3
Функция buildUrl
средняя
Напишите buildUrl(base, params): она принимает адрес и объект параметров и возвращает готовый адрес. Параметры со значением undefined, null или пустой строкой пропускаются; 0 — не пропускается. Если параметров не осталось, знак ? не добавляется.
Пройдите по Object.entries(params) циклом for…of, пропускайте плохие значения через continue, а хорошие кладите в URLSearchParams.
Подсказка 2
Условие пропуска: value === undefined || value === null || value === "". В конце: const text = query.toString(); return text ? `${base}?${text}` : base;
ЗАДАЧА 1.4
Найди баг: поиск «rock & roll»
лёгкая
Пользователь искал rock & roll, а сервер получил что-то странное. Перепишите сборку адреса через URLSearchParams (параметры q и limit=5). Выведите адрес, а второй строкой — что вернёт new URL(url).searchParams.get("q"): это то, что увидит сервер.
https://dummyjson.com/products/search?q=rock+%26+roll&limit=5
rock & roll
Подсказка 1
Создайте new URLSearchParams({ q: query, limit: 5 }).
Подсказка 2
const url = "https://dummyjson.com/products/search?" + params; — объект сам превратится в строку.
02 Читаем документацию и ответ API
У каждого API свои правила: как называются параметры, как устроен ответ, что будет, если ничего не найдено. Узнать это можно только из документации и из самого ответа. Привычка №1 разработчика: сначала посмотреть, что реально приходит, и только потом писать код.
Из урока 14 вы помните обёртку над fetch. Она нам понадобится в каждом примере, поэтому вот её короткая версия — в уроке она будет лежать в начале каждого блока:
Как выглядит ответ DummyJSON · Ctrl + Enter
asyncfunctiongetJson(url) {
const response = awaitfetch(url);
if (!response.ok) thrownewError("HTTP " + response.status);
return response.json();
}
asyncfunctionmain() {
const data = awaitgetJson("https://dummyjson.com/products?limit=2&select=title,price");
console.log(Object.keys(data));
console.log(data.products);
console.log("всего товаров на сервере больше, чем пришло:", data.total > data.products.length);
}
main();
Видите: ответ — не просто массив товаров, а объект-конверт. Сами товары лежат в products, а рядом — служебные поля total (сколько всего), skip и limit (какой кусок вернули). Они нам понадобятся для пагинации.
DummyJSON
Rick and Morty API
JSONPlaceholder
Где список
data.products
data.results
сам ответ — массив
Поиск
/products/search?q=
?name=
нет, только фильтры по полям: ?userId=1
Страницы
limit + skip
page (по 20 штук)
_page + _limit
Сколько всего
data.total
data.info.count, info.pages
заголовок x-total-count
Ничего не нашли
200 и пустой products
404 и { error }
200 и пустой массив
Последняя строка — самая коварная: «ничего не найдено» в одном API это пустой список, а в другом — ошибка 404. Если не знать, поиск по Рику и Морти будет «падать» на любой опечатке.
Документация API — это меню чайханы. Вы не угадываете, что есть на кухне: открываете меню и видите блюда (эндпоинты), порции и добавки (параметры). А ответ сервера — само блюдо на тарелке: нужно посмотреть, что в нём лежит, прежде чем начать есть.
Как читать документацию за 2 минуты
Найдите нужный эндпоинт. «Мне нужен поиск товаров» → GET /products/search.
Выпишите параметры: какие обязательные, какие нет, какие значения допустимы (limit, skip, select, sortBy, order).
Откройте пример ответа и найдите, где лежит список и где служебные поля.
Проверьте руками: вставьте адрес в браузер или в console.log и убедитесь, что ответ такой, как в документации.
Узнайте про «пусто» и «ошибка»: какой статус и какое тело придёт, если ничего нет.
Ещё один приём: параметр select у DummyJSON оставляет в ответе только нужные поля. Запрос становится легче и быстрее — зачем качать описание и отзывы, если нужны название и цена?
Практика: закрепляем
Три задачи, в которых вы читаете ответ API и учитываете его особенности.
ЗАДАЧА 2.1
Что внутри конверта
лёгкая
Запросите три товара (limit=3, select=title,price). Выведите ключи ответа через запятую (Object.keys(data).join(", ")), а второй строкой — сколько товаров в data.products.
data — не массив, а объект-конверт. Какое его поле содержит массив товаров?
Подсказка 2
Замените data.map(...) на data.products.map(...).
ЗАДАЧА 2.3
Поиск персонажей без падений
средняя
Допишите searchCharacters(name): она запрашивает https://rickandmortyapi.com/api/character?name=… (параметр собирайте через URLSearchParams) и возвращает массив data.results. Если сервер ответил 404 — это «ничего не найдено», верните пустой массив []. Любую другую неудачу (!response.ok) считайте ошибкой и бросайте Error.
Сначала const response = await fetch(...), потом проверки статуса — и только после них response.json().
Подсказка 2
if (response.status === 404) return []; затем if (!response.ok) throw new Error("HTTP " + response.status);
03 Рендер списка из API
Данные пришли — теперь покажем их на странице. Схема всегда одна: fetch → массив → для каждого элемента создать узел → вставить в страницу. Это то, что вы делали в уроках 8 и 11, только массив теперь приходит не из переменной, а из интернета.
Для каждого товара создали <li> / карточку, заполнили textContent и добавили в контейнер.
Список товаров из API · Ctrl + Enter
<h3>Первые 5 товаров</h3><ulid="list"></ul>
const list = document.querySelector("#list");
asyncfunctionloadProducts() {
const response = awaitfetch("https://dummyjson.com/products?limit=5&select=title,price");
const data = await response.json();
list.replaceChildren(); // 3. очистилиfor (const product of data.products) { // 4. по узлу на товарconst li = document.createElement("li");
li.textContent = `${product.title} — ${product.price} $`;
list.append(li);
}
}
loadProducts();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Аналогия из жизни
Рендер — это витрина на бозоре. Каждое утро продавец не дописывает товары поверх вчерашних, а выкладывает витрину заново: убрал старое — выставил актуальное. Функция render должна работать так же: сначала очистить, потом построить. Тогда её можно вызывать сколько угодно раз (при поиске, при смене страницы), и ничего не задвоится.
Правило: render полностью перестраивает список
Самая частая ошибка новичков — забыть очистку. Первый вызов работает, а при втором (новая страница, новый поиск) карточки добавляются к старым. Поэтому всё, что рисует список, начинается с replaceChildren() (или innerHTML = "").
Данные с сервера — не доверяйте им
Если вставлять ответ через innerHTML, то любой «товар» с названием <b>…</b> превратится в разметку, а злоумышленник сможет подсунуть и скрипт. Для текста из API используйте textContent: он вставляет строку как есть, без разбора тегов.
textContent против innerHTML · Ctrl + Enter
const title = "<b>СКИДКА!</b> Самбуса"; // как будто пришло с сервераconst safe = document.createElement("p");
safe.textContent = title;
const risky = document.createElement("p");
risky.innerHTML = title;
console.log(safe.innerHTML); // теги превращены в безопасный текст
console.log(risky.children.length); // 1 — внутри появился настоящий <b>
Карточки с картинкой
Настоящий каталог — это не список строк, а карточки. Принцип тот же, узлов просто больше. Картинку берём из поля thumbnail и обязательно задаём alt — для доступности и на случай, если картинка не загрузится.
Если сервер вернул ноль товаров, пользователь должен увидеть «Ничего не найдено», а не пустое место. Про состояния загрузки и ошибок мы подробно поговорим на следующем уроке, а пока заложим минимум: render умеет показывать пустой результат.
Четыре задачи на рендер. Результат — в предпросмотре и в консоли под песочницей.
ЗАДАЧА 3.1
Пять товаров в список
лёгкая
Допишите loadProducts: для каждого товара создайте <li> с текстом название — цена $ (тире с пробелами вокруг) и добавьте в list. Консоль уже выведет проверку: сколько элементов в списке и есть ли в первом тире.
Список товаров · Ctrl + Enter
<ulid="list"></ul>
const list = document.querySelector("#list");
asyncfunctionloadProducts() {
const response = awaitfetch("https://dummyjson.com/products?limit=5&select=title,price");
const data = await response.json();
// для каждого товара: создать li, записать текст, добавить в list
}
// --- проверка, не меняйте ---loadProducts().then(() => {
console.log(list.children.length);
console.log(list.firstElementChild.textContent.includes(" — "));
});
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
5
true
Подсказка 1
for (const product of data.products) { ... } — внутри цикла создаём один li.
Выведите шесть товаров карточками: <article class="card"> с <img> (src = thumbnail, alt = название), заголовком <h3> и ценой <p>. Всё через createElement и textContent.
Создайте четыре узла: article, img, h3, p. Классу card соответствует card.className = "card".
Подсказка 2
Соберите карточку одной строкой: card.append(img, title, price); и вставьте в grid.
ЗАДАЧА 3.3
Найди баг: задвоенный список
лёгкая
Список обновляют дважды (как будто пользователь нажал «обновить»). Должно остаться 3 элемента, а их 6. Исправьте loadProducts, не трогая проверку.
Двойное обновление · Ctrl + Enter
<ulid="list"></ul>
const list = document.querySelector("#list");
asyncfunctionloadProducts() {
const response = awaitfetch("https://dummyjson.com/products?limit=3&select=title");
const data = await response.json();
data.products.forEach((product) => {
const li = document.createElement("li");
li.textContent = product.title;
list.append(li);
});
}
// --- проверка, не меняйте ---loadProducts()
.then(() =>loadProducts())
.then(() => console.log(list.children.length));
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
3
Подсказка 1
Каждый вызов добавляет новые li к тем, что уже есть. Чего не хватает перед циклом?
Подсказка 2
Добавьте list.replaceChildren(); после получения данных, до цикла.
ЗАДАЧА 3.4
renderList с пустым состоянием
сложная
Напишите renderList(items) (items — массив строк). Она всегда сначала очищает list. Для пустого массива добавляет один <li class="empty">Ничего не найдено</li>, для непустого — по <li> на строку. Сеть не нужна.
renderList · Ctrl + Enter
<ulid="list"></ul>
const list = document.querySelector("#list");
functionrenderList(items) {
// ваш код
}
// --- проверка, не меняйте ---renderList(["Самбуса", "Курутоб"]);
console.log(list.children.length);
renderList([]);
console.log(`${list.children.length} ${list.textContent}`);
renderList(["Чой"]);
console.log(list.textContent);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
2
1 Ничего не найдено
Чой
Подсказка 1
Первой строкой — list.replaceChildren();. Затем ветка if (items.length === 0) { ...; return; }.
Подсказка 2
В пустой ветке создайте li, задайте className = "empty" и текст, добавьте в список и сделайте return. Ниже, для непустого, обычный цикл for (const item of items).
04 Поиск: input, debounce и «гонка» запросов
Поиск по API — это тот же запрос с параметром q. Сложность не в запросе, а в том, когда его отправлять. Если слушать событие input «в лоб», то на слово «phone» мы отправим пять запросов — на «p», «ph», «pho», «phon», «phone». Это лишняя нагрузка на сервер и мигающий интерфейс.
Debounce («антидребезг») откладывает действие и каждый новый вызов сбрасывает ожидание. Действие сработает только тогда, когда пользователь замолчал на заданное время — обычно 300–500 мс. Это те самые setTimeout и clearTimeout из урока 13.
Аналогия из жизни
Официант в чайхоне не бежит на кухню после каждого слова гостя. Он ждёт, пока гость договорит: «курутоб… и самбусу… и чай… всё». Только после паузы идёт к повару. Debounce — это терпеливый официант: одна пауза в разговоре — один поход на кухню.
debounce в шесть строк · Ctrl + Enter
functiondebounce(fn, ms) {
let timer; // таймер живёт между вызовамиreturn (...args) => {
clearTimeout(timer); // новый вызов отменяет прошлый
timer = setTimeout(() =>fn(...args), ms);
};
}
const find = debounce((text) => console.log("ищем:", text), 300);
find("п");
find("пл");
find("пло");
find("плов"); // сработает только последний вызов — через 300 мс
Функция возвращает новую функцию, внутри которой «спрятан» таймер (замыкание — вы встречали его в уроке 5). Подписка в приложении выглядит так:
Подписка на input через debounce
input.addEventListener("input", debounce(() => {
state.q = input.value.trim();
state.page = 1; // новый поиск — всегда с первой страницы!load();
}, 400));
«Гонка» запросов: когда старый ответ приходит позже нового
Даже с debounce бывает так: вы отправили запрос на «ph», передумали и отправили «phone». Сервер ответил на второй запрос быстрее, а на первый — позже. Если просто показывать ответы по мере прихода, старый перезапишет новый, и в поле «phone», а в списке — результаты для «ph». Это называется гонка запросов (race condition).
Аналогия из жизни
Вы заказали плов, а через минуту передумали и заказали самбусу. Если повара приносят блюда в порядке готовности, самбуса приедет первой, а потом вам «довезут» ненужный плов — и вы съедите не то. Правильно: каждому заказу выдать номер и принимать только блюдо с последним номером.
Баг: старый ответ перезаписывает новый · Ctrl + Enter
const delays = { ph: 300, phone: 100 }; // на «ph» сервер отвечает дольшеconst fakeFetch = (q) =>newPromise((resolve) =>setTimeout(() =>resolve("результаты для " + q), delays[q]));
asyncfunctionsearch(q) {
const result = awaitfakeFetch(q);
console.log("показано:", result);
}
search("ph");
setTimeout(() =>search("phone"), 50);
// на экране остаётся устаревшее «ph» — пользователь видит не то, что искал
Лекарство простое: у каждого запроса — свой номер, показываем только ответ на самый свежий:
Фикс: номер запроса · Ctrl + Enter
const delays = { ph: 300, phone: 100 };
const fakeFetch = (q) =>newPromise((resolve) =>setTimeout(() =>resolve("результаты для " + q), delays[q]));
let lastRequest = 0; // номер последнего запросаasyncfunctionsearch(q) {
const id = ++lastRequest; // берём свой номерconst result = awaitfakeFetch(q);
if (id !== lastRequest) return; // за это время ушёл более новый — выбрасываем
console.log("показано:", result);
}
search("ph");
setTimeout(() =>search("phone"), 50);
Есть и «взрослый» способ — отменять устаревший запрос через AbortController: браузер прекращает ждать, а fetch выбрасывает ошибку с именем AbortError, которую нужно спокойно проглотить.
Для начала — счётчик lastRequest: он работает везде и понятен с первого взгляда. AbortController экономит трафик, но требует аккуратной обработки AbortError. В нашем приложении будет счётчик.
Напишите search(q): она запрашивает https://dummyjson.com/products/search с параметрами q, limit=5, select=title (через URLSearchParams) и возвращает массив products. Проверка уже написана.
Параметры: new URLSearchParams({ q, limit: 5, select: "title" }). Адрес: "https://dummyjson.com/products/search?" + params.
Подсказка 2
Дальше как обычно: await fetch, await response.json(), return data.products.
ЗАДАЧА 4.2
Предскажи: сколько раз сработает поиск
средняя
Допишите debounce(fn, ms). Затем до запуска выпишите, какие строки появятся: три быстрых вызова в момент 0 мс, ещё один на 100-й миллисекунде и последний — на 600-й. Пауза debounce — 200 мс.
В песочнице уже есть debounce и «поддельный» поиск fakeSearch. Подпишитесь на событие input: через debounce с паузой 300 мс вызывайте fakeSearch(input.value.trim()). Ниже код имитирует набор слова «phone» с паузой 50 мс между буквами.
Обработчик события — это результат вызова debounce(...): input.addEventListener("input", debounce(…, 300));
Подсказка 2
Внутри передайте стрелочную функцию: () => fakeSearch(input.value.trim()).
ЗАДАЧА 4.4
Найди баг: гонка запросов
сложная
Запускаются два поиска: «ph» (сервер отвечает за 300 мс) и через 50 мс «phone» (за 100 мс). Сейчас показываются оба результата, причём устаревший приходит последним. Исправьте search так, чтобы показывался только результат самого свежего запроса.
Заведите снаружи функции счётчик let lastRequest = 0; и в начале search берите номер: const id = ++lastRequest;
Подсказка 2
После await сравните: if (id !== lastRequest) return; — если номер не последний, ответ устарел.
05 Фильтрация и сортировка
Фильтровать можно в двух местах — и от выбора зависит скорость и удобство:
Фильтр на сервере
Вы просите у API только нужное: /products/category/smartphones. Приходит меньше данных, работает на больших каталогах (тысячи записей), совместимо с пагинацией. Но каждый новый фильтр — новый запрос.
Фильтр на клиенте
Данные уже загружены, а вы просто делаете filter по массиву. Мгновенно, без сети. Но работает, только если на руках весь набор — для огромных каталогов и страничной загрузки не подходит.
Аналогия из жизни
Сервер — это склад на «Корвоне»: вы звоните и просите «пришлите только красные ковры» — вам везут нужное. Клиент — это ваш прилавок: ковры уже привезли, вы просто раскладываете красные влево, остальные вправо. Склад экономит дорогу, прилавок — время ожидания.
У DummyJSON эндпоинт /products/searchне комбинируется с категорией: вы либо ищете по словам, либо берёте товары категории. Придумывать ?q=phone&category=smartphones бесполезно — сервер проигнорирует категорию. Реальные API полны таких ограничений. В нашем приложении правило будет простым: если введён поиск — работает поиск; если выбрана категория — работает категория; выбирая одно, сбрасываем другое.
Фильтр на клиенте: filter + includes
Когда нужно скомбинировать условия, а данных мало, фильтруем массив на месте. Одно условие — один «отсев»:
Выведите три строки: есть ли "smartphones" в category-list; все ли товары из ответа /products/category/smartphones?limit=3&select=category имеют категорию smartphones (every); сколько товаров пришло.
Запросите 5 товаров, отсортированных по цене по убыванию (sortBy=price, order=desc, select=price, limit=5 — соберите через URLSearchParams). Проверьте, что цены действительно убывают, и выведите результат и количество.
Каждая цена не больше предыдущей: prices.every((price, i) => i === 0 || prices[i - 1] >= price)
ЗАДАЧА 5.3
Меню: фильтр по трём условиям
средняя
Напишите applyFilters(items, { q, category, maxPrice }). Поиск — по названию, без учёта регистра; категория — точное совпадение; maxPrice — «не дороже». Любое условие можно не передавать. Функция show уже написана.
Самбуса с мясом, Самбуса с тыквой
Курутоб, Манту
Шакароб
ничего не найдено
Манту
Подсказка 1
Приведите искомый текст и название к нижнему регистру: toLowerCase(). Проверка вхождения — includes.
Подсказка 2
Внутри items.filter(...) по очереди отсеивайте: if (text && !… ) return false;, if (category && … !== category) return false; и в конце return item.price <= maxPrice;
ЗАДАЧА 5.4
Select с категориями из данных
сложная
Дополните песочницу: 1) заполните select: первая опция «Все» со значением "", потом по одной опции на каждую уникальную категорию (помогут new Set и готовая addOption); 2) при событии change показывайте только товары выбранной категории (или все, если выбрано «Все»), используя готовую renderList. Ниже код сам «выбирает» категории и печатает проверку.
addOption("", "Все");, затем цикл for (const category of new Set(menu.map((item) => item.category))).
Подсказка 2
В обработчике: const value = select.value; renderList(value ? menu.filter((item) => item.category === value) : menu); Не забудьте один вызов renderList(menu) до подписки.
06 Пагинация: страницы по limit и skip
В каталоге 194 товара — грузить их все сразу глупо: долго, тяжело и никто столько не пролистает. Поэтому сервер отдаёт данные порциями, а вы просите нужную порцию. Это и есть пагинация (постраничная загрузка).
Аналогия из жизни
Плов на свадьбе подают не всем котлом, а порциями на ляганах: на каждый — десять порций. Хотите добавки — просите следующий ляган. Параметр limit — сколько порций на ляган, skip — сколько порций вы уже съели и пропускаете.
Два популярных способа
Способ
Параметры
Кто использует
limit + skip (offset)
«дай limit штук, пропустив первые skip»
DummyJSON, PokeAPI (limit + offset)
page
«дай страницу № page», размер страницы задан сервером
Rick and Morty (20 на странице), CoinGecko (page + per_page)
Состояние пагинации — это всего два числа: текущая страница и общее число страниц. Всё остальное рисуется от них:
«Назад» выключена на первой странице: prev.disabled = page === 1.
«Вперёд» выключена на последней: next.disabled = page === pages.
Подпись «Страница 3 из 5» собирается из тех же чисел.
После нового поиска или смены категории страница сбрасывается на первую — иначе можно оказаться на 7-й странице результатов, которых всего три.
Частые ошибки
1.skip = page * limit без «−1» — пропускается целая страница, первая порция товаров не видна никогда. 2. Страницы считают через Math.floor или Math.round — теряется последняя страница. 3. Забывают сбросить page = 1 при новом поиске. 4. Пустой поиск у DummyJSON вернёт total: 0 — делить на ноль не страшно, но «страница 1 из 0» выглядит странно: показывайте минимум одну страницу (Math.max(1, …)).
Rick and Morty: страницы без skip
Там всё проще: ?page=2. А сколько страниц — подсказывает сам сервер в info.pages, и даже даёт info.next — адрес следующей страницы или null, если страница последняя. Это удобно для кнопки «Показать ещё».
Показать ещё: накапливаем страницы · Ctrl + Enter
let page = 0;
let items = [];
asyncfunctionloadMore(name) {
page++;
const params = newURLSearchParams({ name, page });
const response = awaitfetch("https://rickandmortyapi.com/api/character?" + params);
const data = await response.json();
items = items.concat(data.results); // не заменяем, а дописываемreturn data.info.next !==null; // есть ли ещё страницы
}
asyncfunctionmain() {
let hasMore = awaitloadMore("rick");
console.log("после 1-й загрузки:", items.length, hasMore);
hasMore = awaitloadMore("rick");
console.log("после 2-й загрузки:", items.length, hasMore);
}
main();
Есть два интерфейса: страницы (кнопки, пролистываем номерами; render заменяет список) и «Показать ещё» / бесконечная лента (карточки дописываются; render добавляет, а не очищает). Выбор — дело дизайна, а запросы одинаковые.
Напишите loadPage(page, limit) (запрос к /products с limit, skip и select=title, возвращает весь объект ответа). Загрузите страницы 1 и 2 по 5 штук и выведите: skip и limit второй страницы, нет ли в ней товаров первой (по id), и больше ли страниц одной.
Можно редактировать · Ctrl + Enter
asyncfunctionloadPage(page, limit) {
// ваш код
}
asyncfunctionmain() {
const first = awaitloadPage(1, 5);
const second = awaitloadPage(2, 5);
console.log(`skip=${second.skip}, limit=${second.limit}`);
// без повторов: используйте Set из id первой страницы// страниц больше одной: Math.ceil(second.total / second.limit) > 1
}
main();
Ожидаемый результат
skip=5, limit=5
без повторов: true
страниц больше одной: true
Подсказка 1
const ids = new Set(first.products.map((p) => p.id));
Подсказка 2
second.products.every((p) => !ids.has(p.id)) — ни один id второй страницы не лежит в ids.
ЗАДАЧА 6.4
Кнопки пагинации
средняя
В песочнице 47 записей по 10 на странице — 5 страниц. Напишите render (подпись «Страница N из M», выключение кнопок) и подпишите кнопки на click (page-- / page++ и render()). Не забудьте вызвать render один раз при старте. Код ниже «кликает» по кнопкам, в том числе по выключенным.
const total = 47;
const limit = 10;
const pages = Math.ceil(total / limit);
let page = 1;
const prev = document.querySelector("#prev");
const next = document.querySelector("#next");
const label = document.querySelector("#label");
functionrender() {
// подпись и disabled для обеих кнопок
}
// подписки на click и первый вызов render()// --- имитация кликов, не меняйте ---const state = (b) => (b.disabled ? "выкл" : "вкл");
const report = () => console.log(`${label.textContent} | назад: ${state(prev)} | вперёд: ${state(next)}`);
report();
next.click(); next.click();
report();
next.click(); next.click(); next.click();
report();
prev.click();
report();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Страница 1 из 5 | назад: выкл | вперёд: вкл
Страница 3 из 5 | назад: вкл | вперёд: вкл
Страница 5 из 5 | назад: вкл | вперёд: выкл
Страница 4 из 5 | назад: вкл | вперёд: вкл
Подсказка 1
label.textContent = `Страница ${page} из ${pages}`; и prev.disabled = page === 1;
Подсказка 2
Клик по уже выключенной кнопке ничего не делает, поэтому «перелистать» дальше пятой страницы не получится: следите лишь за тем, чтобы render() вызывался после каждого изменения page.
ЗАДАЧА 6.5
«Показать ещё» на Рике и Морти
сложная
Напишите loadMore(name): она увеличивает счётчик page, запрашивает https://rickandmortyapi.com/api/character?name=…&page=…, дописываетdata.results в массив items и возвращает true, если есть следующая страница (data.info.next !== null). Вызовите её дважды подряд для «rick» и выведите после каждого вызова: сколько накопилось и есть ли ещё.
Можно редактировать · Ctrl + Enter
let page = 0;
let items = [];
asyncfunctionloadMore(name) {
// ваш код
}
asyncfunctionmain() {
// два вызова loadMore, после каждого console.log(items.length, hasMore)
}
main();
Ожидаемый результат
20 true
40 true
Подсказка 1
Страница размером 20 — задана сервером. Накопление: items = items.concat(data.results); (а не items = data.results).
Подсказка 2
В main: let hasMore = await loadMore("rick"); console.log(items.length, hasMore); — и ещё раз то же самое.
07 ИИ-блок: читаем документацию API вместе с ИИ
Документации бывают длинные, на английском и написаны так, что нужный параметр найти сложно. ИИ отлично помогает быстро найти эндпоинт и параметры — если дать ему сам текст документации. Но у него есть слабость: когда документации под рукой нет, он выдумывает параметры, которые звучат правдоподобно, но у вашего API не существуют.
Правила курса, которые здесь особенно важны
1. Сначала 15 минут пробуете сами: открываете документацию, ищете эндпоинт, пишете адрес вручную. 2. Потом идёте к ИИ. 3. Не вставляйте в проект код, который не можете объяснить строчка за строчкой. 4. Всё, что сказал ИИ про параметры API, сверяйте с настоящим ответом сервера — запросом в консоли.
Как просить: плохо и хорошо
Формула промпта курса: контекст → задача → ограничения → формат ответа → проверка. Главное для документации — вставить её текст (или нужный кусок), а не просить «вспомнить».
как в dummyjson искать товары по категории и по названию одновременно
Я учу JavaScript, пишу каталог товаров на чистом JS без библиотек. Вот фрагмент документации DummyJSON: [вставьте текст со страницы https://dummyjson.com/docs/products]. Мне нужно: показать страницу из 6 товаров, искать по названию и фильтровать по категории. Скажи: какой эндпоинт для поиска, какой для категории, какие у них параметры (limit, skip, select, sortBy, order) и можно ли их комбинировать. Ответь таблицей «задача → эндпоинт → параметры» и одним примером запроса на fetch с URLSearchParams. Если в документации чего-то нет, так и скажи, ничего не выдумывай. В конце напиши, что мне проверить руками в консоли.
Скопируйте хороший промпт, подставьте настоящий текст документации — и получите ответ. А теперь проверим главную ловушку: что будет, если поверить выдуманному параметру. Допустим, ИИ уверенно посоветовал ?category=smartphones к поиску:
1) Такой параметр есть в документации? 2) Список лежит там, где ИИ сказал (data.products или просто массив)? 3) Что вернётся при «ничего не найдено» — пустой список или 404? 4) Нужен ли ключ API (в нашем курсе — нет)? 5) Совпадает ли пример с реальным ответом в консоли?
Так выглядит кусок документации. Научитесь читать его без перевода — по названиям параметров и примерам:
Фрагмент документации (сокращённо)
GET https://dummyjson.com/products/category/{category}
Query-параметры:
limit сколько записей вернуть
skip сколько записей пропустить
select поля через запятую (title,price)
sortBy поле для сортировки
order asc | desc
Ответ: { products: [...], total, skip, limit }
Практика: проверяем ИИ и читаем документацию
ЗАДАЧА 7.1
ИИ написал сборку адреса — найдите три бага
средняя
ИИ написал функцию для адреса списка товаров. Она выглядит нормально, но в ней три ошибки: неверный skip, поиск идёт не на тот эндпоинт и значение q не кодируется. Исправьте (параметры в адресе — в порядке limit, skip, q).
Страница 1 должна пропускать 0 записей: (page - 1) * limit. А для непустого q нужен путь /products/search.
Подсказка 2
Соберите new URLSearchParams({ limit, skip }), при непустом q добавьте params.set("q", q) и верните адрес с /search; иначе — без него.
ЗАДАЧА 7.2
Адрес по документации
средняя
По фрагменту выше: три самых дешёвых ноутбука — категория laptops, limit=3, сортировка по цене по возрастанию, поля title,price (параметры в таком порядке: limit, sortBy, order, select). Выведите адрес, затем количество полученных товаров и то, что цены действительно идут по возрастанию. (Запятая в адресе превратится в %2C — это нормально, сервер понимает.)
Можно редактировать · Ctrl + Enter
asyncfunctionmain() {
// 1) соберите адрес через URLSearchParams и выведите его// 2) запросите, выведите количество и проверку сортировки
}
main();
08 Практика на уроке: каталог с поиском, категориями и страницами
Соберём приложение из всех кусочков урока. Это тот самый «рабочий каталог»: поиск, фильтр по категории, постраничная загрузка и данные с настоящего сервера. Строим шаг за шагом — после каждого шага запускайте и смотрите.
Шаг 1. Состояние и адрес запроса
Вся логика приложения держится на одном объекте state (урок 11): что ищем, какая категория, какая страница. А функция buildUrl превращает состояние в адрес. Запустите — пока без сети:
Подключаем fetch и render: запрос по buildUrl(), очистка списка, карточки, а для пустого результата — сообщение. Состояние «Загрузка…» показываем пока ждём.
К state добавляем pages (сколько страниц) — его считаем из data.total после каждого ответа. Функция go(page) проверяет границы, меняет страницу и загружает. updatePager рисует подпись и выключает лишние кнопки.
Шаг 3. Что добавляем к шагу 2
const state = { q: "", category: "", page: 1, pages: 1 };
functionupdatePager() {
info.textContent = `Страница ${state.page} из ${state.pages}`;
prev.disabled = state.page <= 1;
next.disabled = state.page >= state.pages;
}
asyncfunctionload() {
statusEl.textContent = "Загрузка…";
const response = awaitfetch(buildUrl());
const data = await response.json();
state.pages = Math.max(1, Math.ceil(data.total / LIMIT)); // минимум одна страницаrender(data.products);
updatePager();
}
asyncfunctiongo(page) {
if (page < 1|| page > state.pages) return; // за границы не ходим
state.page = page;
awaitload();
}
prev.addEventListener("click", () =>go(state.page - 1));
next.addEventListener("click", () =>go(state.page + 1));
Шаг 4. Поиск и категории
Поиск — это debounce + функция search, которая задаёт state.q, сбрасывает категорию и страницу и загружает. Категория — симметрично. В load добавляем защиту от гонки (lastRequest) и минимальную обработку ошибок. Вот готовое приложение целиком — поищите «phone», выберите категорию, полистайте страницы:
Один источник правды:state. Кнопки, поиск и select не рисуют список сами, они меняют состояние и зовут load(). Один «рисовальщик»:render всегда перестраивает список целиком. Один «адресный стол»:buildUrl. Добавить новый фильтр — значит добавить поле в state, строчку в buildUrl и обработчик события. Остальное не трогаем.
Практика: достраиваем приложение
В двух задачах — то же приложение, но из него вынуто по одному куску. Допишите его: песочница сама прогонит сценарий и напечатает проверку (нужен интернет, проверка занимает несколько секунд).
ЗАДАЧА 8.1
Пагинация: go и updatePager
средняя
В приложении пусты функции updatePager (подпись «Страница N из M» и disabled для кнопок) и go(page) (проверка границ, смена страницы, загрузка). Допишите их.
карточек: 6
страниц больше одной: true
на первой странице «Назад» выкл
страница 2, карточек: 6
go(0) оставила страницу 2
назад вкл, вперёд вкл
Подсказка 1
updatePager: info.textContent = `Страница ${state.page} из ${state.pages}`; затем prev.disabled = state.page <= 1; и next.disabled = state.page >= state.pages;
Подсказка 2
go: если page < 1 || page > state.pages — просто return; иначе state.page = page; await load();
ЗАДАЧА 8.2
Поиск: search со сбросом страницы
сложная
Функция search(text) пока пустая. Она должна: убрать пробелы по краям и записать в state.q, сбросить категорию (state.category и значение catSelect), вернуть страницу на первую и загрузить данные. Тогда после поиска «zzzzqq» приложение покажет «Ничего не найдено», а пустой поиск вернёт обычный список.
страница: 3
поиск: карточек больше нуля: true
страница после поиска: 1
пусто: true, сообщение: Ничего не найдено
сброс поиска, карточек: 6
Подсказка 1
Четыре строки перед загрузкой: state.q = text.trim();, state.category = "";, catSelect.value = "";, state.page = 1;
Подсказка 2
Не забудьте последнюю строку — await load();. Без неё состояние поменяется, а список — нет.
09 Домашнее задание
Добавьте поиск и пагинацию в своё приложение с API. Основа — приложение из урока 14 (два API), каталог из практики сегодня или любая из тем ниже. Все API — публичные и без ключей: проверено, отвечают из браузера.
Выберите тему
Тема
API и документация
Поиск
Пагинация / фильтр
A. Фильмы и сериалы
TVmaze — без ключа (TMDB и OMDb требуют ключ, в курсе их не используем)
https://api.tvmaze.com/search/shows?q=…: массив { score, show }; пусто — []
Каталог https://api.tvmaze.com/shows?page=0 (по ~250, страницы с 0; за пределами — 404). Фильтр по жанру — на клиенте (show.genres). Постер show.image может быть null!
Поиск города: geocoding-api.open-meteo.com/v1/search?name=…&count=10&language=ru. Если ничего не нашли, в ответе нет поля results — используйте data.results ?? []
Выбранный город → api.open-meteo.com/v1/forecast?latitude=…&longitude=…&daily=temperature_2m_max,temperature_2m_min&timezone=auto. Страницы — «показать ещё дни» или листание найденных городов (count до 100)
page, info.pages; серверные фильтры status (alive / dead / unknown) и gender. Всё на сервере — самый простой вариант
Своё публичное API без ключа тоже можно — но заранее согласуйте с преподавателем и проверьте: отвечает ли оно из браузера (CORS) и есть ли поиск и страницы.
Что должно быть в приложении
Данные с API. Функция getJson (из урока 14): проверка response.ok, try / catch, понятное сообщение об ошибке.
Адрес — через URLSearchParams. Ни одной склейки параметров строками. Функция buildUrl или аналог.
Рендер списка. Одна функция render, которая сначала очищает; текст вставляется через textContent; есть состояние «Ничего не найдено».
Поиск с debounce 300–500 мс и защитой от гонки (lastRequest или AbortController).
Фильтр или сортировка — минимум одно, на сервере или на клиенте (осознанно выберите и напишите в README почему).
Пагинация: страницы (кнопки «Назад» / «Вперёд» с disabled на краях и подпись «Страница N из M») или «Показать ещё». При новом поиске страница сбрасывается.
Минимальные состояния: «Загрузка…», «Ничего не найдено», «Ошибка» (подробно — на следующем уроке).
README: какой API взят, ссылка на документацию, как устроены параметры поиска и страниц, какой запрос вы проверяли руками.
ИИ: прочитайте документацию вместе с ИИ по промпту из урока и добавьте в README: промпт, что ИИ предложил, что оказалось неверным или неточным и как вы это проверили запросом.
Что сдавать
Ссылку на репозиторий GitHub (папка с index.html, style.css, script.js и README).
Две-три фразы в README: что сделано и что оказалось самым сложным.
Скриншот приложения (с результатами поиска) — по желанию, как приложение к README.
Как оцениваем (0–10)
Что
Баллы
Данные грузятся с API, адреса через URLSearchParams, ошибки сети обработаны
2
Рендер: очистка, textContent, «Ничего не найдено»
2
Поиск с debounce и защитой от гонки, сброс страницы при новом поиске
2
Пагинация работает на всех страницах, кнопки блокируются на краях
2
Фильтр или сортировка + README и разбор ответа ИИ
2
Перед сдачей
Пройдите сценарий руками: найдите что-нибудь, листните на 2-ю страницу, введите новый поиск (вернулись на 1-ю?), введите чепуху (есть «Ничего не найдено»?), отключите интернет (есть сообщение об ошибке?). Каждую строку, которую подсказал ИИ, вы должны уметь объяснить на защите.
✓ Шпаргалка урока
Параметры запроса идут после ?: ключ=значение через &. Собираем только через new URLSearchParams({…}) — он кодирует пробелы, & и кириллицу. params.get() всегда даёт строку или null.
Читаем API: документация + реальный ответ в консоли. Где список (data.products / data.results / сам массив), где total, что при «пусто» (200 и [] или 404).
Рендер:render(items) всегда сначала очищает (replaceChildren()), потом строит узлы; данные с сервера — через textContent; пустой результат — отдельное сообщение.
Поиск:input + debounce (300–500 мс) + trim() + сброс на первую страницу. От «гонки» — номер запроса lastRequest или AbortController.
Фильтр и сортировка: на сервере (/category/…, sortBy, order) — для больших данных; на клиенте (filter, includes) — для уже загруженных. Не все параметры API сочетаются — читайте документацию.
Пагинация:skip = (page − 1) × limit; страниц Math.ceil(total / limit); «Назад» выключена на 1-й, «Вперёд» — на последней; у Rick and Morty — page и info.pages / info.next.
Структура приложения:state → buildUrl() → load() → render() + updatePager(). События лишь меняют состояние и вызывают load().
С ИИ: документацию API давайте ему текстом; выдуманные параметры ловим запросом в консоли; в README записываем, что ИИ предложил и что мы исправили.