MakeFirstStep· Frontend + ИИ

Урок 11 · DOM и интерфейс

Состояние приложения и localStorage

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

  • Описывать состояние приложения одним объектом и отличать его от «производных» данных
  • Писать функцию render(state), которая рисует экран только по состоянию
  • Менять состояние через небольшие функции-действия: добавить, отметить, удалить
  • Сохранять и читать данные через localStorage вместе с JSON.stringify и JSON.parse
  • Не падать, когда данных нет, они повреждены или устарели
  • Просить ИИ предложить структуру состояния и сравнивать её со своей
Как работать с этой страницей

Код на тёмном фоне с кнопкой ▶ Запустить — живой: меняйте и запускайте. Блоки «Практика» проверяются автоматически по тексту в консоли. В нескольких задачах есть мини-страница с предпросмотром: слева код (вкладки index.html, style.css, script.js), справа результат. Подсказки спрятаны — сначала попробуйте сами.

Важно про localStorage. Код на этой странице работает в изолированной «песочнице», где настоящего localStorage нет. Мы подробно разберём это в разделе 04 и дадим вам точную заглушку с тем же API. На вашем компьютере (в домашнем задании) вы будете использовать настоящий.

01 Что такое состояние приложения

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

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

Табло на автовокзале в Душанбе. Само табло ничего не «помнит»: оно только показывает расписание, которое лежит в системе диспетчера. Отменили рейс в Худжанд — диспетчер поменял запись, и табло перерисовалось. Расписание в системе — это состояние. Табло — экран. Если бы кто-то вручную исправил надпись на табло, не тронув систему, при следующем обновлении правка бы исчезла, а в зале начался бы хаос.

Как делают новички: данные «разбросаны»

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

Данные в разных местах · Ctrl + Enter
let ownerName = "Мадина";
let balance = 150;
let operations = 2;
let lastOperation = "пополнение";

function showWallet() {
  console.log(`${ownerName}: ${balance} сомони, операций: ${operations}, последняя — ${lastOperation}`);
}

showWallet();

Пока переменных четыре, это терпимо. Но когда их двадцать, нельзя ответить на простые вопросы: что именно нужно сохранить? Что передать функции? Что очистить при выходе пользователя?

Как делают в настоящих проектах: один объект

Все изменяемые данные приложения складывают в один объект — обычно его называют state.

Всё состояние в одном объекте · Ctrl + Enter
const state = {
  ownerName: "Мадина",
  balance: 150,
  history: ["пополнение", "покупка"],
};

function showWallet(state) {
  const last = state.history[state.history.length - 1];
  console.log(`${state.ownerName}: ${state.balance} сомони, последняя операция — ${last}`);
}

showWallet(state);
console.log(JSON.stringify(state)); // всё приложение — одним взглядом

Выгоды сразу видны: функции получают state и больше ничего не ищут; «что сохранять» — это просто state; отладка проще — достаточно вывести один объект и увидеть всё приложение.

Действиепользователь нажал «Добавить»
→
Состояниеstate.todos стал на одну задачу больше
→
Экранrender(state) перерисовал список
Главный цикл любого интерфейса: действие меняет состояние, экран рисуется по состоянию. Весь урок — про этот цикл.

Что класть в состояние, а что — нет

В состоянии хранят исходные факты, которые нельзя вывести из других данных. Всё, что можно посчитать из состояния, в нём хранить не нужно — это производные данные.

Лишнее в состоянии

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

JavaScript
const state = {
  items: [{ title: "Самбуса", price: 5, qty: 3 }],
  total: 15,        // можно посчитать
  itemsCount: 1,    // можно посчитать
};
Только факты

В состоянии — товары. Сумму считает функция, и она всегда актуальна.

JavaScript
const state = {
  items: [{ title: "Самбуса", price: 5, qty: 3 }],
};
const getTotal = (state) =>
  state.items.reduce((sum, i) => sum + i.price * i.qty, 0);
В состояние
Список задач, выбранный фильтр, тема, товары в корзине, имя пользователя, номер текущего шага.
Не в состояние
Сумма корзины, количество невыполненных задач, готовые HTML-строки — всё, что можно вычислить из состояния.
Решаем осознанно
Временные вещи вроде набираемого в поле текста. Их можно держать в состоянии, а можно не сохранять — об этом ещё поговорим в разделе про ИИ.
Частая ошибка

Копирование числа из состояния в локальную переменную и изменение копии. Числа и строки копируются по значению: изменили копию — состояние осталось прежним.

Состояние не изменилось · Ctrl + Enter
const state = { visitors: 10 };

let visitors = state.visitors; // скопировали число
visitors = visitors + 1;       // изменили копию

console.log(visitors);         // → 11
console.log(state.visitors);   // → 10 — в состоянии ничего не поменялось

Читать подробнее: learn.javascript.ru — Объекты · Копирование объектов и ссылки

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

Соберём разрозненные данные в состояние, научимся не хранить лишнее и найдём баг.

ЗАДАЧА 1.1

Кошелёк в одном объекте

лёгкая

Данные кошелька разбросаны по трём переменным. Соберите их в один объект state с теми же именами полей и выведите его через JSON.stringify.

Можно редактировать · Ctrl + Enter
let owner = "Мадина";
let balance = 150;
let currency = "сомони";

// создайте const state = { ... } с полями owner, balance, currency


console.log(JSON.stringify(state));
Ожидаемый результат
{"owner":"Мадина","balance":150,"currency":"сомони"}
Подсказка 1

Объект пишется в фигурных скобках: { ключ: значение, ключ: значение }. Значения можно взять из переменных выше.

Подсказка 2

Если ключ и переменная называются одинаково, можно записать коротко: { owner, balance, currency }.

ЗАДАЧА 1.2

Корзина: считаем, а не храним

лёгкая

Напишите функцию getTotal(state), которая вычисляет сумму корзины по state.items (цена × количество). Сумму в состояние класть не нужно: покупатель меняет количество, а функция всегда считает заново.

Можно редактировать · Ctrl + Enter
const state = {
  items: [
    { title: "Самбуса", price: 5, qty: 3 },
    { title: "Чой", price: 8, qty: 2 },
    { title: "Нон", price: 4, qty: 1 },
  ],
};

// function getTotal(state) { ... }

console.log(getTotal(state));
state.items[2].qty = 3; // покупатель взял ещё нон
console.log(getTotal(state));
Ожидаемый результат
35
43
Подсказка 1

Заведите переменную sum = 0, пройдите по state.items циклом и прибавляйте item.price * item.qty.

Подсказка 2

Или короче: state.items.reduce((sum, item) => sum + item.price * item.qty, 0). Не забудьте return.

ЗАДАЧА 1.3

Найди баг: счётчик, который не считает

средняя

Администратор нажимает «+1» на входе в класс, но число посетителей не растёт. Найдите причину и исправьте функцию addVisitor: она должна менять именно состояние.

Можно редактировать · Ctrl + Enter
const state = { visitors: 0 };

function addVisitor(state) {
  let visitors = state.visitors;
  visitors = visitors + 1;
}

addVisitor(state);
addVisitor(state);
addVisitor(state);
console.log("Посетителей:", state.visitors);
Ожидаемый результат
Посетителей: 3
Подсказка 1

Прочитайте пример выше про «копию по значению». Что именно увеличивает функция — число в состоянии или свою переменную?

Подсказка 2

Меняйте поле напрямую: state.visitors = state.visitors + 1; (или state.visitors++).

02 Рендер от состояния: render(state)

Следующая идея — самая важная в уроке. Экран не живёт своей жизнью: он каждый раз рисуется заново по состоянию. Для этого пишут функцию render. Она получает состояние и показывает его. Больше она ничего не делает: ничего не меняет и ничего не решает.

Чтобы пока не отвлекаться на HTML, «экраном» в первом примере будет консоль:

Можно редактировать · Ctrl + Enter
const state = {
  todos: [
    { id: 1, text: "Купить нон", done: true },
    { id: 2, text: "Сделать домашку по JS", done: false },
  ],
};

