Перейти к содержанию

Пользовательский элемент каталога

Контроллер прошлого урока подключался вручную к контейнеру. Теперь создадим собственный HTML-элемент <pw-course-catalog>, который организует подключение через жизненный цикл браузера. Содержимое останется обычным light DOM, а модель и представление продолжат использовать знакомые функции.

Работаем со снимком advanced/lesson-36. Он содержит стабильный catalog-controller.js прошлого этапа, новый catalog-element.js и короткий запуск. Это самостоятельный вариант четырёх курсов, без нового API и без скрытого observer. Программа ещё не запускалась; поведение подключения и повторного добавления описано как ожидаемое.

Имя и базовый класс

Автономный пользовательский элемент наследует HTMLElement. Его имя содержит дефис: это отличает его от стандартных тегов и соответствует правилам регистрации. Мы используем pw-course-catalog и один раз связываем имя с классом через customElements.define.

export class CourseCatalogElement extends HTMLElement {
  constructor() {
    super();
    this._courses = [];
    this._selection = { topic: 'all', sort: 'original' };
    this._controller = null;
  }
}

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

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

Правила автономных элементов и их callbacks представлены в MDN о custom elements. В этом примере не используются встроенные элементы с is, Shadow DOM и экспериментальные способы перемещения.

Подключение и отключение

Полные методы жизненного цикла:

connectedCallback() {
  if (!this._controller) {
    this._controller = new CatalogController(this, this._courses);
  }
  this._controller.mount();
  this._controller.setSelection(this._selection);
}
disconnectedCallback() {
  if (this._controller) {
    this._selection = { ...this._controller.selection };
  }
  this._controller?.unmount();
}

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

unmount снимает слушатели через сигнал. При новом подключении контроллер создаст новый сигнал. Повторный callback не должен означать второй обработчик, а отключение не должно оставлять активное поведение ненужной страницы. Это договор жизненного цикла, который нужно подтвердить ручным опытом после разрешения запуска.

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

Данные через свойство

Каталог принимает массив через courses, а не пытается сериализовать его в длинный HTML-атрибут:

set courses(value) {
  this._courses = validateCourses(value);
  this._controller?.setCourses(this._courses);
}
get courses() {
  return this._courses.map(course => ({ ...course }));
}

Свойство можно задать до добавления в документ. Тогда проверенный массив сохранится, а первое подключение создаст контроллер с ним. Если элемент уже подключён, setter обновит существующее представление. Такой договор отделяет момент получения данных от момента появления узла.

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

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

Регистрация и запуск

В конце модуля выполняется одна регистрация:

customElements.define('pw-course-catalog', CourseCatalogElement);

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

Запуск импортирует модуль регистрации, создаёт элемент, задаёт данные и добавляет его:

import './catalog-element.js';
import { courses } from '../shared/courses.js';
const catalog = document.createElement('pw-course-catalog');
catalog.courses = courses;
document.querySelector('#catalog-host').append(catalog);

Ожидается четыре карточки. Регистрация уже выполнена до создания в этом потоке, поэтому setter является нашим методом класса. Если проект создаёт элемент раньше определения, появляется отдельная задача обновления свойств после upgrade; данный снимок специально избегает такой неоднозначности.

Интерфейс для следующего шага

Публичные операции setSelection({ topic, sort }), render() и setStatus(message) делегируют известные действия контроллеру. listElement, statusElement и sentinelElement возвращают узлы после подключения. Sentinel является маленьким div[data-catalog-sentinel] после списка и пока ничего самостоятельно не загружает.

Такой интерфейс позволит следующему уроку подключить IntersectionObserver, не искать внутренние узлы случайной цепочкой CSS. Но получение sentinel не означает наблюдение автоматически: потребитель создаёт observer и отвечает за его отключение. Базовый компонент не хранит скрытой сетевой операции.

Light DOM означает, что стили и содержимое находятся в обычном дереве документа. Мы не обещаем изоляцию оформления. Формы не используют глобальные повторяющиеся id, поэтому два экземпляра могут иметь собственные узлы без конфликтного поиска. Контроллер хранит ссылки непосредственно.

Согласование внешних свойств и внутреннего выбора

Публичный setter данных работает и до, и после подключения, однако представление существует только после создания контроллера. Поэтому listElement у нового ещё не добавленного элемента ожидаемо равен null. Это нормальное состояние, а не признак ошибки регистрации. Код наблюдателя должен получить узел после подключения и не предполагать, что конструктор уже построил всё дерево.

Форма внутри компонента меняет selection контроллера. Перед отключением элемент сохраняет эту пару в своём поле, чтобы повторное подключение не вернуло старую внешнюю настройку. Публичный setSelection также обновляет согласованное состояние. Прямое присваивание _selection извне обошло бы эту связь и могло бы оставить форму с другим выбором. Поэтому подчёркивание обозначает договор использования, хотя язык не запрещает такой доступ.

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

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

Для ручного опыта сохраняйте именно тот же объект элемента при удалении и добавлении. Создание нового <pw-course-catalog> проверяет другое поведение: у него новое состояние и новый контроллер. Сравните эти два случая после выбора публикации. Ожидается сохранение темы только у возвращённого экземпляра, если новому не передали её явно. Это объясняет границу жизненного цикла и подготавливает подключение внешних наблюдателей без скрытого сброса каталога.

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

Оглавление курса