В этой главе появляются первые две функции: одна тянет список с сервера, другая рисует его в HTML. После них приложение уже что-то делает на экране.

Получаем задачи

async function loadTodos() {
  const data = await api('/todos?_limit=10');
  todos = data;
  render();
}

Используем нашу обёртку api() из главы 9.1. Параметр _limit=10 — query-параметр JSONPlaceholder, ограничивающий количество. Без него вернётся 200 задач, нам столько не нужно.

В реальной жизни тут была бы пагинация (см. 5.1), но для мини-проекта ограничения хватит.

Рисуем список

const listEl   = document.querySelector('#list');
const statusEl = document.querySelector('#status');

function render() {
  if (todos.length === 0) {
    listEl.innerHTML = '';
    statusEl.textContent = 'Нет задач — добавьте первую.';
    return;
  }

  listEl.innerHTML = todos.map(t => `
    <li data-id="${t.id}" class="${t.completed ? 'done' : ''}">
      <input type="checkbox" ${t.completed ? 'checked' : ''} />
      <span>${escapeHtml(t.title)}</span>
      <button type="button" class="remove" aria-label="Удалить">×</button>
    </li>
  `).join('');

  statusEl.textContent = '';
}

function escapeHtml(s) {
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

Что важно в этой функции:

  • Атрибут data-id — чтобы по клику на чекбокс или кнопку удаления знать, какая задача обрабатывается.
  • Экранирование escapeHtml — защита от XSS. Сейчас задачи приходят с JSONPlaceholder, риска нет; но привычка экранировать пользовательский ввод спасает от уязвимостей в будущем.
  • Полный re-render на каждое изменение. Неэффективно для тысяч задач, но для нашей сотни — норма и упрощает код.

Делегирование событий

Чекбоксы и кнопки удаления у нас динамические — рендерятся каждый раз заново. Вешать обработчики после каждого render() неудобно. Вместо этого — один обработчик на родительский <ul>, который смотрит, куда конкретно кликнули:

listEl.addEventListener('click', async (e) => {
  const li = e.target.closest('li');
  if (!li) return;
  const id = Number(li.dataset.id);

  if (e.target.classList.contains('remove')) {
    await deleteTodo(id);
  } else if (e.target.matches('input[type="checkbox"]')) {
    await toggleTodo(id, e.target.checked);
  }
});

Функции deleteTodo и toggleTodo мы напишем в следующей главе. e.target.closest('li') идёт вверх по DOM от точки клика и находит ближайший <li> — полезный паттерн для делегированных обработчиков.

Старт приложения

На запуске страницы один вызов:

loadTodos().catch(error => {
  statusEl.textContent = 'Не удалось загрузить: ' + error.message;
});

Если сеть отвалилась или сервер ответил с ошибкой — покажем сообщение прямо в статусной строке. Подробнее про обработку ошибок — в главе 9.4.

Что получилось

На этом моменте в приложении:

  • при загрузке страницы тянется 10 задач;
  • они рендерятся в список с чекбоксами и кнопками удаления;
  • чекбоксы и кнопки уже «слышат» клики — но пока ничего не делают, потому что deleteTodo и toggleTodo ещё не написаны.

В следующей главе подключаем POST и DELETE — и приложение начинает реагировать на действия пользователя.