Зачем нужны веб-фреймворки. Ванильная Альтернатива.
В предыдущей статье мы рассмотрели преимущества и затраты использования фреймворков, начиная с того, какие основные проблемы они пытаются решить, сосредоточив внимание на декларативном программировании, привязке данных, реактивности, списках и условных выражениях. Сегодня мы рассмотрим, может ли появится альтернатива из самой веб-платформы.
Создание собственного фреймворка
Результатом, который может показаться неизбежным при изучении жизни без фреймворка — создание собственного фреймворка для реактивной привязки данных. Попробовав это ранее и увидев, насколько дорогостоящим это может быть, я решил поработать с руководством в этом исследовании. Не для того, что бы развернуть свой собственный фреймворк, а посмотреть смогу ли я напрямую использовать веб-платформу таким образом, что бы сделать фреймворк менее необходимым. Если вы подумываете о внедрении собственного фреймворка, имейте в виду, что существует ряд затрат, не обсуждаемых в этой статье.
Ванильный выбор
Веб-платформа уже предоставляет декларативный механизм программирования из коробки: HTML и CSS. Этот механизм зрелый, хорошо проверенный, популярный, широко используемый и задокументированный. Однако, он не обеспечивает чёткие встроенные концепции привязки данных, условного рендеринга, и синхронизации списков, а реактивность — тонкая деталь, распределённая по нескольким функциям платформы.
Когда я просматриваю документацию популярных фреймворков, я сразу нахожу функции описанные в первой части. Когда я читаю документацию по веб-платформе (например на MDN) я нахожу множество запутанных шаблонов того, как что-то делать, без окончательного представления о привязке данных, синхронизации списков или реактивности. Я попытаюсь изобразить некоторые рекомендации о том, как решать эти проблемы на веб-платформе, без фреймворка (другими словами, переходя на ванильный JavaScript).
Реактивность со стабильным деревом DOM и каскадированием
Вернёмся к примеру с сообщением об ошибке. В ReactJS и SolidJS мы создаём декларативный код, транслируемый в императивный код, который добавляет <label>
в DOM или удаляет её. В Svelte этот код генерируется.
Но что, если бы у нас вообще не было этого кода, а вместо этого мы использовали бы CSS для сокрытия или отображения метки ошибки?
<style>
label.error { display: none; }
.app.has-error label.error {display: block; }
</style>
<label class="error">Message</label>
<script>
app.classList.toggle('has-error', true);
</script>
Реактивность, в этом случае, обрабатывается в браузере — изменение класса приложения распространяется на его потомков до тех пор, пока внутренний механизм в браузере не решит, отображать ли метку.
Эта техника имеет ряд преимуществ:
- Размер пакета ноль.
- Ноль этапов сборки проекта.
- Распространение изменений оптимизировано и хорошо протестировано в собственном коде браузера и позволяет избежать ненужных дорогостоящих операций DOM, таких, как
append
иremove
. - Селекторы работают стабильно. В этом случае вы можете рассчитывать на наличие элемента
label
. Вы можете применить к нему анимацию, не полагаясь на сложные конструкции, такие какгруппы переходов
. Вы можете сохранить ссылку на него и в JavaScript. - Если
label
отображается или скрыта, вы можете увидеть причину в панели стилей инструментов разработчика, показывающей вам весь каскад, цепочку правил, которая привела к тому, что 'label' оказалась видимой (или скрытой).
Даже если после прочтения этого вы продолжите использовать фреймворки, идея сохранения стабильности DOM и изменения состояния с помощью CSS довольно мощная. Подумайте, где это может вам пригодится.
Формоориентированная привязка данных
До эпохи JavaScript-насыщенных одностраничных приложений (single-page applications — SPA), формы были основным способом создания веб-приложений, включающих пользовательский ввод. Традиционно, пользователь заполняет форму и нажимает кнопку Отправить
, а код на стороне сервера отрабатывает ответ. Формы представляли собой многостраничную версию приложения для привязки данных и интерактивности. Неудивительно, что HTML элементы с названиями основанными на вводе и выводе являются элементами формы.
Из-за широкого распространения и долгой истории API-интерфейсы форм накопили несколько скрытых самородков, делающих их полезными для задач, которые традиционно не рассматриваются как решаемые с помощью форм.
Формы и элементы формы как стабильные селекторы
Формы доступны по имени (через document.forms
), и каждый элемент формы доступен по имени (через form.elements
). Кроме того, форма, связанная с элементом, доступна (через атрибут form
). Сюда входят не только элементы input
, но и другие элементы формы, такие как output
, textarea
и fieldset
, что обеспечивает вложенный доступ к элементам в дереве.
В примере с ошибкой и label
из предыдущего раздела мы показали, как реактивно отображать и скрывать сообщение об ошибке. Вот так мы обновляем текст сообщения об ошибке в React (и аналогично в SolidJS):
const [errorMessage, setErrorMessage] = useState(null);
return <label className="error">{errorMessage}</label>
Когда у нас есть стабильный DOM и стабильные древовидные формы и элементы форм, мы можем сделать следующее:
<form name="contactForm">
<fieldset name="email">
<output name="error"></output>
</fieldset>
</form>
<script>
function setErrorMessage(message) {
document.forms.contactForm.elements.email.elements.error.value = message;
}
</script>
В необработанном виде это выглядит довольно многословно, но также очень стабильно, прямолинейно и чрезвычайно производительно.
Формы для ввода
Обычно, когда мы создаём SPA, у нас есть какой-то JSON-подобный API, с которым мы работаем для обновления нашего сервера или любой другой модели, которую мы используем.
Это был бы знакомый пример (написанный для удобочитаемости на TypeScript):
interface Contact {
id: string;
name: string;
email: string;
subscriber: boolean;
}
function updateContact(contact: Contact) { … }
Он в коде фреймворка генерирует объект Contact
, отбирая элементы input и создавая объект по частям. При правильном использовании форм существует краткая альтернатива:
<form name="contactForm">
<input name="id" type="hidden" value="136" />
<input name="email" type="email"/>
<input name="name" type="string" />
<input name="subscriber" type="checkbox" />
</form>
<script>
updateContact(Object.fromEntries(
new FormData(document.forms.contactForm));
</script>
Используя скрытые поля и полезный класс FormData
, мы можем беспрепятственно преобразовывать значения между полями input DOM и функциями JavaScript.
Объединение форм и реактивности
Объединив высокопроизводительную стабильность селекторов форм и реактивность CSS, мы можем добиться более сложной логики пользовательского интерфейса:
<form name="contactForm">
<input name="showErrors" type="checkbox" hidden />
<fieldset name="names">
<input name="name" />
<output name="error"></output>
</fieldset>
<fieldset name="emails">
<input name="email" />
<output name="error"></output>
</fieldset>
</form>
<script>
function setErrorMessage(section, message) {
document.forms.contactForm.elements[section].elements.error.value = message;
}
function setShowErrors(show) {
document.forms.contactForm.elements.showErrors.checked = show;
}
</script>
<style>
input[name="showErrors"]:not(:checked) ~ * output[name="error"] {
display: none;
}
</style>
Обратите внимание, что в этом примере не используются классы — мы разрабатываем поведение DOM и стиль из данных форм, а не вручную изменяем классы элементов.
Я не люблю злоупотреблять классами CSS в качестве селекторов JavaScript. Я думаю, что их следует использовать для группировки элементов с одинаковы стилем, я не в качестве универсального механизма для изменения стилей компонентов.
Преимущества форм
- Как и в случае с каскадированием, формы встроены в веб-платформу, и большинство их функций стабильны. Это означает гораздо меньше JavaScript кода, гораздо меньше несоответствий версий фреймворка и отсутствие
сборки
. - Формы доступны по умолчанию. Если ваше приложение правильно использует формы, гораздо меньше необходимости в атрибутах
ARIA
,плагинах специальных возможностей
и проверках в последнюю минуту. Формы подходят для навигации с помощью клавиатуры, программ чтения с экрана и других вспомогательных технологий. - Формы поставляются со встроенными функциями проверки ввода: проверка по шаблону регулярного выражения, реакция на не валидные и валидные формы в CSS, обработка обязательных и опциональных полей, и много другое. Вам нужно что-то похожее на форму, что бы пользоваться этими функциями.
- Событие
submit
— отправка формы чрезвычайно полезно. Например, оно позволяет перехватывать клавишу Enter, даже если нет кнопки отправки формы, и позволяет различать несколько кнопок отправки по атрибутуsubmitter
(как мы увидим позже в примере с TODO) - По умолчанию элементы связаны с содержащей их формой, но могут быть связаны с любой другой формой документа с помощью атрибута
form
. Это позволяет нам экспериментировать с ассоциацией форм, не создавая зависимости от дерева DOM. - Использование стабильных селекторов помогает в автоматизации тестирования пользовательского интерфейса: мы можем использовать вложенный API как стабильный способ подключения к DOM независимо от его макета и иерархии. Иерархия
form > (fieldset) > элемент
может служить интерактивным скелетом вашего документа.
ChaCha и HTML шаблон
Фреймворки предоставляют собственный способ выражения наблюдаемых списков. Сегодня многие разработчики полагаются на библиотеки, не относящиеся к фреймворку, которые предоставляют некоторые возможности, такие как MobX.
Основная проблема с наблюдаемыми списками общего назначения заключается в том, что они имеют общее назначение. Это добавляет удобства за счёт снижения производительности, а также требует специальных инструментов разработчика для отладки сложных действий, которые эти библиотеки выполняют в фоновом режиме.
Использование этих библиотеки понимание того, что они делают — это нормально. И они могут быть полезны независимо от выбора фреймворка пользовательского интерфейса, но использование альтернативы может быть не более сложным и это может предотвратить некоторые ловушки, которые случаются, когда вы пытаетесь развернуть свою собственную модель.
Канал изменений (или ChaCha)
ChaCha
— также известный как Канал Изменений (Changes Channel) — представляет собой двунаправленный поток, целью которого является уведомление об изменениях в направлении намерения и направлении наблюдения.
- В направлении намерения пользовательский интерфейс уведомляет модель об изменениях запланированных пользователем.
- В направлении наблюдения модель уведомляет пользовательский интерфейс об изменениях, внесённых моделью и которые необходимо отобразить пользователю.
Возможно, это забавное название, но это не сложная или новая модель. Двунаправленные потоки используются повсеместно в Интернете и программном обеспечении (например, MessagePort). В этом случае мы создаём двунаправленный поток, который имеет конкретную цель: сообщать о фактических изменениях модели в пользовательском интерфейсе и о намерениях модели.
Интерфейс ChaCha
обычно может быть получен из спецификации приложения без какого-либо кода пользовательского интерфейса.
Например, приложение, позволяющее добавлять и удалять контакты, и загружающее исходный список с сервера (с возможностью обновления), может иметь ChaCha, который выглядит следующим образом:
interface Contact {
id: string;
name: string;
email: string;
}
// Направление "Наблюдения"
interface ContactListModelObserver {
onAdd(contact: Contact);
onRemove(contact: Contact);
onUpdate(contact: Contact);
}
// Направление "Намерения"
interface ContactListModel {
add(contact: Contact);
remove(contact: Contact);
reloadFromServer();
}
Обратите внимание, что все функции в обоих интерфейсах пустые и принимают только простые объекты. Это сделано намеренно. ChaCha
построен как канал с двумя портами для отправки сообщений, что позволяет ему работать в EventSource
, HTML MessageChannel
, сервис-воркере или любом другом протоколе.
Преимущество ChaCha
в том, что их легко тестировать: Вы отправляете действия и ожидаете в ответ вызов конкретных наблюдателей.
HTML template
элемент для элементов списка
HTML шаблоны — это специальные элементы, которые присутствуют в модели DOM, но не отображаются. Их цель — генерировать динамические элементы.
Когда мы используем элемент template
, мы можем избежать стандартного кода создания элементов и заполнения их из JavaScript.
Следующий пример кода добавит элемент name
в список с использованием template
:
<ul id="names">
<template>
<li><label class="name" /></li>
</template>
</ul>
<script>
function addName(name) {
const list = document.querySelector('#names');
const item = list.querySelector('template').content.cloneNode(true).firstElementChild;
item.querySelector('label').innerText = name;
list.appendChild(item);
}
</script>
Используя элемент template
для элементов списка, мы можем элемент списка в нашем исходном HTML — он не визуализируется
с использованием JSX или какого-либо другого языка. Ваш HTML файл содержит весь HTML код приложения — статические части являются частью отображаемого DOM, а динамические части выражены в шаблонах, готовых к клонированию и добавлению в документ, когда придёт время.
Собирая всё это воедино: TodoMVC
TodoMVC — это спецификация приложения для списка задач, который использовался для демонстрации различных фреймворков. Шаблон TodoMVC поставляется с готовыми HTML и CSS, которые помогут вам сосредоточится на фреймворке.
Вы можете поиграть с результатом в репозитории GitHub, также доступен полный исходный код.
Начните с ChaCha полученной из спецификации
Мы начинаем со спецификации и используем её для построения интерфейса ChaCha
:
interface Task {
title: string;
completed: boolean;
}
interface TaskModelObserver {
onAdd(key: number, value: Task);
onUpdate(key: number, value: Task);
onRemove(key: number);
onCountChange(count: {active: number, completed: number});
}
interface TaskModel {
constructor(observer: TaskModelObserver);
createTask(task: Task): void;
updateTask(key: number, task: Task): void;
deleteTask(key: number): void;
clearCompleted(): void;
markAll(completed: boolean): void;
}
Функции в модели задач выводятся непосредственно из спецификации и того, что может делать пользователь (очистить выполненные задачи, пометить все как выполненные или активные, получить количество активных и завершённых задач).
Обратите внимание, что он следует рекомендациям ChaCha
:
- Есть два интерфейса один действующий и один наблюдающий.
- Все типы параметров являются примитивами или простыми объектами (которые легко переводятся в JSON)…
- Все функции возвращают void
Реализация TodoMVC использует LocalStorage
в качестве бэкэнда
Модель очень проста и не актуальна для обсуждения фреймворка пользовательского интерфейса. При необходимости он сохраняет данные в localStorage
и запускает обратные вызовы изменений для наблюдателя, когда что-то меняется, либо в результате действий пользователя, либо когда модель загружается из localStorage
в первый раз.
Основанный на форма-ориентированном HTML
Далее я возьму шаблон TodoMVC и изменю его на форма-ориентированный — иерархию форм, с элементами ввода и вывода представляющими данные, которые можно изменить с помощью JavaScript.
Как узнать, что должно быть элементов формы? Как правило, если это привязано к данным из модели, то должно быть элементом формы.
Доступен полный HTML-файл, вот его основная часть:
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<form name="newTask">
<input name="title" type="text" placeholder="What needs to be done?" autofocus>
</form>
</header>
<main>
<form id="main"></form>
<input type="hidden" name="filter" form="main" />
<input type="hidden" name="completedCount" form="main" />
<input type="hidden" name="totalCount" form="main" />
<input name="toggleAll" type="checkbox" form="main" />
<ul class="todo-list">
<template>
<form class="task">
<li>
<input name="completed" type="checkbox" checked>
<input name="title" readonly />
<input type="submit" hidden name="save" />
<button name="destroy">X</button>
</li>
</form>
</template>
</ul>
</main>
<footer>
<output form="main" name="activeCount">0</output>
<nav>
<a aria-hidden='true' id="/" href="#/">All</a>
<a aria-hidden='true' id="/active" href="#/active">Active</a>
<a aria-hidden='true' id="/completed" href="#/completed">Completed</a>
</nav>
<input form="main" type="button" name="clearCompleted" value="Clear completed" />
</footer>
</section>
Этот HTML-файл включает в себя следующее:
- Обратите внимание, что мы связываем элементы с формой с помощью атрибута
form
, что бы избежать вложения элементов в форму. - Элемент
template
представляет собой элемент списка, а его корневой элемент представляет собой другую форму, представляющую интерактивные данные, относящиеся к конкретной задаче. Эта форма будет повторяться путём клонирования содержимого шаблона при добавлении задачи. - Скрытые поля ввода представляют данные, которые не отображаются напрямую, но используются для стилизации и выбора.
Обратите внимание, насколько лаконичным является этот DOM. У него нет классов, разбросанных по элементам. Он включает в себя все элементы, необходимые для приложения, расположенные в разумной иерархии. Благодаря скрытым полям ввода вы уже можете получить представление о том, что может измениться в документе позже.
Этот HTML не знает, как он будет оформлен или с какими именно данными он связан. Пусть CSS и JavaScript работают для вашего HTML, а не ваш HTML работает для определённого механизма стилей. Это значительно облегчило бы изменение дизайна по мере продвижения.
Минимальный контроллер JavaScript
Когда у нас есть большая часть реактивности в CSS и у на есть обработка списков в модели, остаётся контроллер — клейкая лента, скрепляющая всё вместе. В этом небольшом приложении JavaScript-контроллер занимает около 40 строк.
Вот версия с объяснением каждой части:
import TaskListModel from './model.js';
const model = new TaskListModel(new class {
Выше мы создаём новую модель.
onAdd(key, value) {
const newItem = document.querySelector('.todo-list template').content.cloneNode(true).firstElementChild;
newItem.name = `task-${key}`;
const save = () => model.updateTask(key, Object.fromEntries(new FormData(newItem)));
newItem.elements.completed.addEventListener('change', save);
newItem.addEventListener('submit', save);
newItem.elements.title.addEventListener('dblclick', ({target}) => target.removeAttribute('readonly'));
newItem.elements.title.addEventListener('blur', ({target}) => target.setAttribute('readonly', ''));
newItem.elements.destroy.addEventListener('click', () => model.deleteTask(key));
this.onUpdate(key, value, newItem);
document.querySelector('.todo-list').appendChild(newItem);
}
Когда элемент добавляется в модель, мы создаём соответствующий элемент списка в пользовательском интерфейсе.
Выше мы клонируем содержимое элемента template
, назначаем слушателя событий для определённого элемента и добавляем новый элемент в список.
Обратите внимание, что эта функция, наряду с onUpdate
, onRemove
, и onCountChange
, являются обратными вызовами, которые будут вызываться из модели.
onUpdate(key, {title, completed}, form = document.forms[`task-${key}`]) {
form.elements.completed.checked = !!completed;
form.elements.title.value = title;
form.elements.title.blur();
}
Когда элемент обновляется, мы устанавливаем его completed
и title
значения, а затем blur
(для выхода из режима редактирования).
onRemove(key) { document.forms[`task-${key}`].remove(); }
Когда элемент удаляется из модели, мы удаляем соответствующий ему элемент списка из представления.
onCountChange({active, completed}) {
document.forms.main.elements.completedCount.value = completed;
document.forms.main.elements.toggleAll.checked = active === 0;
document.forms.main.elements.totalCount.value = active + completed;
document.forms.main.elements.activeCount.innerHTML = `<strong>${active}</strong> item${active === 1 ? '' : 's'} left`;
}
В приведённом выше коде, когда количество завершённых или активных элементов изменяется, мы устанавливаем правильные входные данные для запуска реакций CSS и форматируем вывод, который отображает количество.
const updateFilter = () => filter.value = location.hash.substr(2);
window.addEventListener('hashchange', updateFilter);
window.addEventListener('load', updateFilter);
Мы обновляем фильтр из фрагмента хэша
(причём при запуске). Всё, что мы делаем выше — это устанавливаем значение элемента формы — CSS обрабатывает всё остальное.
document.querySelector('.todoapp').addEventListener('submit', e => e.preventDefault(), {capture: true});
Здесь мы гарантируем, что не перезагружаем страницу при отправке формы. Это строка, которая превращает это приложение в SPA (single page application)
document.forms.newTask.addEventListener('submit', ({target: {elements: {title}}}) =>
model.createTask({title: title.value}));
document.forms.main.elements.toggleAll.addEventListener('change', ({target: {checked}})=>
model.markAll(checked));
document.forms.main.elements.clearCompleted.addEventListener('click', () =>
model.clearCompleted());
И это обрабатывает основные действия (создание, пометка всего, завершение очистки).
Реактивность с CSS
Полный CSS файл доступен для просмотра.
CSS выполняет множество требований спецификации (с некоторыми поправками для обеспечения доступности). Давайте посмотрим некоторые примеры.
В соответствии со спецификацией, кнопка X
(destroy
) показывается только при наведении. Я также добавил немного доступности, что бы сделать его видимым, когда задача находится в фокусе.
.task:not(:hover, :focus-within) button[name="destroy"] { opacity: 0 }
Ссылка filter
получает красную рамку, когда является текущей:
.todoapp input[name="filter"][value=""] ~ footer a[href$="#/"],
nav a:target {
border-color: #CE4646;
}
Обратите внимание, что мы можем использовать href
элемента ссылка в качестве частичного селектора атрибутов — нет необходимости в JavaScript, который проверяет текущий фильтр и устанавливает класс selected
для соответствующего элемента.
Мы также используем селектор :target
, который освобождает нас от необходимости беспокоится о том, добавлять ли фильтры.
Стиль просмотра и редактирования поля ввода title
изменяется в зависимости от его режима read-only:
.task input[name="title"]:read-only {
…
}
.task input[name="title"]:not(:read-only) {
…
}
Фильтрация (т.е. отображение только активных и завершённых задач) осуществляется с помощью селектора:
input[name="filter"][value="active"] ~ * .task
:is(input[name="completed"]:checked, input[name="completed"]:checked ~ *),
input[name="filter"][value="completed"] ~ * .task
:is(input[name="completed"]:not(:checked), input[name="completed"]:not(:checked) ~ *) {
display: none;
}
Приведённый выше код может показаться немного многословным, и, вероятно, его легче читать с CSS препроцессором, таким как Sass. Но то, что он делает, очень просто: если фильтр active
, флажок completed
установлен, то мы скрываем флажок и его элементы.
Я решил реализовать этот простой фильтр в CSS, чтобы показать, как далеко это может зайти, но если он начинает становиться неудобным, то имеет смысл переместить его в модель.
Заключение и выводы
Я считаю, что фреймворки предоставляют удобные способы решения сложных задач, и у них есть преимущества, выходящие за рамки технических, такие как приведение группы разработчиков к определённому стилю и шаблонам. Веб-платформа предлагает множество вариантов, и внедрение фреймворка позволяет всем, по крайней мере частично, получить одинаковую страницу для некоторых из вариантов. В этом их ценность. Кроме тог, есть что сказать об элегантности декларативного программирования, и в этой статье я не рассматривал большую возможность компонентизации.
Но помните, что существую альтернативные шаблоны, часто с меньшими затратами и не всегда требующие меньшего опыта разработчика. Проявите любопытство к этим шаблонам, возможно вы решите выбирать один из них пока используете фреймворк.
Краткое описание шаблона
- Сохраняет дерево DOM стабильным. Это запускает цепную реакцию упрощения процесса.
- Полагайтесь на CSS для реактивности вместо JavaScript, когда есть возможность.
- Используйте элементы форм в качестве основного способа представления интерактивных данных.
- Используйте HTML элемент
template
вместо шаблонов, созданных на JavaScript. - Используйте двунаправленный поток изменений в качестве интерфейса к вашей модели.