function render(state) {
  console.log("--- Список дел ---");
  state.todos.forEach((todo) => {
    const mark = todo.done ? "[x]" : "[ ]";
    console.log(`${mark} ${todo.text}`);
  });
}

render(state);

Теперь то же самое на настоящей странице. Обратите внимание на структуру скрипта: сверху состояние, потом render, потом действие и обработчик клика. Нажмите «Добавить блюдо» и посмотрите, как меняется список:

Первый рендер: список блюд · Ctrl + Enter
<button id="add">Добавить блюдо</button>
<ul id="menu"></ul>
<p id="info"></p>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Правила хорошего render

  1. Читает только состояние. Данные для экрана берём из state, а не из DOM. Не list.children.length, а state.todos.length.
  2. Ничего не меняет. Внутри render нет push, state.x = … и других изменений данных.
  3. Рисует всё целиком. Очистили контейнер и построили заново. Для списков из десятков элементов это мгновенно, а ошибок меньше: не нужно думать, «что именно поменялось».
  4. Вызывается после каждого действия. Изменили состояние — вызвали render(). Забыли — экран «врёт».
  5. Умеет рисовать пустое состояние. Если задач нет, показываем «Список пуст», а не белую пустоту.
Аналогия из жизни

Повар в чайхоне собирает заказ по чеку, а не по памяти. Изменился заказ — посмотрел в чек и приготовил заново. Чек — это состояние. Блюдо на столе — экран. Спорить с чеком бессмысленно: если в чеке «без лука», лука не будет.

Два источника правды

Число задач хранится и в массиве, и «на странице». Забыли обновить одно из них — они расходятся.

JavaScript
// ❌ данные берём из DOM
const count = list.children.length;
if (count >= 5) {
  alert("Слишком много задач");
}
Один источник правды

Правда — в state. DOM — просто его отражение.

JavaScript
// ✓ данные берём из состояния
if (state.todos.length >= 5) {
  alert("Слишком много задач");
}
Частая ошибка

Менять данные и тут же «подправлять» экран вручную (li.textContent = …, li.remove()) в каждом обработчике. Пока элемент один — работает. Когда появятся фильтры, счётчики и сохранение, вы будете забывать обновлять то одно, то другое. Меняем state, вызываем render() — и всё.

Читать подробнее: learn.javascript.ru — DOM-дерево · MDN — Введение в DOM

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

Пишем render для консоли: это та же логика, что и для страницы.

ЗАДАЧА 2.1

Первый render

лёгкая

Напишите render(state): для каждой задачи выведите строку [x] текст (если выполнена) или [ ] текст (если нет).

Можно редактировать · Ctrl + Enter
const state = {
  todos: [
    { id: 1, text: "Купить нон", done: true },
    { id: 2, text: "Сделать домашку по JS", done: false },
    { id: 3, text: "Позвонить маме", done: false },
  ],
};

// function render(state) { ... }

render(state);
Ожидаемый результат
[x] Купить нон
[ ] Сделать домашку по JS
[ ] Позвонить маме
Подсказка 1

Переберите state.todos через forEach или for...of и для каждой задачи выведите одну строку.

Подсказка 2

Пометку выбирайте тернарным оператором: todo.done ? "[x]" : "[ ]".

ЗАДАЧА 2.2

Пустой список

лёгкая

Напишите render с пустым состоянием: если задач нет, выведите Список пуст — добавьте первую задачу и больше ничего. Формат непустого списка прежний ([ ] и [x]).

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

render({ todos: [] });
render({ todos: [{ id: 1, text: "Выучить localStorage", done: false }] });
Ожидаемый результат
Список пуст — добавьте первую задачу
[ ] Выучить localStorage
Подсказка 1

Проверьте state.todos.length === 0 в самом начале функции.

Подсказка 2

Внутри if выведите сообщение и сделайте return — тогда остальной код не выполнится (ранний выход).

ЗАДАЧА 2.3

Найди баг: забыли перерисовать

средняя

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

Можно редактировать · Ctrl + Enter
const state = { todos: [{ id: 1, text: "Хлеб", done: false }] };

function render(state) {
  console.log("--- Экран ---");
  state.todos.forEach((todo) => console.log(`${todo.done ? "[x]" : "[ ]"} ${todo.text}`));
}

function addTodo(text) {
  state.todos.push({ id: state.todos.length + 1, text, done: false });
}

render(state);
addTodo("Молоко");
addTodo("Чой");
Ожидаемый результат
--- Экран ---
[ ] Хлеб
--- Экран ---
[ ] Хлеб
[ ] Молоко
--- Экран ---
[ ] Хлеб
[ ] Молоко
[ ] Чой
Подсказка 1

Состояние поменялось, а экран нет. Кто должен сказать экрану «перерисуйся»?

Подсказка 2

Добавьте render(state); последней строкой в тело addTodo. Тогда любое добавление автоматически обновит экран.

ЗАДАЧА 2.4

Фильтр и счётчик

сложная

В состоянии появилось поле filter: "all", "active" (только невыполненные) или "done" (только выполненные). Напишите render: первой строкой Фильтр: …, затем подходящие задачи в формате [x]/[ ], и последней строкой Осталось: N — число невыполненных задач во всём списке, а не только среди видимых.

Можно редактировать · Ctrl + Enter
const state = {
  filter: "all",
  todos: [
    { id: 1, text: "Нон", done: true },
    { id: 2, text: "Молоко", done: false },
    { id: 3, text: "Чой", done: true },
    { id: 4, text: "Зира", done: false },
  ],
};

// function render(state) { ... }

render(state);
state.filter = "active";
render(state);
state.filter = "done";
render(state);
Ожидаемый результат
Фильтр: all
[x] Нон
[ ] Молоко
[x] Чой
[ ] Зира
Осталось: 2
Фильтр: active
[ ] Молоко
[ ] Зира
Осталось: 2
Фильтр: done
[x] Нон
[x] Чой
Осталось: 2
Подсказка 1

Сначала получите список для показа: через filter оставьте задачи, подходящие под state.filter. Для "all" фильтровать не нужно.

Подсказка 2

Счётчик — отдельное вычисление по state.todos: state.todos.filter((t) => !t.done).length. Видимый список на него не влияет.

03 Действия: как правильно менять состояние

Состояние меняют небольшие функции-действия (actions): addTodo, toggleTodo, removeTodo. Обработчики событий не лезут в данные сами — они только вызывают нужное действие. Каждое действие делает три вещи: меняет состояние → вызывает render() (а в следующих разделах — ещё и сохраняет).

Событиеclick по кнопке или по задаче
→
ДействиеaddTodo(text)
toggleTodo(id)
removeTodo(id)
→
render()экран заново рисуется по state
Обработчик → действие → рендер. Данные меняются только в действиях.

Три приёма для массивов в состоянии

Все изменения списка сводятся к трём операциям. Условимся менять массив не «на месте», а присваивать новый — так проще отследить, что именно поменялось, и именно этот стиль потребуется вам дальше в React.

Что делаемМетодЗапись
Добавитьspread ...state.todos = [...state.todos, newTodo];
Изменить однуmapstate.todos = state.todos.map((t) => t.id === id ? { ...t, done: !t.done } : t);
Удалитьfilterstate.todos = state.todos.filter((t) => t.id !== id);
Три действия и render · Ctrl + Enter
const state = { nextId: 1, todos: [] };

function render() {
  console.log("--- Экран ---");
  if (state.todos.length === 0) console.log("(пусто)");
  state.todos.forEach((t) => console.log(`${t.id}. ${t.done ? "[x]" : "[ ]"} ${t.text}`));
}

function addTodo(text) {
  const todo = { id: state.nextId, text, done: false };
  state.nextId = state.nextId + 1;
  state.todos = [...state.todos, todo];
  render();
}

function toggleTodo(id) {
  state.todos = state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
  render();
}

function removeTodo(id) {
  state.todos = state.todos.filter((t) => t.id !== id);
  render();
}

addTodo("Купить нон");
addTodo("Выучить JSON");
toggleTodo(1);
removeTodo(2);
Аналогия из жизни

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

Откуда брать id

