Зачем нужны веб-фреймворки. Ванильная Альтернатива.

Источник: «What Web Frameworks Solve: The Vanilla Alternative (Part 2)»
Во второй части рассмотрим несколько примеров непосредственного использования веб-платформы в качестве альтернативы некоторым решениям, предлагаемым фреймворками

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

Создание собственного фреймворка

Результатом, который может показаться неизбежным при изучении жизни без фреймворка — создание собственного фреймворка для реактивной привязки данных. Попробовав это ранее и увидев, насколько дорогостоящим это может быть, я решил поработать с руководством в этом исследовании. Не для того, что бы развернуть свой собственный фреймворк, а посмотреть смогу ли я напрямую использовать веб-платформу таким образом, что бы сделать фреймворк менее необходимым. Если вы подумываете о внедрении собственного фреймворка, имейте в виду, что существует ряд затрат, не обсуждаемых в этой статье.

Ванильный выбор

Веб-платформа уже предоставляет декларативный механизм программирования из коробки: 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 и изменения состояния с помощью 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. Я думаю, что их следует использовать для группировки элементов с одинаковы стилем, я не в качестве универсального механизма для изменения стилей компонентов.

Преимущества форм

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:

Реализация 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-файл включает в себя следующее:

Обратите внимание, насколько лаконичным является этот 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, чтобы показать, как далеко это может зайти, но если он начинает становиться неудобным, то имеет смысл переместить его в модель.

Заключение и выводы

Я считаю, что фреймворки предоставляют удобные способы решения сложных задач, и у них есть преимущества, выходящие за рамки технических, такие как приведение группы разработчиков к определённому стилю и шаблонам. Веб-платформа предлагает множество вариантов, и внедрение фреймворка позволяет всем, по крайней мере частично, получить одинаковую страницу для некоторых из вариантов. В этом их ценность. Кроме тог, есть что сказать об элегантности декларативного программирования, и в этой статье я не рассматривал большую возможность компонентизации.

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

Краткое описание шаблона

Дополнительные материалы

Предыдущая Статья

Зачем нужны веб-фреймворки и как обойтись без них

Следующая Статья

Сравнение Node.js с JavaScript в браузере