Реализация реактивности и компонуемости стандартными средствами таких фреймворков, как React, Vue и прочие, несёт собой ряд сложностей, включая необходимость настройки множества зависимостей. Но этой цели также можно достичь более простым путём, о чём и пойдёт речь в текущей статье.
Для начала небольшое уточнение. Под фреймворком я подразумеваю систему, которая позволяет избегать необходимости написания стандартного HTML и JS-кода вроде такого:
<p id="cool-para"></p>
<script>
const coolPara = 'Lorem ipsum.';
const el = document.getElementById('cool-para');
el.innerText = coolPara;
</script>
Вместо этого он даёт возможность писать магический HTML и JS-код, наподобие такого (Vue):
<script setup>
const coolPara = 'Lorem ipsum.';
</script>
<template>
<p>{{ coolPara }}</p>
</template>
Или такого (React):
export default function Para() {
const coolPara = 'Lorem ipsum';
return <p>{ coolPara }</p>;
}
Преимущества здесь вполне понятны. Запоминать слова или фразы вроде document
, innerText
и getElementById
трудно – очень уж много слогов.
Хотя, естественно, количество слогов здесь не самое главное.
Первая основная причина в том, что во втором и третьем примерах можно просто установить или обновить значение переменной coolPara
, и разметка – т.е. элемент <p>
– обновится без необходимости явной установки её innerText
.
Это называется реактивность – UI привязан к данным таким образом, что простое их изменение ведёт также и к его обновлению.
Вторая основная причина – это возможность определять компонент и переиспользовать его без необходимости повторного определения в каждом месте, где он потребуется. Это называется компонуемость.
Стандартный HTML + JavaScript по умолчанию не обладает такой возможностью, поэтому следующий код не делает того, что по ощущениям должен:
<!— Определение компонента —>
<component name="cool-para">
<p>
<content />
</p>
</component>
<!— Использование компонента —>
<cool-para>Lorem ipsum.</cool-para>
Реактивность и компонуемость являются двумя основными преимуществами, которые нам предоставляют обычные фронтенд-фреймворки, такие как Vue, React и прочие.
Но эти абстракции даются не столь легко. Разработчику требуется предварительно загрузить множество специфичных для фреймворка компонентов, разобраться с их нестыковками, в результате которых что-то может работать непостижимым странным образом, и плюсом ко всему наладить кучу уязвимых для сбоев зависимостей.
Однако оказывается, что при использовании современных Web API реализовать эти две функциональные возможности не так уж трудно. И в большинстве случаев нам не потребуются обычные фреймворки со всеми своими сложностями.
Реактивность
Простым языком, реактивность объясняется как автоматическое обновление пользовательского интерфейса вслед за обновлением данных.
Первым делом нужно узнать, когда данные обновляются. К сожалению, это в возможности стандартных объектов не входит. Нельзя просто прикрепить слушателя ondataupdate
для отслеживания событий обновления данных.
Зато в JavaScript как раз есть элемент, который позволит это сделать – Proxy
.
▍ Объекты Proxy
Proxy
позволяет создавать из стандартного объекта прокси-объект:
const user = { name: 'Lin' };
const proxy = new Proxy(user, {});
И этот прокси-объект способен прослушивать события изменения данных.
В примере выше у нас присутствует объект Proxy
, но пока что, узнав об изменении name
, он ничего не предпринимает.
Чтобы это исправить, нам необходим обработчик, представляющий объект, который сообщает прокси-объекту, что делать в случае изменения данных.
// Обработчик, прослушивающий операции присваивания данных
const handler = {
set(user, value, property) {
console.log(`${property} is being updated`);
return Reflect.set(user, value, property);
},
};
// Создание proxy с обработчиком
const user = { name: 'Lin' };
const proxy = new Proxy(user, handler);
Теперь при каждом обновлении name
с помощью прокси-объекта мы будем получать сообщение: name is being updated
.
Если вы думаете: «Да что здесь такого, я мог проделать это с помощью старого-доброго сеттера». Объясню в чём тут суть:
- Метод
proxy
обобщается, и обработчики можно использовать повторно. Это означает, что… - Любое значение, которое вы устанавливаете на проксируемый объект, можно рекурсивно конвертировать в прокси, а значит...
- Теперь у вас есть тот самый магический объект с возможностью реагировать на обновление данных, вне зависимости от степени их вложенности.
Помимо этого, вы можете обрабатывать несколько других событий доступа, например, связанных со свойствами read
, updated
, deleted
и так далее.
Теперь, когда у вас есть возможность прослушивать операции, необходимо реализовать осмысленное на них реагирование.
▍ Обновление UI
Если помните, то второй частью реактивности выступает автоматическое обновление UI. Для этого нам нужно получить соответствующий элемент UI, требующий обновления. Но прежде необходимо отметить этот элемент как подходящий.
Это мы сделаем с помощью атрибутов data-*, функционала, который позволяет устанавливать для элемента произвольные значения:
<div>
<!-- Mark the h1 as appropriate for when "name" changes -->
<h1 data-mark="name"></h1>
</div>
Приятная особенность атрибутов data-* в том, что теперь мы можем найти все подходящие элементы с помощью:
document.querySelectorAll('[data-mark="name"]');
Далее мы просто устанавливаем innerText
всех этих элементов:
const handler = {
set(user, value, property) {
const query = `[data-mark="${property}"]`;
const elements = document.querySelectorAll(query);
for (const el of elements) {
el.innerText = value;
}
return Reflect.set(user, value, property);
},
};
// Стандартный объект опускается, так как не нужен.
const user = new Proxy({ name: 'Lin' }, handler);
В этом и состоит суть реактивности.
Ввиду общей специфики нашего handler
, для любого установленного свойства user
будут обновляться все подходящие элементы UI.
Вот такими мощными возможностями обладает Proxy
– за счёт применения некоторой смекалки он способен предоставить нам магические реактивные объекты при полном отсутствии зависимостей.
Теперь перейдём ко второй важной концепции.
Компонуемость
Как оказывается, в браузерах уже есть для этого отдельная функция – Web-компоненты. Правда, ей мало кто пользуется, потому что это доставляет определённые сложности (а также, потому что обычно большинство разработчиков с самого начала проекта задействуют привычные фреймворки).
Для реализации компонуемости сначала необходимо определить компоненты.
▍ Определение компонентов с помощью template и slot
Теги <template>
используются для хранения не отображаемой браузером разметки. К примеру, вы можете добавить к себе в HTML следующий её вариант:
<template>
<h1>Will not render!</h1>
</template>
И она отображаться не будет. Эти теги можно рассматривать как невидимые контейнеры для компонентов.
Следующим составляющим является элемент <slot>
, который определяет место расположения содержимого в компоненте. Это позволяет повторно использовать компонент с другим содержимым, по сути, делая его компонуемым.
Например, вот элемент h1
, окрашивающий свой текст в красный.
<template>
<h1 style="color: red">
<slot />
</h1>
</template>
Прежде, чем перейти к использованию наших компонентов, таких как красный h1
выше, их нужно зарегистрировать.
▍ Регистрация компонентов
Для регистрации же компонента его необходимо проименовать, что можно сделать с помощью атрибута name
:
<template name="red-h1">
<h1 style="color: red">
<slot />
</h1>
</template>
И теперь с помощью JS-кода можно получить компонент по его имени:
const template = document.getElementsByTagName('template')[0];
const componentName = template.getAttribute('name');
После чего, наконец, зарегистрировать его через customElements.define
:
customElements.define(
componentName,
class extends HTMLElement {
constructor() {
super();
const component = template.content.children[0].cloneNode(true);
this.attachShadow({ mode: 'open' }).appendChild(component);
}
}
);
В блоке выше происходит очень многое:
- мы вызываем
customElements.define
с двумя аргументами; - первый аргумент представляет имя компонента (то есть «red-h1»).
- второй аргумент – это класс, определяющий наш кастомный компонент как HTML-элемент.
В этом конструкторе класса мы используем копию шаблона red-h1
для установки теневого дерева DOM.
Теневая DOM элемента по умолчанию скрыта, в связи с чем на панели разработчика не отображается. Но здесь мы устанавливаем её режим как open
.
Это позволяет проинспектировать элемент и увидеть, что красный h1
прикреплён к #shadow-root
.
Вызов customElements.define
позволит использовать определённый компонент как стандартный HTML-элемент.
<red-h1>This will render in red!</red-h1>
Теперь пора объединить две рассмотренные концепции.
▍ Компонуемость + реактивность
К этому моменту мы проделали две вещи:
- Создали реактивную структуру данных, то есть прокси-объекты, которые при установке значения обновляют тот элемент, который мы обозначили как подходящий.
- Определили кастомный компонент
red-h1
, который будет отображать своё содержимое как red h1.
Теперь всё это можно объединить:
<div>
<red-h1 data-mark="name"></red-h1>
</div>
<script>
const user = new Proxy({}, handler);
user.name = 'Lin';
</script>
Вот мы и реализовали отображение наших данных кастомным компонентом, который также будет обновлять UI при их дальнейшем изменении.
Естественно, типичные фронтенд-фреймворки просто так этого не делают. В них есть специализированный синтаксис, такой как шаблоны в Vue или JSX в React, который позволяет писать код фронтенда в более сжатой форме.
Поскольку этот специализированный синтаксис не является стандартным JS или HTML, он не парсится браузерами, в связи с чем требует отдельных инструментов для своей компиляции в обычный JS, HTML и CSS. Поэтому больше никто и не пишет JavaScript.
Даже без специализированного синтаксиса вы можете реализовать очень многие возможности типичного фронтенд-фреймворка, добившись аналогичной лаконичности кода просто за счёт использования Proxy
и WebComponents
.
Приведённый в этой статье код очень упрощён, и для становления полноценным фреймворком требует доработки. Приведу ссылку на свой пример подобного проекта фреймворка под названием Strawberry.
В процессе его разработки я планирую придерживаться двух твёрдых требований:
- Отсутствие зависимостей.
- Отсутствие этапа сборки перед использованием.
А также следовать мягкому требованию по сохранению небольшого размера базы кода. На момент написания статьи этот фреймворк представляет собой всего один файл, содержащий менее 400 строк кода. Посмотрим, что получится в итоге ✌️
Автор: Дмитрий Брайт