У каждой задачи должен быть уникальный id: по нему мы находим её для отметки или удаления. Соблазн — брать state.todos.length + 1. Он ломается после первого же удаления:

Ловушка: id по длине массива · Ctrl + Enter
let todos = [{ id: 1 }, { id: 2 }, { id: 3 }];

todos = todos.filter((t) => t.id !== 1);          // удалили первую: осталось две
todos = [...todos, { id: todos.length + 1 }];     // новая получила id = 3

console.log(todos.map((t) => t.id).join(","));    // → 2,3,3 — два одинаковых id!

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

Действия в настоящей странице

Вспомните делегирование из урока 9: один обработчик на списке, а нужный элемент находим через event.target.closest(...). Нужную задачу определяем по data-id:

Связка: клик → id → действие
list.addEventListener("click", (event) => {
  const li = event.target.closest("li");
  if (!li) return;                     // клик мимо задач
  toggleTodo(Number(li.dataset.id));   // data-id — строка, переводим в число
});

Обратите внимание на Number(...): значения из dataset всегда строки, а id в состоянии — число. Без перевода t.id === id никогда не сработает: 1 === "1" — это false.

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

Изменить состояние и сохранить результат в отдельной переменной, забыв присвоить обратно в state. Методы map и filter не меняют исходный массив — они возвращают новый.

Результат map потерян · Ctrl + Enter
const state = { todos: [{ id: 1, done: false }] };

state.todos.map((t) => ({ ...t, done: true })); // новый массив никуда не записали
console.log(state.todos[0].done);               // → false — ничего не изменилось

Читать подробнее: learn.javascript.ru — Методы массивов · Делегирование событий

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

Пишем действия для списка дел и корзины. А в последней задаче раздела — действие на настоящей странице.

ЗАДАЧА 3.1

Действие addTodo

лёгкая

Напишите addTodo(state, text): создаёт задачу { id, text, done: false } с id из state.nextId, добавляет её в state.todos и увеличивает nextId на 1.

Можно редактировать · Ctrl + Enter
const state = { nextId: 1, todos: [] };

// function addTodo(state, text) { ... }

addTodo(state, "Купить нон");
addTodo(state, "Выучить JSON");
console.log(JSON.stringify(state));
Ожидаемый результат
{"nextId":3,"todos":[{"id":1,"text":"Купить нон","done":false},{"id":2,"text":"Выучить JSON","done":false}]}
Подсказка 1

Сначала соберите объект задачи, взяв id из state.nextId. Порядок полей: id, text, done.

Подсказка 2

Три строки: создать todo; state.todos = [...state.todos, todo];; state.nextId = state.nextId + 1;.

ЗАДАЧА 3.2

Отметить выполненной

средняя

Напишите toggleTodo(state, id) через map: у задачи с нужным id значение done меняется на противоположное, остальные задачи остаются как были. Функция marks уже написана — она показывает, какие задачи выполнены.

Можно редактировать · Ctrl + Enter
const state = {
  todos: [
    { id: 1, text: "Нон", done: false },
    { id: 2, text: "Молоко", done: false },
    { id: 3, text: "Чой", done: false },
  ],
};

const marks = (state) => state.todos.map((t) => (t.done ? "x" : "-")).join("");

// function toggleTodo(state, id) { ... }

toggleTodo(state, 2);
console.log(marks(state));
toggleTodo(state, 1);
console.log(marks(state));
toggleTodo(state, 2);
console.log(marks(state));
Ожидаемый результат
-x-
xx-
x--
Подсказка 1

Внутри map для каждой задачи спросите: это нужная? Если да — верните копию с обратным done, если нет — саму задачу.

Подсказка 2

state.todos = state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t));

ЗАДАЧА 3.3

Удаление без дубликатов id

средняя

Напишите removeTodo(state, id) через filter. Функция addTodo уже готова и берёт id из nextId. Проверьте: после удаления и добавления новой задачи все id должны остаться уникальными.

Можно редактировать · Ctrl + Enter
const state = {
  nextId: 4,
  todos: [
    { id: 1, text: "Нон", done: false },
    { id: 2, text: "Молоко", done: false },
    { id: 3, text: "Чой", done: false },
  ],
};

function addTodo(state, text) {
  state.todos = [...state.todos, { id: state.nextId, text, done: false }];
  state.nextId = state.nextId + 1;
}

// function removeTodo(state, id) { ... }

removeTodo(state, 2);
addTodo(state, "Зира");
console.log(state.todos.map((t) => t.id).join(","));
console.log("Задач:", state.todos.length);
Ожидаемый результат
1,3,4
Задач: 3
Подсказка 1

filter оставляет элементы, для которых функция вернула true. Какие задачи надо оставить при удалении?

Подсказка 2

state.todos = state.todos.filter((t) => t.id !== id); — оставляем все, кроме задачи с этим id.

ЗАДАЧА 3.4

Корзина: изменить количество

сложная

Напишите changeQty(state, id, delta): прибавляет delta (может быть отрицательным) к количеству товара. Если после этого количество стало 0 или меньше, товар удаляется из корзины. Функция summary уже готова.

Можно редактировать · Ctrl + Enter
const state = {
  items: [
    { id: 1, title: "Самбуса", qty: 2 },
    { id: 2, title: "Чой", qty: 1 },
  ],
};

const summary = (state) =>
  state.items.map((i) => `${i.title}×${i.qty}`).join(", ") || "пусто";

// function changeQty(state, id, delta) { ... }

changeQty(state, 1, +1);
console.log(summary(state));
changeQty(state, 2, -1);
console.log(summary(state));
changeQty(state, 1, -5);
console.log(summary(state));
Ожидаемый результат
Самбуса×3, Чой×1
Самбуса×3
пусто
Подсказка 1

Два шага: сначала map — меняем количество у нужного товара; потом filter — оставляем только товары с qty > 0.

Подсказка 2

state.items = state.items.map((i) => (i.id === id ? { ...i, qty: i.qty + delta } : i)).filter((i) => i.qty > 0);

ЗАДАЧА 3.5

Счётчик на странице

средняя

Допишите счётчик посетителей: render показывает число из state.visitors в элементе #count; кнопка «+1» увеличивает его, а кнопка «−1» уменьшает, но не ниже нуля. Менять нужно состояние, а потом вызывать render(). Нижние строки скрипта нажимают кнопки сами и выводят проверку — не удаляйте их.

Счётчик посетителей курса · Ctrl + Enter
<p>Посетителей: <b id="count"></b></p>
<button id="plus">+1</button>
<button id="minus">−1</button>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
после +1: 3 3
после −1: 0 0
Подсказка 1

В render достаточно одной строки: countEl.textContent = state.visitors;. Остальное — в обработчиках, и в каждом в конце вызывается render().

Подсказка 2

Не ниже нуля: state.visitors = Math.max(0, state.visitors - 1); или проверка if (state.visitors > 0).

04 localStorage: память браузера

Сейчас всё состояние живёт в переменной. Обновили страницу — переменные создаются заново, и состояние обнуляется. Чтобы данные пережили перезагрузку, их нужно положить туда, где они не пропадают вместе со страницей. Для этого у браузера есть localStorage — небольшое хранилище «ключ → строка», привязанное к сайту.

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

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

Что нужно знать о localStorage

  • Хранит пары «ключ — строка». Ключи тоже строки.
  • Данные не исчезают при перезагрузке страницы, закрытии вкладки и даже перезапуске браузера.
  • У каждого сайта своё хранилище: makefirststep.tj не видит данные example.com.
  • Объём — около 5 МБ на сайт: для списка дел или настроек этого с запасом.
  • Пользователь может очистить его в настройках или вручную изменить в DevTools. Данным из него доверять нельзя так же, как и вводу в форму.
  • Не храните там пароли и секреты: любой скрипт на странице может их прочитать.

Методы

МетодЧто делаетПример
setItem(key, value)Записывает значение (строку) по ключу. Старое значение заменяет.localStorage.setItem("theme", "dark")
getItem(key)Читает значение. Если ключа нет — возвращает null.localStorage.getItem("theme")
removeItem(key)Удаляет одну запись.localStorage.removeItem("theme")
clear()Удаляет все записи этого сайта.localStorage.clear()
Важно: в песочнице этого курса настоящего localStorage нет

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

