Компонент карточки и обратные вызовы
Страница «Маршрута» хранит локальные массивы закладок и завершённых уроков. Повторяющаяся разметка уже содержит название, длительность и два действия. Вынесем её в LessonCard.svelte, сохранив владение отметками на странице. Результат — компонент, который можно использовать в каталоге и в подборке сохранённых материалов, не создавая две независимые истории прогресса.
Вы уже знакомы с $state, $derived и обработчиком кнопки. Теперь понадобится ещё одна rune — $props: через неё компонент получает значения от родителя. Используем контракт Svelte 5 и функции обратного вызова. Приведённые файлы являются авторским примером; компиляция и взаимодействие с экраном не выполнялись.
Кто отвечает за отметку
Карточка отвечает за отображение одного урока. Она знает, установлена ли закладка, но не решает, где хранится список всех закладок. Родитель располагает всем каталогом и умеет вычислять общий прогресс, поэтому именно он остаётся владельцем массивов. Такое разделение поможет позже заменить локальную запись серверной, не переписывая смысл карточки.
Полный файл src/lib/components/LessonCard.svelte получает предметные значения. Тип Lesson уже определён в первом уроке. Свойства функций требуют идентификатор и возвращают void; карточка не ожидает немедленного серверного результата в текущем локальном варианте.
<script lang="ts">
import type { Lesson } from '$lib/catalog';
type Props = {
lesson: Lesson;
bookmarked: boolean;
complete: boolean;
onbookmark: (id: string) => void;
oncomplete: (id: string) => void;
};
let { lesson, bookmarked, complete, onbookmark, oncomplete }: Props = $props();
</script>
<article>
<h2><a href={`/lessons/${lesson.id}`}>{lesson.title}</a></h2>
<p>{lesson.minutes} минут</p>
<button onclick={() => onbookmark(lesson.id)} aria-pressed={bookmarked}>
Закладка
</button>
<button onclick={() => oncomplete(lesson.id)} aria-pressed={complete}>
Завершено
</button>
</article>
<style>
article { padding: 1rem; border: 1px solid #cbd5e1; border-radius: .5rem; }
button[aria-pressed='true'] { background: #e0f2fe; }
a:focus-visible, button:focus-visible { outline: 3px solid #0369a1; }
</style>
Значения bookmarked и complete входят в контракт, поэтому карточка не выводит их из глобального массива и не импортирует родительскую страницу. Такое направление зависимостей важно: небольшой визуальный компонент не должен знать конкретный адрес кабинета, схему сессии или библиотеку хранения данных. Он получает достаточно информации для одного состояния.
При нажатии карточка сообщает намерение через onbookmark. Родитель решает, как его выполнить. Название функции похоже на имя события, однако это обычное свойство компонента, содержащее функцию. В документации Svelte 5 свойства обратного вызова рекомендованы для событий компонентов. Мы не вводим createEventDispatcher, потому что серии не требуется совместимость с прежним синтаксисом.
Сборка списка на странице
В странице второго урока сохраните объявления массивов, вычисление процента и функцию toggle. Добавьте импорт карточки, затем замените только список следующим фрагментом. Это замена разметки, а не второй полный файл страницы.
<script lang="ts">
// Этот импорт добавляется к существующему script страницы.
import LessonCard from '$lib/components/LessonCard.svelte';
</script>
<ul>
{#each lessons as lesson (lesson.id)}
<li>
<LessonCard {lesson}
bookmarked={bookmarks.includes(lesson.id)}
complete={completed.includes(lesson.id)}
onbookmark={id => toggle(id, 'bookmark')}
oncomplete={id => toggle(id, 'complete')} />
</li>
{/each}
</ul>
В существующем файле должен остаться один верхний блок script; приведённый импорт вставляется в него. Два отдельных верхних блока без специального назначения были бы неверным способом объединить пример. Указание места изменения позволяет продолжить страницу, сохранив ранее написанную логику.
Ожидаемое поведение совпадает со вторым уроком: отметка меняет состояние кнопки и общий процент. Новая граница компонента не должна изменить предметный результат. Это полезный критерий рефакторинга: читатель видит те же данные и те же действия, а автор получает отдельно определённый контракт карточки.
Данные вниз, намерение вверх
Не добавляйте в карточку собственный $state(bookmarked) для копирования свойства. Начальное значение оказалось бы снимком. Когда родитель изменит отметку, внутренняя копия потребует дополнительной синхронизации. Получится два владельца одной закладки, и при обновлении каталога карточка может показать прошлое состояние.
Не следует также напрямую менять lesson.title из карточки. Родитель передал объект для чтения, а интерфейс здесь не является редактором содержания. Если позже понадобится редактирование названия, это станет отдельным действием с отдельной проверкой. Случайная доступность объекта в JavaScript не превращает любое изменение в правильный контракт приложения.
Двусторонняя привязка полезна для некоторых полей ввода, но для закладки callback точнее выражает смысл. Пользователь не вводит произвольное значение bookmarked; он просит выполнить операцию. На сервере эта операция может отказать, занять время или обнаружить устаревшую версию. Контракт намерения сохраняет место для этих результатов.
Стили и повторное использование
Стили внутри компонента относятся к его разметке. Соседняя карточка получает те же правила, но произвольная кнопка в шапке не обязана наследовать оформление. Это помогает читать пример: внешний список отвечает за расстояния между карточками, а карточка отвечает за свою рамку и внутренние элементы. Общие дизайн-токены при необходимости добавляются отдельно.
В варианте сохранённых материалов родитель сначала отбирает уроки по массиву закладок, затем передаёт ту же карточку. Кнопка снятия закладки приведёт к исчезновению элемента из подборки. Для доступности такое исчезновение позже потребует внимания к фокусу: выбранный элемент мог содержать активную кнопку. Компонентная граница не отменяет ответственности страницы за изменение списка.
Сравните два решения мысленно. Если каждая карточка хранит собственную историю, общий счётчик приходится собирать из разрозненных элементов. Если история хранится на странице, счётчик и подборка получаются из одних данных. Второй вариант лучше соответствует нашей задаче, хотя число строк первоначально может оказаться чуть больше.
Карточка без обработчика записи
Публичному каталогу иногда нужна карточка только со ссылкой. Тогда контракт можно сделать отдельным: визуальная карточка отображает урок, а область действий передаётся явно или отсутствует. Не подставляйте пустые функции ради удовлетворения типов, если кнопки не имеют доступной операции. Человек увидит действие, которое ничего не делает, и не поймёт причину.
В нашей серии локальная карточка предназначена демонстрации состояния, а серверные формы кабинета выражены непосредственно HTML. Это два учебных применения одного содержания, а не требование любую кнопку упаковать в универсальный компонент. Когда появятся несколько повторяющихся форм, можно выделить новый компонент с полями POST и результатом действия.
Обратите внимание и на уровни заголовков. Карточка использует h2, потому что список расположен под главным заголовком страницы. Внутри более глубокой панели тот же тег может нарушить ожидаемую структуру чтения. Повторное использование требует проверять место компонента, а не только его свойства и размеры. Граница модуля не освобождает автора страницы от ответственности за семантику общего документа.
На этом этапе у вас есть компонент с ясными входами и действиями, а не самостоятельный мини-кабинет внутри каждой карточки. Следующий урок введёт URL страницы урока и общую компоновку. Карточка уже содержит правильную ссылку; теперь создадим страницу, которая действительно соответствует этому адресу.