Сохранение и восстановление доски

проект≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

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

Разделы темы:·

Соблазн велик: сохранить board.innerHTML целиком и при загрузке вставить обратно. Работает ровно до первой правки вёрстки — старые узлы приедут со старыми классами и без обработчиков. Хуже другое: вставляя в страницу сохранённый HTML, вы доверяете строке, до которой у чужого кода уже был доступ. Сохранять надо данные, а разметку строить заново.

Состояние доски — это массив карточек: идентификатор, текст, флаг выполнения. DOM — всего лишь его отражение. Значит, первым делом нужна функция, которая снимает состояние со страницы.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

localStorage — словарь строк, привязанный к origin: протокол, домен и порт. Все вкладки одного сайта видят один и тот же словарь, и он переживает перезагрузку страницы, закрытие браузера и перезагрузку компьютера. Ключ — строка, значение — строка; всё остальное молча приводится к строке, поэтому setItem("board", state) с объектом запишет бесполезное [object Object]. Сериализуем сами — JSON.stringify. Про оба ящика — объём, событие storage и то, чего в них держать нельзя, — статья «localStorage и sessionStorage».

localStoragesessionStorage
Живётпока не удалятдо закрытия вкладки
Кто видитвсе вкладки originтолько своя вкладка
Тип значенийтолько строкитолько строки
Объёмоколо 5 МБоколо 5 МБ

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

localStorage — общая полка, а не сейф: чужим секретам там не место

Момент записи выбирают так, чтобы о нём не пришлось помнить в каждом обработчике. Рабочая схема: любое изменение проходит через одну функцию — она правит состояние, обновляет DOM и в конце зовёт save(). Тогда «забыл сохранить» становится структурно невозможным.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обработчик висит на списке, а не на карточках: карточки приходят и уходят, список остаётся. В настоящем коде вместо writes.push стоял бы store.setItem("board", JSON.stringify(state)). На клик писать в хранилище нормально; на каждое нажатие клавиши в поле ввода — уже нет: запись синхронная и тормозит ввод.

Приём стандартный: save не вызывают напрямую, а планируют через setTimeout на 300 мс, сбрасывая предыдущий таймер через clearTimeout. Пока человек печатает, записи не происходит; она случится один раз, когда он остановится.

У такого откладывания есть хвост: вкладку могут закрыть раньше срабатывания таймера. Поэтому принудительную запись вешают на visibilitychange (когда document.visibilityState стал hidden) и на pagehide. На beforeunload полагаться не стоит — на мобильных он часто не срабатывает вовсе.

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Здесь raw написан руками, чтобы вывод был предсказуем; в жизни это store.getItem("board"). Обратите внимание на textContent: текст карточки пришёл из хранилища, и вставлять его как HTML нельзя — textContent делает любую строку просто буквами.

Строка из хранилища — недоверенный ввод. Её мог оборвать переполнившийся диск, переписать расширение браузера, положить туда прошлогодняя версия вашего же кода. JSON.parse на мусоре бросает SyntaxError, и если исключение случилось на старте — доска не появится вообще. Пустая страница вместо одной потерянной карточки: плохой обмен.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Правило простое: parse — внутрь try, результат — через проверку формы. Не валидатор на двести строк, а три вопроса: это массив? у элемента есть строковые id и text? остальное приводим к ожидаемым типам — строка "да" честно превращается в false, потому что done обязан быть булевым. Карточку без id тихо выбрасываем: остальные важнее. Когда в проекте появится TypeScript, эта же проверка станет границей, которую видит и компилятор, — это разбирает статья «Данные извне — это unknown».

Формат состояния будет меняться: сегодня карточка — строка, завтра у неё появятся срок и цвет. А в хранилище у человека лежит вчерашняя версия, и новый код обязан её пережить. Дешёвый приём — держать номер версии рядом с данными и одну функцию, которая приводит старое к текущему.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Отсюда до сервера ближе, чем кажется: состояние уже отделено от DOM, уже сериализуется, уже проверяется на входе. Меняется только транспорт — вместо store.setItem будет fetch("/api/board", { method: "PUT", body: JSON.stringify(state) }). Добавятся две заботы, которых нет у локального хранилища.

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

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

Если сайт открыт в двух вкладках, они делят одно хранилище — и вторая вкладка ничего не узнает о правках первой. Узнать можно: браузер шлёт на window событие storage, у которого есть key, oldValue и newValue. Поймали событие со своим ключом — перечитали состояние и перерисовали доску.

Главная ловушка: в той вкладке, которая писала, событие storage не срабатывает — только в остальных. Поэтому обработчик storage не заменяет обычную перерисовку после собственной записи, а дополняет её.

  • Сохраняют состояние — массив карточек, а не innerHTML: разметку каждый раз заново строит render.
  • localStorage хранит только строки и только в пределах своего origin; JSON.stringify и JSON.parse — ваша забота.
  • Любое обращение к хранилищу может бросить исключение, поэтому доступ идёт через обёртку с запасным вариантом.
  • Строка из хранилища — недоверенный ввод: parse внутри try/catch плюс проверка формы данных и нормализация типов.
  • Номер версии рядом с данными открывает дорогу к миграциям, а отделённое от DOM состояние — к синхронизации с сервером.

1/3В хранилище ещё ничего не записывали. Что вернёт localStorage.getItem("board")?

2/3Состояние доски лежит в объекте state. Что окажется в хранилище после localStorage.setItem("board", state)?

3/3Зачем при восстановлении оборачивать JSON.parse в try/catch?

Пара save и load для доски

Напишите две функции для ключа board.v1. save(state) сериализует массив карточек и кладёт его в localStorage, ничего не ломая, если хранилище недоступно. load() возвращает массив карточек — и всегда именно массив.

load() должна вернуть [] в четырёх случаях: ключа нет; хранилище бросило исключение; строка не разобралась как JSON; разобралось не в массив. Карточки без строковых id и text отбрасываются, а done приводится к булеву значению.

Каждая опасная операция закрыта своим try, а нормализация вынесена в конец цепочки — так функция всегда возвращает предсказуемый массив.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

Писать не чаще раза в 300 мс

Текст карточки редактируется на лету, и save вызывается на каждое нажатие клавиши. Сделайте scheduleSave(getState), которая откладывает запись на 300 мс и сбрасывает предыдущий отложенный вызов.

Второе требование: если вкладку закрывают или уводят в фон раньше срабатывания таймера, последняя правка не должна потеряться.

Один таймер на всё приложение плюс явный сброс хвоста. timer === null служит признаком того, что несохранённых изменений нет.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверять это удобно счётчиком: увеличивайте его внутри save и печатайте после серии быстрых правок — вместо десятка записей должна остаться одна.