В песочнице выдаст ошибку · Ctrl + Enter
localStorage.setItem("theme", "dark");
console.log(localStorage.getItem("theme"));

Ошибка доступа — это не ваша вина и не баг урока. Чтобы вы всё равно могли тренироваться, мы даём заглушку: объект storage с теми же методами setItem, getItem, removeItem, clear. Она хранит данные в памяти, пока работает ваш код. А чтобы «перезагрузку страницы» можно было показать и в песочнице, мы будем вызывать функцию запуска приложения несколько раз подряд в одном скрипте: хранилище при этом остаётся, а переменные приложения — нет.

Заглушка storage — тот же API, что у localStorage · Ctrl + Enter
// ── заглушка localStorage ─────────────────────────────────────
// В песочнице настоящий localStorage недоступен — на вашем компьютере
// используйте window.localStorage. Остальной код при этом не меняется.
function createStorage() {
  let data = {};
  return {
    getItem(key) {
      return Object.hasOwn(data, key) ? data[key] : null;
    },
    setItem(key, value) {
      data[key] = String(value); // как и настоящий: всё превращается в строку
    },
    removeItem(key) {
      delete data[key];
    },
    clear() {
      data = {};
    },
  };
}
const storage = createStorage();
// ─────────────────────────────────────────────────────────────

storage.setItem("theme", "dark");
console.log(storage.getItem("theme"));    // → dark
console.log(storage.getItem("language")); // → null — такого ключа нет
storage.removeItem("theme");
console.log(storage.getItem("theme"));    // → null
Как перейти на настоящий localStorage

Весь код в уроке пишите через переменную storage. На вашем компьютере, в обычном HTML-файле, достаточно заменить заглушку одной строкой — всё остальное работает без изменений:

На своём компьютере
const storage = window.localStorage;

Хотите попробовать настоящий прямо сейчас? Откройте любой сайт (хоть этот), нажмите F12 → вкладка Console и введите localStorage.setItem("test", "привет"), затем localStorage.getItem("test"). Увидеть записи можно на вкладке Application (в Firefox — Storage) → Local Storage. Там же их можно редактировать и удалять.

Всё превращается в строку

Это главная ловушка. Положили число — получили строку. Положили объект — получили текст "[object Object]".

Ловушка: только строки · Ctrl + Enter
function createStorage() {
  let data = {};
  return {
    getItem: (key) => (Object.hasOwn(data, key) ? data[key] : null),
    setItem: (key, value) => { data[key] = String(value); },
    removeItem: (key) => { delete data[key]; },
    clear: () => { data = {}; },
  };
}
const storage = createStorage();

storage.setItem("visits", 5);
const visits = storage.getItem("visits");
console.log(typeof visits);   // → string, а не number
console.log(visits + 1);      // → 51 — строки склеиваются!
console.log(Number(visits) + 1); // → 6 — вот так правильно

storage.setItem("user", { name: "Фарход" });
console.log(storage.getItem("user")); // → [object Object] — данные потеряны

Числа переводим обратно через Number(...). А для объектов и массивов есть специальный инструмент — JSON. Он как раз и нужен, чтобы «упаковать» объект в строку и «распаковать» обратно. Ему посвящён следующий раздел.

«Перезагрузка» в песочнице

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

Счётчик визитов: каждый вызов — «новая загрузка» · Ctrl + Enter
function createStorage() {
  let data = {};
  return {
    getItem: (key) => (Object.hasOwn(data, key) ? data[key] : null),
    setItem: (key, value) => { data[key] = String(value); },
    removeItem: (key) => { delete data[key]; },
    clear: () => { data = {}; },
  };
}
const storage = createStorage();

function openSite() {
  const saved = storage.getItem("visits");        // первый раз — null
  const visits = saved === null ? 1 : Number(saved) + 1;
  storage.setItem("visits", visits);
  console.log(`Добро пожаловать! Это визит №${visits}`);
}

openSite(); // пользователь открыл сайт
openSite(); // перезагрузил страницу
openSite(); // открыл на следующий день
Частая ошибка

Забыть, что при первом запуске getItem вернёт null. Если сразу сделать Number(null) + 1, получится 1 — случайно сработает, а вот null.length или JSON.parse(...).items уже упадут. Всегда думайте: «а что, если здесь пусто?».

Читать подробнее: learn.javascript.ru — LocalStorage, sessionStorage · MDN — Window.localStorage

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

В задачах ниже используйте готовую заглушку storage (она записана в первых строках — менять её не нужно).

ЗАДАЧА 4.1

Записать и прочитать

лёгкая

