Проект: доска задач — разметка и план
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Дальше — не упражнение на один приём, а маленький проект: доска задач, которая живёт в браузере. В этой статье мы ещё не пишем логику, а решаем, из чего она будет собрана: какая разметка, какое состояние и в каком порядке появляется код.
Доска задач — это три колонки и карточки, которые между ними ездят. Звучит несерьёзно, но именно на таком размере впервые становится больно от бардака: если писать «как пойдёт», через полчаса код превращается в клубок обработчиков, и каждый чинит страницу по-своему.
Поэтому сначала договоримся о границах. Вот всё, что доска должна уметь, и способ проверить каждый пункт руками:
| Умеет | Как проверяем |
|---|---|
| Показывает три колонки: «Сделать», «В работе», «Готово» | В каждой колонке — свой список карточек |
| Добавляет задачу из формы | После отправки карточка появляется в «Сделать», поле пустое |
| Двигает карточку дальше по колонкам | Клик по кнопке меняет статус, карточка переезжает |
| Удаляет карточку | Клик по крестику убирает её со страницы |
| Помнит список после перезагрузки | Данные лежат в localStorage |
Не менее важно, чего в проекте не будет: сервера, авторизации, перетаскивания мышью и анимаций. Карточка переезжает кнопкой — этого хватает, чтобы разобрать события и состояние, а drag-and-drop добавим потом, как надстройку над работающей основой.
Разметка здесь не про внешность, а про договор между HTML и кодом. Классы (column, card) — зацепки для CSS, а смысл живёт в data-атрибутах: data-status у колонки говорит, чьи карточки в ней лежат, data-id у карточки — какой задаче она соответствует. По этим двум атрибутам страница и состояние потом находят друг друга.
Карточки не будем склеивать из строк. В HTML есть <template> — кусок разметки, который браузер разбирает, но не показывает и не оживляет. Готовый фрагмент лежит в свойстве content; его клонируют и заполняют. Разметка карточки остаётся в HTML, где ей и место, а в коде — только подстановка значений. Клонируют обязательно через cloneNode(true), а не вставляют сам content: почему, разобрано в статье «DocumentFragment и производительность».
Сверстайте руками одну колонку с двумя карточками и посмотрите на неё глазами. И только потом стирайте карточки и отдавайте это место рендеру. Разметка, которую ни разу не видели заполненной, почти всегда оказывается неудобной.
Состояние — это то, что можно выписать на бумажку и по одной этой бумажке восстановить всю страницу. Доске хватает двух вещей: списка колонок (он не меняется) и массива задач, у каждой — id, текст и status.
Массив именно плоский, и это выбор, а не лень. Если раскладывать задачи по колонкам во вложенные списки, переезд карточки — «удалить здесь, вставить туда»: две операции, которые легко рассинхронизировать. В плоском виде переезд — смена одного поля.
Идентификатор нужен раньше, чем карточка попадёт на страницу: по нему обработчик найдёт задачу в массиве. В проекте возьмём встроенный crypto.randomUUID(). В примерах он не годится — результат каждый раз новый, а нам нужен предсказуемый вывод, поэтому здесь id выдаёт счётчик.
Первым пишется рендер, и он не знает ни про клики, ни про сохранение. На входе — состояние, на выходе — страница: render() проходит по колонкам, отбирает задачи с нужным статусом и складывает их в список. Данные пока подставляем руками — этого достаточно, чтобы увидеть доску и понять, удобна ли разметка.
replaceChildren сначала выбрасывает старых детей контейнера, потом вставляет новых, поэтому такой рендер можно звать сколько угодно раз с одним и тем же итогом. С append карточки накапливались бы при каждом вызове.Дальше вся логика сведётся к «поменяли состояние — позвали render()»: страницу руками не правит никто.
Соблазн понятный: карточка ведь уже на экране, зачем дублировать её текст в массиве? Затем, что обратно из DOM всё возвращается строками и вперемешку с версткой — служебные значения слипаются с заголовком, и разбирать это приходится вручную.
В массиве число остаётся числом, а строка — строкой. Страница — только отражение состояния, и читать её обратно незачем.
Обработчики появляются только после того, как доска рисуется. Правило одно: событие меняет состояние и зовёт render() — руками страницу не правит никто, иначе через десяток кликов массив и экран разойдутся, и не будет способа понять, кто из них соврал. Слушателя при этом вешаем не на каждую кнопку, а на список: карточки приходят и уходят, а список остаётся, и event.target.closest(...) внутри отвечает, по чему именно кликнули.
Хранение — последний и самый тонкий слой. Состояние у нас из обычных объектов и массивов, значит, оно превращается в строку через JSON.stringify и восстанавливается через JSON.parse. В проекте хранилищем будет localStorage; в примере ниже вместо него подставлен самодельный объект с теми же getItem и setItem — код от такой замены не меняется ни на строку.
Порядок «рендер, события, хранение» выбран не по вкусу. Начав с событий, вы отлаживаете сразу две несуществующие вещи; начав с хранения — сохраняете то, чего ещё нет. А если рендер уже честно рисует состояние, каждый следующий шаг проверяется одним взглядом на доску.
- Границы проекта заданы заранее: три колонки, добавление, переезд и удаление карточки, память после перезагрузки. Сервера, авторизации и drag-and-drop в нём нет.
- Разметка хранит смысл в data-атрибутах:
data-statusу колонки иdata-idу карточки — по ним страница и состояние находят друг друга, а сама карточка живёт в<template>. - Состояние — плоский массив задач с полем
status; переезд карточки сводится к смене одного поля, а не к паре операций над двумя списками. render()перерисовывает колонку целиком черезreplaceChildren, поэтому его безопасно звать сколько угодно раз подряд.- Порядок работы: сначала рендер по руками собранным данным, потом события (меняют состояние и зовут рендер), потом хранение — тонкий слой
JSON.stringifyиJSON.parseповерх состояния.
1/3Зачем карточке в разметке атрибут data-id?
2/3Функцию render() позвали дважды подряд — почему карточки не удвоились?
3/3Слушатель клика висит на списке, а не на каждой кнопке. Что это даёт?
Переезд карточки без порчи исходника
Напишите функцию move(list, id, status): она возвращает новый массив задач, в котором у задачи с нужным id заменён статус. Исходный массив трогать нельзя — на этом свойстве держится вся дальнейшая логика доски.
Достаточно map: он собирает новый массив, а нужную задачу подменяет её копией со свежим статусом. Остальные объекты переезжают в новый массив как есть — их копировать незачем.
После этого проверки печатают done, todo и false: карточка переехала, старый массив цел, а next — уже другой объект. Именно это и позволяет рендеру каждый раз рисовать состояние заново, не гадая, что в нём изменилось.
Счётчик карточек в заголовке колонки
В заголовке каждой колонки есть <span class="count">. Напишите updateCounts(), которая проставит туда число задач этой колонки. Условие: считать нужно по массиву tasks, а не по карточкам на странице.
Идём по колонкам, у каждой спрашиваем её data-status и считаем задачи с таким статусом:
Функция ни разу не спрашивает у страницы, сколько там карточек. Поэтому её можно звать хоть до рендера, хоть после — цифры всегда сойдутся с состоянием.