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

TypeScript и DOM

Тип курса уже связывает модель, функции и асинхронный результат. Но браузерный документ имеет свою границу: элемента с ожидаемым id может не быть, а найденный элемент может оказаться другой разновидностью. Даже если HTML написан нами, изменения шаблона способны нарушить это соглашение.

В этом уроке построим список курсов и кнопку отбора frontend. Новый результат — безопасное получение nullable-узлов и правильное чтение объекта события. Данные остаются локальными, сеть и форма пока не добавляются. Полный снимок lesson-18 есть в архиве продолжения. Код не запускался при подготовке; описываем ожидаемые изменения страницы.

Документ является частью договора

HTML подключает ./dist/app.js как ES-модуль. Список, сообщение и кнопка имеют разные типы элементов. Полностью замените index.html:

<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Каталог TypeScript</title><script type="module" src="./dist/app.js"></script></head>
<body><main><h1>Учебный каталог</h1>
<button id="front-only" type="button">Показать фронтенд</button>
<p id="catalog-status" role="status" aria-live="polite"></p>
<ul id="catalog-list"></ul></main></body></html>

Названия id помогают найти узлы, но сами не доказывают их класс. Если редактор заменит ul на div, строка селектора останется той же. Следовательно, типовой аргумент у querySelector и настоящее условие instanceof решают разные задачи.

Нельзя считать универсальную запись querySelector<HTMLUListElement> runtime-проверкой тега. Она уточняет сведения для инструмента, но не вызывает дополнительное сравнение найденного DOM-элемента. В нашем примере тип узла подтверждается исполняемым условием, которое одновременно исключает null.

Полный src/app.ts:

import { courses } from './data.js';
import { renderCourses } from './view.js';

const list = document.querySelector('#catalog-list');
const status = document.querySelector('#catalog-status');
const button = document.querySelector('#front-only');
if (!(list instanceof HTMLUListElement) || !(status instanceof HTMLElement) || !(button instanceof HTMLButtonElement)) {
  throw new Error('Не найдены элементы каталога');
}
renderCourses(list, status, courses);
button.addEventListener('click', event => {
  if (!(event.currentTarget instanceof HTMLButtonElement)) return;
  const selected = courses.filter(course => course.topic === 'frontend');
  renderCourses(list, status, selected);
  event.currentTarget.textContent = 'Фронтенд выбран';
});

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

От модели к DOM без HTML-конкатенации

Функция представления принимает уже подходящие элементы. Её полный src/view.ts:

import type { Course } from './model.js';
import { countLessons } from './model.js';

export function renderCourses(list: HTMLUListElement, status: HTMLElement, courses: readonly Course[]): void {
  const nodes = courses.map(course => {
    const item = document.createElement('li');
    const link = document.createElement('a');
    link.href = course.url;
    link.textContent = `${course.title}: ${course.lessons} уроков`;
    item.append(link);
    return item;
  });
  list.replaceChildren(...nodes);
  status.textContent = `Курсов: ${courses.length}. Уроков: ${countLessons(courses)}.`;
}

После первоначального вызова ожидаются четыре ссылки и статус «Курсов: 4. Уроков: 60». После нажатия кнопки остаются три frontend-курса, сумма становится 48, подпись кнопки меняется на «Фронтенд выбран». Функция создаёт новые li и заменяет содержимое списка; она не добавляет повторные карточки к прежнему набору.

Названия записываются через textContent. Это отделяет текст данных от разметки страницы. Тип string сам не гарантирует, что строка безопасна для вставки через innerHTML; поэтому способ создания DOM остаётся важным даже при корректных TypeScript-аннотациях.

Адрес ссылки берётся из внутреннего Course. В этой серии runtime-парсер ограничивает внешние адреса локальными путями courses, а текущий источник ещё локальный и написан автором. Тип Course.url всё равно является строкой. Он не знает, что файл существует; четыре учебных страницы включены в снимок отдельно.

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

Target и currentTarget

У события есть место происхождения и элемент, на котором зарегистрирован обработчик. При клике по вложенной иконке target может быть этой иконкой, а currentTarget — кнопкой. Поэтому чтение event.target.disabled было бы необоснованным ожиданием формы любого источника события.

В нашем обработчике проверяется event.currentTarget instanceof HTMLButtonElement. После этого разрешено изменить текст кнопки. Проверка происходит синхронно внутри callback. Если действие будет продолжено после await, сохраните проверенную локальную ссылку: currentTarget относится к текущему этапу доставки события и не должен использоваться как вечная ссылка на элемент.

Общий тип EventTarget также подходит объектам, которые не являются HTMLElement. Не каждый отправитель события имеет textContent, value или dataset. Причина проверки состоит в реальных возможностях выбранного элемента, а не в желании избавиться от жалобы инструмента.

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

Почему узлы получены один раз

Мы ищем необходимые элементы при начальной настройке и передаём их представлению. Если выполнять querySelector внутри каждой итерации courses.map, программа заново искала бы один и тот же список, а работа с nullable-результатом смешалась бы с построением карточек. Нынешний код отделяет проверку шаблона от повторяемого отображения.

У document.createElement('a') есть другой контекст. Передана известная строка имени тега, поэтому инструмент может определить конкретный HTMLAnchorElement. При произвольном селекторе '#catalog-list' само имя не сообщает, какой тег находился в документе. Поэтому две операции получают разную точность без необходимости вручную приписывать каждому узлу тип.

Мы разрешаем для status любой HTMLElement: функция использует общую возможность textContent, а не специальный метод параграфа. Для list выбрана более узкая HTMLUListElement, чтобы договор совпадал с учебной разметкой. Точность параметра должна объясняться реальной ролью элемента; чрезмерно общий Element осложнил бы использование HTML-свойств, а произвольно узкий класс давал бы ложную гарантию.

Проверка instanceof связана с конструктором текущего браузерного окружения. Для элементов из другого window, например iframe, сравнение с конструктором внешнего окна требует дополнительной аккуратности. В этом снимке все узлы принадлежат одному document, поэтому такой сценарий не моделируется. Типы не отменяют существование нескольких DOM-окружений.

Свойство currentTarget особенно удобно в синхронном обработчике. Его назначение и ограниченный срок значения описаны в Event.currentTarget. Если позднее добавить загрузку по клику, сначала сохраните уже проверенную кнопку в локальную переменную, а затем ожидайте результат. Это будет настоящая ссылка на выбранный объект, а не повторное чтение состояния доставляемого события.

При расширении карточки ссылку по-прежнему можно создать отдельно и добавить к li. Не нужно превращать название или описание в HTML-строку только потому, что появляются дополнительные поля. Способ представления остаётся обычной DOM-задачей, а TypeScript помогает удерживать договор её входных элементов и данных.

Утверждение типа не исправляет шаблон

Можно было бы записать document.querySelector(...)!, предполагая наличие узла. Это уменьшило бы текст, но не создало отсутствующий ul. При несовпадении шаблона исключение появилось бы позже, на чтении метода, и причина стала бы менее явной.

Аналогично as HTMLButtonElement не превратит найденный div в кнопку. Такое утверждение бывает уместно при доказанной внешней гарантии, но в уроке мы как раз учимся выразить и проверить эту гарантию. Исполняемая проверка легче связывает HTML и программу.

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

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

Попробуйте мысленно изменить id списка в HTML, сохранив старый селектор. Ожидаемый исход — начальный отказ «Не найдены элементы каталога», а не успешный пустой список. Затем верните id и проследите frontend-фильтр: исходный массив остаётся из четырёх записей, меняется только представление. В следующем уроке добавим пользовательский отбор и отделим строки полей формы от доменных значений.

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