Сохраните в хранилище ключ academy со значением MakeFirstStep. Затем прочитайте и выведите его. Потом прочитайте и выведите ключ teacher, которого нет.

Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйте
const storage = (() => { let d = {}; return {
  getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
  removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();

// ваш код
Ожидаемый результат
MakeFirstStep
null
Подсказка 1

Запись: storage.setItem("academy", "MakeFirstStep"). Чтение: storage.getItem("academy") — результат нужно вывести через console.log.

Подсказка 2

Для ключа, которого нет, getItem вернёт null — исключения не будет.

ЗАДАЧА 4.2

Предскажите: число или строка?

лёгкая

Сначала предскажите вывод и запустите. Вторая строка сейчас покажет 905, а нужно 95. Исправьте код: первая строка должна по-прежнему выводить string.

Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйте
const storage = (() => { let d = {}; return {
  getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
  removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();

storage.setItem("score", 90);
const score = storage.getItem("score");
console.log(typeof score);
console.log(score + 5);
Ожидаемый результат
string
95
Подсказка 1

Из getItem всегда приходит строка. А "90" + 5 — это склейка строк.

Подсказка 2

Переведите значение в число перед сложением: Number(score) + 5.

ЗАДАЧА 4.3

Счётчик визитов

средняя

Напишите openSite(): читает из хранилища ключ visits (если его нет — считаем, что визитов 0), увеличивает на 1, сохраняет и выводит Визит №N. После трёх «открытий» выведите, что лежит в хранилище.

Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйте
const storage = (() => { let d = {}; return {
  getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
  removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();

// function openSite() { ... }

openSite();
openSite();
openSite();
console.log("В хранилище:", storage.getItem("visits"));
Ожидаемый результат
Визит №1
Визит №2
Визит №3
В хранилище: 3
Подсказка 1

Прочитайте saved = storage.getItem("visits"). Если saved === null — это первый визит, иначе Number(saved) плюс 1.

Подсказка 2

Запишите новое число обратно через storage.setItem("visits", visits) — заглушка сама превратит его в строку.

ЗАДАЧА 4.4

Тёмная тема, которая помнит выбор

средняя

Напишите toggleTheme(): читает ключ theme (если его нет — тема light), переключает на противоположную (light ↔ dark), сохраняет и выводит новую тему. В конце удалите ключ и убедитесь, что чтение даёт null.

Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйте
const storage = (() => { let d = {}; return {
  getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
  removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();

// function toggleTheme() { ... }

toggleTheme();
toggleTheme();
toggleTheme();
storage.removeItem("theme");
console.log(storage.getItem("theme"));
Ожидаемый результат
dark
light
dark
null
Подсказка 1

Текущая тема: storage.getItem("theme") || "light" — если пришёл null, возьмётся "light".

Подсказка 2

Новая тема: current === "light" ? "dark" : "light". Сохраните её и выведите.

05 JSON: упаковываем состояние в строку

С JSON вы познакомились на уроке 7. Здесь он решает конкретную задачу: localStorage принимает только строки, а наше состояние — объект с массивами. Поэтому перед записью мы упаковываем объект в строку (JSON.stringify), а после чтения — распаковываем обратно (JSON.parse).

state{ todos: [...] }
stringify →
строка'{"todos":[...]}'
setItem →
localStorageключ → строка
Сохранение: объект → строка JSON → хранилище. Чтение идёт в обратную сторону: getItem → parse → объект.
Туда и обратно · Ctrl + Enter
const state = {
  filter: "all",
  todos: [{ id: 1, text: "Купить нон", done: false }],
};

const raw = JSON.stringify(state);    // объект → строка
console.log(raw);
console.log(typeof raw);              // → string

const restored = JSON.parse(raw);     // строка → объект
console.log(restored.todos[0].text);  // → Купить нон
console.log(restored === state);      // → false — это новая копия, а не тот же объект
Аналогия из жизни

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

Пара функций saveState / loadState

Чтобы не писать JSON.stringify и setItem в десяти местах, прячем их в две функции. Всё приложение обращается к хранилищу только через них:

Шаблон: сохранить и прочитать состояние
const KEY = "todo-app:v1";   // ключ: имя приложения + версия формата

function saveState(state) {
  storage.setItem(KEY, JSON.stringify(state));
}

function loadState() {
  const raw = storage.getItem(KEY);
  return raw === null ? null : JSON.parse(raw);
}

Ключ с префиксом todo-app: защищает от конфликта с другими приложениями на том же сайте, а :v1 пригодится, когда вы поменяете формат состояния: новая версия сможет читать новый ключ и не споткнётся о старые данные.

Что не переживёт упаковку

JSON — это только текст с простыми значениями. Не всё, что есть в объекте, можно в него превратить:

Что в объектеЧто получится после stringify → parse
Строки, числа, true/false, null, массивы, объектыБез изменений
undefinedПоле пропадает
Функции и методыПоле пропадает
Дата new Date()Становится строкой (ISO-формат), а не датой
NaN, InfinityСтановятся null
Что потерялось по дороге · Ctrl + Enter
const task = {
  title: "Сдать проект",
  deadline: new Date(0),    // дата → строка
  note: undefined,          // пропадёт
  remind() { console.log("!"); }, // пропадёт
};

const copy = JSON.parse(JSON.stringify(task));
console.log(JSON.stringify(copy));
console.log(typeof copy.deadline);  // → string
console.log(typeof copy.remind);    // → undefined

Вывод: в состоянии храните «простые» данные — строки, числа, булевы значения, массивы и объекты. Даты — как числа (Date.now()) или строки. Функции в состояние не кладут вообще: функции — это действия, они живут в коде.

Ошибки JSON

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

Этот код выдаст ошибку · Ctrl + Enter
const raw = "{'text': 'нон'}";       // одинарные кавычки — это не JSON
const data = JSON.parse(raw);        // ✖ SyntaxError
console.log(data);
Совет

Чтобы быстро «разглядеть» большое состояние в консоли, вызывайте JSON.stringify(state, null, 2) — вывод будет с отступами, по две пробела на уровень. Для сохранения в localStorage отступы не нужны.

Читать подробнее: learn.javascript.ru — Формат JSON, метод toJSON · MDN — JSON

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

Упаковываем, распаковываем и ищем, что теряется по дороге.

ЗАДАЧА 5.1

Упаковать настройки

лёгкая

Превратите объект настроек в строку JSON. Выведите саму строку, а затем её тип.

Можно редактировать · Ctrl + Enter
const settings = {
  theme: "dark",
  fontSize: 16,
  favorites: ["курутоб", "шакароб"],
};

// ваш код: получите строку и выведите её и её тип
Ожидаемый результат
{"theme":"dark","fontSize":16,"favorites":["курутоб","шакароб"]}
string
Подсказка 1

Нужный метод — JSON.stringify(settings). Результат сохраните в переменную.

Подсказка 2

Тип выводится так: console.log(typeof raw).

ЗАДАЧА 5.2

Распаковать данные

лёгкая

Из хранилища пришла строка. Распакуйте её и выведите: имя, количество тегов и тип поля age.

Можно редактировать · Ctrl + Enter
const raw = '{"name":"Фарход","age":21,"tags":["js","css","html"]}';

// ваш код
Ожидаемый результат
Фарход
3
number
Подсказка 1

const data = JSON.parse(raw); — после этого data обычный объект.

Подсказка 2

data.name, data.tags.length и typeof data.age — три вызова console.log.

ЗАДАЧА 5.3

Предскажите: что потеряется

средняя

Сначала на бумаге предскажите, что выведут четыре строки. Запустите и сверьтесь. Затем добавьте пятую строку: восстановите дату из строки через new Date(copy.deadline) и выведите её год через getUTCFullYear().

Можно редактировать · Ctrl + Enter
const task = {
  title: "Сдать проект",
  done: false,
  deadline: new Date(0),
  note: undefined,
  remind() {
    console.log("!");
  },
};

const copy = JSON.parse(JSON.stringify(task));
console.log(JSON.stringify(copy));
console.log(typeof copy.deadline);
console.log("note" in copy);
console.log(typeof copy.remind);
// пятая строка — ваша
Ожидаемый результат
{"title":"Сдать проект","done":false,"deadline":"1970-01-01T00:00:00.000Z"}
string
false
undefined
1970
Подсказка 1

Вспомните таблицу выше: дата превращается в строку, а undefined и функции пропадают.

Подсказка 2

console.log(new Date(copy.deadline).getUTCFullYear()); — новая дата собирается из строки.

ЗАДАЧА 5.4

saveState и loadState

средняя

Напишите пару saveState(state) и loadState() с ключом todo-app:v1. Проверка: сохраните состояние, «сломайте» его в памяти и убедитесь, что loadState() вернул сохранённый вариант.

Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйте
const storage = (() => { let d = {}; return {
  getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
  removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();

const KEY = "todo-app:v1";

// function saveState(state) { ... }
// function loadState() { ... }

let state = {
  todos: [
    { id: 1, text: "Нон", done: true },
    { id: 2, text: "Чой", done: false },
  ],
};

saveState(state);
state = { todos: [] }; // «перезагрузка»: в памяти всё потеряно

const restored = loadState();
console.log(restored.todos.length);
console.log(restored.todos[0].text);
Ожидаемый результат
2
Нон
Подсказка 1

В saveState — одна строка: storage.setItem(KEY, JSON.stringify(state)).

Подсказка 2

В loadState: return JSON.parse(storage.getItem(KEY)); — для этой задачи данные уже есть, пустой случай разберём в следующем разделе.

06 Пустые, битые и устаревшие данные

Код, который работает «когда всё хорошо», — это половина дела. Вторая половина — что произойдёт, когда данных нет, они повреждены или были записаны старой версией приложения. С localStorage такое случается постоянно:

  • Первый запуск. Пользователь пришёл впервые — в хранилище пусто, getItem вернёт null.
  • Мусор. Кто-то поправил запись в DevTools, запись оборвалась, другое приложение записало под тем же ключом что-то своё.
  • Устаревший формат. Вчера в состоянии не было поля filter, сегодня вы его добавили, а в браузере пользователя лежит вчерашний снимок.
Аналогия из жизни

Склад на курсе перед началом занятий. Администратор открывает шкаф за маркерами: а вдруг шкаф пуст (первый день), маркеры высохли (битые данные) или вместо них лежат стикеры от прошлого семестра (старый формат)? Хороший администратор не паникует: у него есть запасной комплект. Запасной комплект в коде — это состояние по умолчанию.

Пустое хранилище

Если ключа нет, getItem возвращает null. Проверьте это явно и верните состояние по умолчанию:

Первый запуск: null → состояние по умолчанию · Ctrl + Enter
function createStorage() {
  let data = {};
  return {
    getItem: (key) => (Object.hasOwn(data, key) ? data[key] : null),
    setItem: (key, value) => { data[key] = String(value); },
    removeItem: (key) => { delete data[key]; },
    clear: () => { data = {}; },
  };
}
const storage = createStorage();

const KEY = "todo-app:v1";
const DEFAULT_STATE = { nextId: 1, todos: [] };

function loadState() {
  const raw = storage.getItem(KEY);
  if (raw === null) return { ...DEFAULT_STATE, todos: [] }; // первый запуск
  return JSON.parse(raw);
}

console.log(JSON.stringify(loadState())); // хранилище пустое

Обратите внимание на { ...DEFAULT_STATE, todos: [] }: мы возвращаем копию по умолчанию, а не сам объект. Иначе все изменения попадут в «запасной комплект», и при следующем сбросе он будет уже не пустым.

Битый JSON: try / catch

Если в хранилище лежит строка, которая не является корректным JSON, JSON.parse бросает исключение. Его перехватывают через try / catch (вы уже видели это в уроке 7) и возвращаются к состоянию по умолчанию:

try / catch вокруг JSON.parse · Ctrl + Enter
function safeParse(raw) {
  try {
    return JSON.parse(raw);
  } catch (error) {
    console.log("Данные повреждены:", error.name);
    return null;
  }
}

console.log(safeParse('{"ok":true}'));  // → { ok: true }
console.log(safeParse("{oops"));        // → Данные повреждены: SyntaxError, потом null

Неверная форма: проверяем, что внутри

Строка может быть корректным JSON, но совсем не тем, что мы ждали: число 42, строка, null, объект вместо массива. Поэтому после parse проверяем форму данных:

Проверка формы данных · Ctrl + Enter
function isValidTodos(value) {
  return Array.isArray(value);
}

console.log(isValidTodos([]));            // → true
console.log(isValidTodos({ a: 1 }));      // → false — объект, а не массив
console.log(isValidTodos(JSON.parse("42"))); // → false — число
console.log(isValidTodos(JSON.parse("null"))); // → false
Любопытная ловушка

JSON.parse(null) не падает, а возвращает null — потому что null сначала превращается в строку "null", а это корректный JSON. В итоге JSON.parse(storage.getItem(key)) при пустом хранилище «работает», но возвращает null, и уже следующая строка state.todos падает с TypeError. Поэтому проверка raw === null нужна всегда.

Состояние устарело: подмешиваем значения по умолчанию

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

Слияние: старые данные + новые поля · Ctrl + Enter
const DEFAULT_STATE = { filter: "all", theme: "light", todos: [] };

// вчерашний снимок: полей filter и theme ещё не было
const saved = { todos: [{ id: 1, text: "Нон", done: false }] };

const state = { ...DEFAULT_STATE, ...saved };
console.log(JSON.stringify(state));
// → поля filter и theme взяты по умолчанию, todos — из сохранённого

Всё вместе: надёжный loadState

  1. Достали строку через getItem. Если null — вернули состояние по умолчанию.
  2. Распаковали внутри try / catch. Ошибка — вернули состояние по умолчанию.
  3. Проверили форму: это вообще объект (а не число, строка, null, массив)? Нет — состояние по умолчанию.
  4. Подмешали значения по умолчанию для недостающих полей и проверили важные поля: например, todos обязан быть массивом.
Шаблон надёжной загрузки
function loadState() {
  const raw = storage.getItem(KEY);
  if (raw === null) return createDefaultState();     // 1. пусто

  let saved;
  try {
    saved = JSON.parse(raw);                         // 2. битый JSON
  } catch {
    return createDefaultState();
  }

  if (typeof saved !== "object" || saved === null || Array.isArray(saved)) {
    return createDefaultState();                     // 3. это не объект состояния
  }

  const state = { ...createDefaultState(), ...saved }; // 4. добавили недостающее
  if (!Array.isArray(state.todos)) state.todos = [];   //    и починили важное поле
  return state;
}

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

Читать подробнее: learn.javascript.ru — Обработка ошибок, try…catch · MDN — Array.isArray()

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

Учимся не падать. Заглушка storage уже включена в каждую задачу.

ЗАДАЧА 6.1

Первый запуск

лёгкая

Напишите loadTodos(): возвращает массив задач из ключа todos, а если ключа нет — пустой массив. Функция saveTodos уже готова.

Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйте
const storage = (() => { let d = {}; return {
  getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
  removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();

function saveTodos(todos) {
  storage.setItem("todos", JSON.stringify(todos));
}

// function loadTodos() { ... }

console.log(loadTodos().length); // первый запуск
saveTodos(["Нон", "Чой"]);
console.log(loadTodos().length);
Ожидаемый результат
0
2
Подсказка 1

Сначала прочитайте raw = storage.getItem("todos") и проверьте, не null ли он.

Подсказка 2

if (raw === null) return []; и затем return JSON.parse(raw);

ЗАДАЧА 6.2

Повреждённые данные

средняя

В хранилище кто-то записал мусор. Дополните loadTodos(): если JSON.parse упал, выведите Данные повреждены — начинаем с пустого списка и верните пустой массив.

Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйте
const storage = (() => { let d = {}; return {
  getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
  removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();

function loadTodos() {
  const raw = storage.getItem("todos");
  if (raw === null) return [];
  // ваш код: обработайте повреждённые данные
  return JSON.parse(raw);
}

storage.setItem("todos", "{oops");
console.log(loadTodos().length);
storage.setItem("todos", '["Нон","Чой"]');
console.log(loadTodos().length);
Ожидаемый результат
Данные повреждены — начинаем с пустого списка
0
2
Подсказка 1

Оберните только строку с JSON.parse в try { ... } catch { ... }.

Подсказка 2

В try — return JSON.parse(raw);, в catch — console.log(...) с сообщением и return [];.

ЗАДАЧА 6.3

Валидный JSON, но не то

средняя

Теперь в хранилище лежит корректный JSON, но не массив. Допишите проверку формы: loadTodos() должна возвращать только массив — во всех остальных случаях пустой массив.

Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйте
const storage = (() => { let d = {}; return {
  getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
  removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();

function loadTodos() {
  const raw = storage.getItem("todos");
  if (raw === null) return [];
  try {
    const data = JSON.parse(raw);
    // ваш код: проверьте, что data — массив
    return data;
  } catch {
    return [];
  }
}

for (const raw of ['{"a":1}', "42", '["Нон"]', "null"]) {
  storage.setItem("todos", raw);
  console.log(loadTodos().length);
}
Ожидаемый результат
0
0
1
0
Подсказка 1

Для проверки «это массив?» есть готовая функция Array.isArray(data). Обычная проверка typeof тут не поможет: массив тоже «объект».

Подсказка 2

return Array.isArray(data) ? data : [];

ЗАДАЧА 6.4

Надёжный loadState

сложная

Соберите полную функцию loadState() по шаблону из урока. Состояние по умолчанию: { filter: "all", theme: "light", todos: [] }. Правила: пусто, битый JSON или не объект (число, строка, null, массив) — вернуть состояние по умолчанию. Иначе подмешать недостающие поля, а если todos оказался не массивом — заменить его на [] (остальные сохранённые поля, например filter, остаются).

Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйте
const storage = (() => { let d = {}; return {
  getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
  removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();

const KEY = "todo-app:v1";
const createDefaultState = () => ({ filter: "all", theme: "light", todos: [] });

// function loadState() { ... }

console.log(JSON.stringify(loadState()));                 // 1. пусто

storage.setItem(KEY, '{"todos":[{"id":1,"text":"Нон","done":false}]}');
console.log(JSON.stringify(loadState()));                 // 2. старый формат

storage.setItem(KEY, "}{");
console.log(JSON.stringify(loadState()));                 // 3. мусор

storage.setItem(KEY, '{"filter":"done","todos":"oops"}');
console.log(JSON.stringify(loadState()));                 // 4. todos неверного типа

storage.setItem(KEY, "42");
console.log(JSON.stringify(loadState()));                 // 5. не объект
Ожидаемый результат
{"filter":"all","theme":"light","todos":[]}
{"filter":"all","theme":"light","todos":[{"id":1,"text":"Нон","done":false}]}
{"filter":"all","theme":"light","todos":[]}
{"filter":"done","theme":"light","todos":[]}
{"filter":"all","theme":"light","todos":[]}
Подсказка 1

Идите по шагам шаблона: null → try / catch → проверка формы → слияние. Каждый «плохой» случай заканчивается return createDefaultState().

Подсказка 2

Слияние: const state = { ...createDefaultState(), ...saved };. После него проверьте Array.isArray(state.todos) — если нет, поставьте state.todos = [];. Для случая «не объект» проверьте typeof saved !== "object" || saved === null сразу после parse.

07 ИИ-блок: просим предложить структуру состояния

Самый важный момент в любом приложении — какие данные вы храните и в каком виде. Ошибка здесь дорого обходится: переделывать структуру состояния, когда уже написаны render, действия и сохранение, — всё равно что перестраивать фундамент у готового дома. Поэтому именно сюда стоит привлечь ИИ — но как второго мнения, а не как автора.

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

1) Сначала 15 минут сами: нарисуйте свой state на бумаге или в коде. 2) Только потом промпт. 3) Не вставляйте в проект код, который не можете объяснить строчку за строчкой. 4) Решение принимаете вы: ИИ предлагает, вы сравниваете и выбираете.

Порядок работы

  1. Своя версия. Опишите приложение одним предложением и набросайте объект state. Отметьте, что из него вы сохраните в localStorage.
  2. Промпт по формуле: контекст → задача → ограничения → формат ответа → проверка.
  3. Сравнение. Выпишите отличия в таблицу: поле — у меня — у ИИ — что решил и почему.
  4. Проверка. Для каждого спорного места спросите себя: «что будет, если здесь пусто или лежит мусор?».

Плохой и хороший промпт

Придумай как хранить данные для моего приложения

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

Контекст: я новичок, пишу на чистом JavaScript без библиотек, учебный проект — трекер привычек. Пользователь может добавить привычку, отметить её выполненной сегодня, удалить, и переключать фильтр «все / не выполненные». Данные должны переживать перезагрузку страницы. Задача: предложи структуру объекта состояния и объясни, что из него класть в localStorage. Ограничения: только строки, числа, булевы значения, массивы и объекты (то, что переживёт JSON); не хранить то, что можно вычислить; у каждой привычки уникальный id. Формат ответа: сначала пример объекта state как JS-код; затем список полей — по строке «поле — зачем нужно»; затем что сохраняем, а что нет. Проверка: назови 2 случая, когда сохранённые данные могут оказаться некорректными, и как с ними справиться.

Это развёрнутый вариант шаблона из шпаргалки курса: «Предложи структуру объекта состояния для приложения [описание]. Объясни, почему такие поля и что положить в localStorage». Мы добавили контекст, ограничения и проверку — и получили ответ, который можно сравнивать.

Чек-лист: как проверять ответ ИИ

  • Нет ли производных данных. Если в состоянии лежит total, doneCount или готовый HTML — их можно вычислить, значит, они лишние.
  • Уникальные id. Откуда они берутся? Есть ли nextId, или ИИ предлагает length + 1 (мы видели, чем это кончается)?
  • Простые типы. Нет ли дат, функций, undefined, которые не переживут JSON?
  • Что сохранять. Нужно ли сохранять временное — например, текст, который пользователь набирает в поле прямо сейчас? Часто нет: после перезагрузки он не нужен.
  • Версия формата. Предложил ли ИИ ключ вида app:v1? Что произойдёт при смене структуры?
  • Пустые и битые данные. ИИ часто «забывает» про первый запуск и повреждённые данные — добавьте это сами.

Таблица сравнения

ВопросМоя версияВерсия ИИРешение и причина
Как хранить привычки?habits: []habits: [{ id, title, doneDates }]Беру массив объектов с id — нужен для удаления
Где счётчик id?нетnextIdДобавляю nextId — защита от дубликатов
Что сохранять?всё состояниевсё, кроме текста в поле вводаСогласен: поле ввода не сохраняю

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

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

Сначала своя структура, потом проверка «ответа ИИ» с ошибками.

ЗАДАЧА 7.1

Моя структура против структуры ИИ

средняя

Придумайте состояние для приложения «Расписание курса» (занятия с названием, днём недели и временем; можно отметить «посетил»; есть выбранный день для просмотра). Сначала запишите свою версию как объект state и выведите её через JSON.stringify(state, null, 2). Затем отправьте ИИ промпт по образцу выше, заполните таблицу сравнения в тетради и отметьте, что вы поменяли и почему. Эта задача проверяется преподавателем — отметьте её сами, когда закончите.

Можно редактировать · Ctrl + Enter
// 15 минут сами, без ИИ
const state = {
  // ваша структура
};

console.log(JSON.stringify(state, null, 2));
Подсказка 1

Подумайте: что здесь «исходные факты» (занятия, отметки, выбранный день), а что можно вычислить (сколько занятий посещено, список занятий на выбранный день)?

Подсказка 2

У каждого занятия нужен id, а в корне состояния — nextId. День недели проще хранить строкой или числом 1–7, а не датой.

ЗАДАЧА 7.2

Проверьте «ответ ИИ»: три ошибки

сложная

ИИ написал сохранение списка привычек, но в коде три ошибки — все из чек-листа. Найдите их и исправьте так, чтобы вывод был таким, как ниже. Менять можно функции и состояние; три нижних блока вывода не трогайте.

Можно редактировать · Ctrl + Enter
// заглушка localStorage — не меняйте
const storage = (() => { let d = {}; return {
  getItem: (k) => (Object.hasOwn(d, k) ? d[k] : null), setItem: (k, v) => { d[k] = String(v); },
  removeItem: (k) => { delete d[k]; }, clear: () => { d = {}; } }; })();

const habits = { nextId: 1, items: [] };

function addHabit(title) {
  habits.items = [...habits.items, { id: habits.items.length + 1, title }];
}

function save() {
  storage.setItem("habits", habits);
}

function load() {
  return JSON.parse(storage.getItem("habits"));
}

addHabit("Зарядка");
addHabit("Чтение");
addHabit("Вода");
habits.items = habits.items.filter((h) => h.id !== 1); // удалили «Зарядку»
addHabit("Сон");
console.log(habits.items.map((h) => h.id).join(","));

save();
console.log(load().items.length);

storage.clear(); // «чистый» браузер
console.log(load().items.length);
Ожидаемый результат
2,3,4
3
0
Подсказка 1

Запускайте после каждой правки и читайте ошибку. Первая проблема видна по строке с id (повторы!), вторая — по тому, что именно записывается в хранилище, третья — по тому, что вернёт load() при пустом хранилище.

Подсказка 2

1) Берите id из habits.nextId и увеличивайте его. 2) Перед записью — JSON.stringify(habits). 3) В load при null верните { nextId: 1, items: [] } вместо JSON.parse(null).

08 Практика на уроке: список дел, который помнит всё

Собираем всё вместе: список дел с состоянием, рендером, действиями и сохранением. Делаем в три этапа, и каждый заканчивается проверкой в консоли. Сверху над кодом — вкладки index.html, style.css, script.js. Решаем в script.js: ищите комментарии TODO.

Этап 1состояние + render + действия
→
Этап 2saveState / loadState, «перезагрузка»
→
Этап 3битые данные + удаление
Каждый этап работает сам по себе. Не переходите к следующему, пока не пройдена проверка.
Про «перезагрузку» в песочнице

Настоящую перезагрузку страницы здесь не сделать: она сотрёт и заглушку storage. Поэтому нижние строки скрипта имитируют её сами: очищают экран, забывают переменную state и снова вызывают boot(). Выжить после этого может только то, что лежит в storage — как и в настоящем браузере. Автопроверку внизу трогать не нужно.

Этап 1. Состояние, render и действия

Сначала никакого сохранения: одно состояние, одна функция render, действия addTodo и toggleTodo и два обработчика. Форма добавляет задачу, клик по задаче переключает «сделано» (делегирование из урока 9).

Практика: этап 1

ЗАДАЧА 8.1

Этап 1: список дел из состояния

средняя

Допишите всё, что помечено TODO: счётчик «Осталось: N» в render, действия addTodo и toggleTodo, обработчики формы и списка. Пустой текст (в том числе из одних пробелов) добавляться не должен.

Список дел, этап 1 · Ctrl + Enter
<form id="form">
  <input id="text" placeholder="Что нужно сделать?" autocomplete="off">
  <button>Добавить</button>
</form>
<ul id="list"></ul>
<p id="counter"></p>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
2 2
done Осталось: 1
Подсказка 1

Счётчик: const left = state.todos.filter((t) => !t.done).length;, затем counter.textContent = "Осталось: " + left;.

Подсказка 2

Действия — как в разделе 03: addTodo делает state.todos = [...state.todos, {...}] и увеличивает nextId; toggleTodo использует map; обе заканчиваются render(). В обработчике формы не забудьте event.preventDefault().

Этап 2. Сохраняем и восстанавливаем

Теперь добавим память. Нужны три вещи: две функции saveState и loadState, вызов saveState() в конце каждого действия и функция boot() — «запуск страницы»: загрузить состояние и нарисовать экран. В настоящем проекте последней строкой скрипта стоит просто boot();.

Практика: этап 2

ЗАДАЧА 8.2

Этап 2: список переживает перезагрузку

средняя

Реализуйте saveState (запись JSON.stringify(state) в storage по ключу KEY) и loadState (если в хранилище пусто — вернуть { nextId: 1, todos: [] }, иначе распаковать). Вызовите saveState() в addTodo и toggleTodo — перед render(). Остальной код этапа 1 уже готов.

Список дел, этап 2 · Ctrl + Enter
<form id="form">
  <input id="text" placeholder="Что нужно сделать?" autocomplete="off">
  <button>Добавить</button>
</form>
<ul id="list"></ul>
<p id="counter"></p>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
до перезагрузки: 2 Осталось: 1
после: 2 done Осталось: 1
Подсказка 1

saveState — одна строка с storage.setItem. loadState — вспомните шаблон из раздела 05, только вместо null возвращается начальное состояние.

Подсказка 2

const raw = storage.getItem(KEY); return raw === null ? { nextId: 1, todos: [] } : JSON.parse(raw);. В действиях: saveState(); перед render();.

Этап 3. Защита от мусора и удаление

Приложение, которое падает от одной сломанной записи, — не приложение. Сделаем loadState надёжным (шаблон из раздела 06) и добавим кнопку «×» для удаления задачи. Кнопка находится внутри li, а клик по li уже переключает задачу — поэтому в общем обработчике сначала проверяем, куда именно нажали.

Практика: этап 3

ЗАДАЧА 8.3

Этап 3: надёжная загрузка и удаление

сложная

1) В loadState обработайте: пустое хранилище, битый JSON, и неверную форму — состояние должно быть объектом, у которого todos — массив, а nextId — число; иначе возвращайте состояние по умолчанию. 2) Допишите removeTodo(id) через filter и обработку кнопки «×» в клике по списку — нажатие на «×» должно удалять задачу, а не переключать её.

Список дел, этап 3 · Ctrl + Enter
<form id="form">
  <input id="text" placeholder="Что нужно сделать?" autocomplete="off">
  <button>Добавить</button>
</form>
<ul id="list"></ul>
<p id="counter"></p>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
битые данные: 0
Нон, Зира Осталось: 2
после перезагрузки: 2
неверная форма: 0
Подсказка 1

Надёжная загрузка — это три return createDefaultState() в трёх разных случаях. Проверка формы: typeof saved === "object" && saved !== null && Array.isArray(saved.todos) && typeof saved.nextId === "number".

Подсказка 2

Клик по списку: if (event.target.matches(".remove")) removeTodo(id); else toggleTodo(id);. Внутри removeTodo — state.todos = state.todos.filter((t) => t.id !== id);, потом saveState() и render().

Что вы построили

Это скелет, на котором держатся почти все интерфейсы: состояние → действия → render → сохранение. Трекер задач и привычек из мини-проекта на уроке 12 — это тот же скелет с другими полями. Попробуйте добавить в него фильтр «все / активные / выполненные» (задача 2.4) и сохранить выбранный фильтр.

09 Домашнее задание: добавить сохранение в проект урока 9

Возьмите свой проект с урока 9 (интерактивная страница с событиями и делегированием) и научите его помнить данные. Здесь вы работаете на своём компьютере — значит, используете настоящий localStorage: const storage = window.localStorage;.

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

  • В проекте одно состояние — объект state. Нигде нет данных, которые «живут» только в DOM.
  • Экран рисует функция render(). Обработчики событий вызывают действия, а те — render().
  • Есть saveState() и loadState(); ключ с префиксом проекта и версией, например myproject:v1.
  • Состояние сохраняется после каждого действия и восстанавливается при открытии страницы: закрыли вкладку, открыли снова — всё на месте.
  • loadState() переживает: пустое хранилище, битый JSON, неверную форму данных и отсутствующие новые поля.
  • Есть кнопка «Сбросить», которая очищает данные приложения (removeItem по вашему ключу) и возвращает начальное состояние.
  • В состоянии нет производных данных, функций и дат-объектов.

Как проверить свою работу

  1. Добавьте несколько элементов, обновите страницу (F5) — всё на месте?
  2. Откройте DevTools (F12) → Application → Local Storage. Найдите свой ключ, посмотрите, как выглядит значение.
  3. Испортите значение вручную: удалите последнюю скобку. Обновите страницу — приложение должно запуститься с начальным состоянием, а не упасть с ошибкой в консоли.
  4. Удалите ключ — страница должна открыться «как в первый раз».
  5. Нажмите «Сбросить» — данные исчезли и из экрана, и из хранилища?
ИИ в домашнем задании

Правило курса: 15 минут сами. Запишите свою структуру состояния в файл NOTES.md. Потом спросите ИИ по образцу из раздела 07 и добавьте в тот же файл таблицу «моё / ИИ / решение». В коде оставляйте только то, что можете объяснить построчно. Обязательно спросите: «назови 2 случая, когда сохранённые данные окажутся некорректными» — и проверьте их руками.

Что сдавать

  • Папку проекта (архив или ссылка) с работающим сохранением.
  • Файл NOTES.md: ваша структура состояния, ключ в localStorage, что сохраняется и что нет, таблица сравнения с ИИ.
  • Короткую запись экрана (до 30 секунд): добавили данные → обновили страницу → данные на месте → испортили значение → приложение не упало.

Как оценивается (0–10)

КритерийБаллы
Одно состояние state и render(), без «правды в DOM»2
Данные сохраняются и восстанавливаются после перезагрузки3
Корректная работа при пустом и повреждённом хранилище2
Чистая структура: простые типы, уникальные id, ключ с версией, кнопка сброса1
NOTES.md и сравнение с ИИ: решение объяснено своими словами2
Если страница открыта как файл

В большинстве браузеров localStorage работает и для файлов file://, но у разных файлов могут быть общие или раздельные данные — это зависит от браузера. Надёжнее запускать проект через Live Server в VS Code: тогда у него обычный адрес вида localhost:5500 и своё хранилище.

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

  • Состояние — все изменяемые данные приложения в одном объекте state. Хранить только исходные факты; то, что можно посчитать (сумма, счётчик), считаем функцией.
  • render(state) рисует экран целиком и только по состоянию: ничего не меняет, не читает данные из DOM, умеет рисовать пустой список.
  • Цикл: событие → действие (addTodo, toggleTodo, removeTodo) → изменили state → render() → saveState().
  • Массивы в состоянии: добавить — [...arr, item], изменить — map, удалить — filter. Присваиваем результат обратно в state. Id берём из nextId, а не из length + 1.
  • localStorage: setItem(k, v), getItem(k) (нет ключа — null), removeItem(k), clear(). Хранит только строки, живёт после перезагрузки, около 5 МБ, свой для каждого сайта. Секреты не хранить.
  • В песочнице курса настоящий localStorage недоступен — используем заглушку storage с тем же API. На своём компьютере: const storage = window.localStorage;. Проверить настоящий можно в DevTools → Application → Local Storage.
  • JSON: JSON.stringify(state) перед записью, JSON.parse(raw) после чтения. Не переживают: undefined, функции, даты (станут строками).
  • Надёжный loadState: null → состояние по умолчанию; JSON.parse в try / catch; проверка формы; { ...defaults, ...saved } для новых полей. Ключ с префиксом и версией: app:v1.
  • ИИ: сначала своя структура состояния (15 минут), потом промпт по формуле и сравнение в таблице. Проверять: нет ли производных данных, откуда id, что сохранять, что с пустыми и битыми данными.