Состояние и вычисления в Svelte 5
Каталог «Маршрута» уже задаёт устойчивые идентификаторы трёх уроков. Теперь добавим действия, которые изменяют интерфейс без перехода на другую страницу. Читатель сможет сохранять материалы и отмечать завершение, а счётчик будет зависеть от этих отметок. В этом уроке состояние принадлежит одному экземпляру страницы и сбрасывается после перезагрузки.
Результат важен для дальнейшей работы: вы научитесь отделять изменяемые данные от величин, которые всегда можно вычислить. Сохранение на сервер появится позже. Пока не называйте счётчик личной историей пользователя: он показывает только действия, сделанные в открытом экране. Код приводится для Svelte 5; учебная программа не запускалась.
Значение, от которого зависит разметка
Обычный массив JavaScript хранит данные, но сам по себе не описывает связь с интерфейсом. Rune $state отмечает реактивное состояние. В компоненте вы читаете и изменяете его как привычное значение: отдельный вызов для получения числа или массива не требуется. Механизм $state документирован в руководстве Svelte.
Замените страницу из первого урока этим полным файлом src/routes/+page.svelte. Два массива выражают разные действия; процент зависит только от завершённых материалов.
<script lang="ts">
import { lessons } from '$lib/catalog';
let bookmarks = $state<string[]>([]);
let completed = $state<string[]>([]);
let percent = $derived(Math.round(completed.length / lessons.length * 100));
function toggle(id: string, kind: 'bookmark' | 'complete') {
const source = kind === 'bookmark' ? bookmarks : completed;
const next = source.includes(id) ? source.filter(x => x !== id) : [...source, id];
if (kind === 'bookmark') bookmarks = next;
else completed = next;
}
</script>
<h1>Маршрут</h1>
<p>Завершено {completed.length} из {lessons.length}: {percent}%</p>
<ul>
{#each lessons as lesson (lesson.id)}
<li>
<span>{lesson.title}</span>
<button onclick={() => toggle(lesson.id, 'bookmark')}
aria-pressed={bookmarks.includes(lesson.id)}>Закладка</button>
<button onclick={() => toggle(lesson.id, 'complete')}
aria-pressed={completed.includes(lesson.id)}>Завершено</button>
</li>
{/each}
</ul>
При первой отметке завершения ожидаем 1 из 3: 33%. При второй — 2 из 3: 67%. Округление не означает, что три урока равны по длительности; это выбранная метрика количества материалов. Если понадобится доля учебного времени, числитель придётся вычислять по minutes, а название показателя изменить, чтобы не вводить читателя в заблуждение.
Функция toggle сначала определяет набор, затем создаёт его новую версию. Повторное действие снимает отметку, а не добавляет второй экземпляр идентификатора. В примере не бывает двух закладок на один урок. Такое правило пригодится и на сервере, где повторная доставка запроса тоже не должна создавать несколько одинаковых записей.
Почему процент не хранится отдельно
percent определяется из completed и количества уроков. Если сделать его ещё одним $state, пришлось бы обновлять два значения при каждом действии. Через некоторое время один обработчик забывает изменить процент, и экран одновременно показывает два несовместимых результата. Вычисляемое значение устраняет саму возможность такого расхождения.
Rune $derived выражает зависимость, а не команду сохранить копию результата. В примере изменение completed должно привести к новому отображению процента. Формула остаётся рядом с объявлением и читается независимо от обработчика. Документация $derived описывает её как механизм производного состояния; наша метрика прогресса является применением этого механизма к учебному каталогу.
Не переносите формулу в $effect, который при каждом изменении присваивает процент другой переменной. Эффект нужен, когда взаимодействие с внешней системой действительно имеет побочное действие. Вычисление числа для разметки такого действия не требует. Чем меньше синхронизируемых копий, тем легче объяснить, почему экран получил конкретное значение.
Есть и предметная граница: completed.length корректен только пока массив содержит известные уникальные идентификаторы. После чтения внешних данных понадобится очистить неизвестные и повторяющиеся значения. Тип string[] защищает форму кода, но не доказывает, что строка действительно присутствует в каталоге. В серверных уроках эту проверку выполним явно.
Управление состоянием через действие
Обработчик onclick передаёт функцию, которую браузер вызовет при нажатии. Если написать непосредственный вызов toggle(...) вместо стрелочной функции, он выполнится при построении разметки, а его результат попадёт на место обработчика. Здесь важно различать «вызвать действие сейчас» и «дать интерфейсу действие для будущего события».
Нативная кнопка уже умеет получать фокус и реагировать на клавиатуру. Поэтому мы не создаём кликабельный span, которому пришлось бы добавлять семантику и обработку клавиш вручную. aria-pressed сообщает, что действие имеет два устойчивых состояния. Надпись «Закладка» не исчезает после нажатия, поэтому читатель понимает назначение той же кнопки и при установленной отметке.
Состояние можно расширить: добавить поиск, режим «только сохранённые», выбранный курс. Каждое значение стоит оценивать отдельно. Поисковая строка изменяется пользователем и является исходным состоянием. Отфильтрованный список вычисляется из неё и каталога. Количество найденных уроков тоже вычисляется. Такая цепочка сохраняет один источник для каждого решения пользователя.
Перезагрузка и независимые вкладки
Откройте страницу в двух вкладках мысленно или позже в своей среде. У каждой вкладки будет собственный экземпляр компонента и свой набор отметок. Завершение в первой вкладке не меняет вторую, потому что общая система хранения ещё не существует. После перезагрузки создаётся пустой массив и снова появляется ноль процентов. Это ожидаемое ограничение текущего шага.
Не исправляйте его глобальным $state в серверном модуле. Сервер обрабатывает запросы разных людей; общее изменяемое реактивное значение способно соединить их данные. Локальное состояние компонента и серверный репозиторий — разные механизмы с разной областью жизни. Позже мы свяжем их через загрузку и действия, сохранив проверку владельца.
Неизвестная отметка в массиве
Представьте, что массив завершения содержит html-start и old-lesson. Формула второго урока покажет два завершения, хотя карточка старого материала отсутствует. Корректный счётчик должен пересечь идентификаторы с текущим каталогом и убрать повторы. Это потребуется при чтении серверного состояния после редакционного изменения курса.
Не удаляйте неизвестную историю автоматически только потому, что её нет на экране. Возможно, урок архивирован и должен оставаться в отдельном разделе. Для текущего процента допустимо считать только известные материалы, сохраняя исходную запись в репозитории по его политике. Отображение и удаление данных снова оказываются разными действиями.
Массивы удобны для трёх уроков и ясного листинга. Для большого набора поиск через includes может стать частью стоимости построения списка. Тогда можно рассмотреть набор идентификаторов или подготовленную проекцию, но сначала потребуется измерение реального масштаба. Маленький пример не даёт основания объявлять такое усложнение обязательным.
Сейчас полезно добавить второй вариант вычисления самостоятельно: количество оставшихся уроков. Оно должно определяться из каталога и завершённых идентификаторов, а не изменяться отдельным обработчиком. Если снятие завершения автоматически увеличивает остаток и уменьшает процент, модель остаётся согласованной. Следующий урок вынесет повторяющуюся карточку в компонент, но владелец массивов останется на странице.