Давайте поговорим о том, что внутри, под капотом Alpine.js. К концу статьи вы узнаете, как происходит создание современного реактивного фронтенд фреймворка путем написания оного своими руками. Это легче, чем вам кажется.
Эта статья – вольный перевод мини-курса Building AlpineJS от создателя библиотеки Caleb'а Porzio на Laracasts. Если вы в нормальных отношениях с английским и больше любите видео, чем текст, – милости прошу. 4 из 6 уроков бесплатны, остальные по подписке. Статья затрагивает только бесплатные уроки. В принципе, их нам будет достаточно, чтобы понять, что там внутри.
Предыдущие статьи серии:
- Alpine.js на конкретном примере – пишем простую тудушку на Alpine.js;
- Alpine.js – события и глобальное хранилище данных – говорим о взаимодействии компонентов и о способах хранения данных.
Рекомендую повторять все действия параллельно с автором для полного погружения в процесс. Если вы не хотите этого делать, полный код проекта из статьи доступен вам здесь. Дальше повествование идет от лица Caleb'а.
Всё начинается с данных
Раньше фронтенд-фреймворки больше напоминали мне черный ящик, у которого неизвестно, что внутри. Но после написания своего я осознал, что на самом деле основные компоненты фреймворка не так уж и сложно понять. Чтобы вас в этом убедить, предлагаю вместе написать Alpine.js с нуля.
Сначала создадим базовый компонент Alpine.js. Максимально простой.
<div x-data="{ count: 0 }">
<button @click="count++">+</button>
<button @click="count--">-</button>
<span x-text="count"></span>
</div>
Какая функциональность нам нужна?
Любой фронтенд-фреймворк работает по следующему алгоритму:
- Следим за данными;
- Когда данные меняются, обновляем DOM.
При нажатии на определенную кнопку, значение в <span>
должно меняться. "Умная система" должна проходится по DOM и обновлять то, что должно быть обновлено. В данном примере, когда значение count
меняется, нужно, чтобы поменялось всё, что на этот count
ссылается.
Первое, что нам нужно сделать, – получить данные.
<script>
let root = document.querySelector('[x-data]');
let dataString = root.getAttribute('x-data');
</script>
Если мы посмотрим в консоли, чему равна переменная dataString
, мы получим строку "{ count: 0 }"
. Как преобразовать её в объект? Первое, что приходит в голову – JSON.parse()
. Но нам это не подойдет, потому что JSON.parse()
очень строгий. Он требует JSON-нотации, где все ключи будут в двойных кавычках, как '{ "count": 0 }'
. Это не очень гибко.
Что нам отлично подойдет – это eval(). Во фронтенд-фреймворке он не так опасен, как, например, в PHP, но если вы погуглите, то вам довольно быстро объяснят, почему пользоваться им не надо. Сейчас Alpine.js уже не использует eval()
, но для упрощения в нашем примере мы им воспользуемся.
let data = eval(`(${dataString})`);
Для того, чтобы не конкатенировать строки, мы используем обратные кавычки. А чтобы выражение внутри eval()
возвращало нам объект, мы оборачиваем все в круглые скобки.
Выведем получение данных в отдельную функцию:
<script>
let root = document.querySelector('[x-data]');
let data = getInitialData();
function getInitialData() {
let dataString = root.getAttribute('x-data');
return eval(`(${dataString})`);
}
</script>
Первая цель выполнена – мы получили данные из нашего компонента.
Оживляем DOM
Второе, что нужно сделать, – вывести count
в <span>
. То есть пройтись по DOM-дереву и обновить в нем необходимые значения. Есть несколько способов, как это сделать. Нам нужен цикл, который проходится по элементам и проверяет, есть ли у них атрибут x-text
. Если есть, то он прочитывает значение атрибута, находит это значение в данных и обновляет на него innerText
элемента.
Мы можем сделать так:
function refreshDom() {
document.querySelectorAll('[x-text]').forEach(el => ...);
}
Но, во-первых, это не будет работать с модификаторами, например, @click.window
. Во-вторых, это медленно.
Давайте сделаем то же самое, что делает оригинальный Alpine.js – он "шагает" по DOM. Начиная с родительского элемента с x-data
, он рекурсивно проходится по всем элементам. Если у элемента есть потомки, он проходится и по ним.
Сделаем функцию walkDom()
. Начнем с того, как она используется:
function refreshDom() {
walkDom(root, (el) => {
console.log(el.outerHTML);
});
}
refreshDom(); // не забудьте вызвать, иначе не заработает
Первый аргумент – элемент, второй – колбэк. Для начала мы просто выведем каждый элемент в консоль.
А вот сама функция walkDom()
. Она, на самом деле, очень простая, но, как и любая рекурсия, немного сложна для восприятия.
function walkDom(el, callback) {
callback(el); // 1
el = el.firstElementChild; // 2
// 3
while (el) {
walkDom(el, callback); // 4
el = el.nextElementSibling; // 5
}
}
Сначала вызываем колбэк (1). Получаем первого потомка (2). Если у элемента есть потомок (3), то вызываем walkDom()
еще раз (4), с этим потомком. Который, если имеет потомка (2), вызывает walkDom()
со своим потомком (4). И так далее, пока не закончатся все потомки. Затем переходим к следующему потомку предыдущего элемента (5). И делаем то же самое. Функция остановится, когда запустится для каждого элемента, независимо от того, как глубоко он запрятян.
Можно попробовать. Для наглядности добавим еще один div
с вложенным внутрь span
<div x-data="{ count: 0 }">
<button @click="count++">+</button>
<button @click="count--">-</button>
<span x-text="count"></span>
<div>
<span>внутренний элемент</span>
</div>
</div>
В консоли мы получим следующее:
Сначала мы получаем внешний элемент со всеми потомками. Потом первый button
, затем второй button
, span
, div
и внутренний span
. Отлично! Теперь мы умеем "ходить" по DOM. Всё, что осталось, – изменить наш колбэк.
function refreshDom() {
walkDom(root, (el) => {
if (el.hasAttribute('x-text')) {
let expression = el.getAttribute('x-text'); // count
el.innerText = eval(`(${expression})`); // innerText = count
}
});
}
Мы проверяем, есть ли у элемента атрибут x-text
, и, если есть, меняем его innerText
на значение из объекта. В нашем случае – count
.
Посмотрим на результат:
count is not defined
. Ничего удивительного. Мы делаем innerText
элемента равным count
, но никакого count
в глобальной видимости (window) у нас нет. Для того, чтобы это работало правильно в HTML мы должны писать <span x-text="data.count"></span>
. Теперь всё работает, как надо. Но нам бы не хотелось писать каждый раз data.*
, это неудобно. Мы хотим писать count
, но подразумевать data.count
. Как это сделать?
Когда я столкнулся с этой проблемой, я застрял на несколько дней, пока не догадался посмотреть в исходники Vue.js, и не узнал, что делают они.
В JavaScript, когда мы пишем variable
, мы подразумеваем window.variable
. Движок, когда ищет глобальную переменную, сам смотрит в объект window
и, если не находит её там, выбрасывает ошибку. Всё, что нам нужно сделать, – это сказать движку, что перед тем, как смотреть в window
, он должен посмотреть в нашу переменную data
.
Как это сделать? На самом деле, очень просто. Этот функционал встроен в язык с момента его создания. Это выражение with() {}. В "обычном" программировании его рекомендуется не использовать, так как он создает путаницу. Но в нашем случае – эта именно та часть пазла, которой нам так не хватает.
walkDom(root, (el) => {
if (el.hasAttribute('x-text')) {
let expression = el.getAttribute('x-text'); // count
el.innerText = eval(`with (data) { (${expression}) }`); // innerText = count
}
});
Готово! x-text
заработал. Теперь, если написать в консоли
data.count = 4;
refreshDom();
в браузере мы увидим 4. Что дальше? Думаю, вы уже догадались. Было бы неплохо, чтобы на каждое изменение DOM обновлялся сам. И это будет уже настоящая реактивность, к которой мы так стремимся.
Добавим реактивности
Нам нужно вызывать refreshDom()
каждый раз, когда меняются данные. То есть, нам нужно наблюдать за изменениями данных. Как это сделать?
В JavaScript наблюдение за изменениями было довольно сложным до добавления в ES6 Proxy. Прокси оборачивает объект и следит за всеми его изменениями. Поначалу они казались мне сложными, но, на самом деле, они очень простые.
Прокси – это класс, который принимает два параметра – объект, за которым нужно следить, и объект с "ловушками". "Ловушки" – это определенные функции, которые срабатывают при каком-либо действии с объектом. Например, ловушка get(<цель>, <ключ>)
перехватывает получение объекта и выполняется вместо этого (аналогично тому, как работает геттер).
let proxy = new Proxy(data, {
get(target, key) {
console.log('Получаем свойство...');
return target[key];
},
});
Теперь, если мы напишем proxy.count
, то сначала получим console.log, а затем само значение count
. data.count
продолжает работать, как обычно, и давать только значение.
Также есть ловушка set(<цель>, <ключ>, <значение>)
, которая изменяет значение (аналог сеттера).
let proxy = new Proxy(data, {
// ...
set(target, key, value) {
console.log('Изменяем свойство...');
target[key] = value;
},
});
Если мы сейчас напишем proxy.count = 4
, то сначала получим console.log, а затем изменим значение count
. При этом значение также изменится и в data.count
(ну вы понимаете, ссылочная связь и т.п.).
Надеюсь, вы уже поняли, как мы можем использовать эти ловушки для автоматического обновления наших данных.
Давайте переименуем наш объект data
в rawData
, а data
назовем наш объект с обозревателем.
// ...
let rawData = getInitialData();
let data = observe(rawData);
// ...
function observe(data) {
return new Proxy(data, {
set(target, key, value) {
target[key] = value;
refreshDom();
},
});
}
// ...
Всё! Теперь каждый раз при изменении свойств объекта data
, это изменение будет перехватываться set()
и вызывать refreshDom()
.
Откроем наше приложение в браузере.
Теперь, если написать в консоли
data.count = 4;
данные в браузере поменяются сами! Вот она реактивность!
Во Vue 3 для реактивности используется пакет @vue/reactivity, который под капотом использует прокси. В упоминаниях к этому пакету можно увидеть другой пакет salesforce/observable-membrane. Он позволяет делать всё, что мы делали выше, прямо из коробки.
Оригинальный Alpine.js долгое время использовал самописные прокси. Но там очень много подводных камней. Возможно, после прочтения этой статьи вы подумаете "Ну окей, вот я и написал Alpine.js. Заняло 15 строк. Ну и зачем мне нужны это 7 килобайт?". Но, когда проект становится большим, вскрываются все самые мелкие недостатки. И вместо вручную написанных прокси становится проще просто взять уже готовый пакет. Поэтому сейчас Alpine.js использует observable-membrane, библиотеку, сильно вдохновившую ядро реактивности Vue 3.
Итак, реактивность готова. Теперь последнее – хотелось бы иметь возможность нажимать на кнопки.
Прослушка событий
Для регистрации прослушивателей событий мы будем использовать тот же самый паттерн, который мы использовали в refreshDom()
, а именно: пройти по всем элементам DOM, найти все @click
, зарегистрировать для них прослушиватели.
Мы могли бы даже сделать это внутри функции refreshDom()
. Единственная проблема – при каждом изменении данных прослушиватели добавлялись бы повторно, что сильно бы все усложнило. Поэтому для этого мы сделаем отдельную функцию. Запускаем мы ее единожды, после загрузки DOM.
let root = document.querySelector('[x-data]');
let rawData = getInitialData();
let data = observe(rawData);
registerListeners();
refreshDom();
// ...
function registerListeners() {
walkDom(root, (el) => {
if (el.hasAttribute('@click')) {
let expression = el.getAttribute('@click');
el.addEventListener('click', () => {
eval(`with (data) { (${expression}) }`);
});
}
});
}
Готово! Теперь наши кнопки "+" и "-" работают.
Что дальше
На этом статья подходит к концу. Мы полностью имплементировали работу Alpine.js для нашего конкретного куска HTML-кода. В последних двух видеоуроках курса на Laracasts рассказывается как добавить другие обработчики событий, такие как mouseenter
и другие, как добавить другие директивы, а также, чем код, который мы написали отличается от реального кода Alpine.js.
Эти видео входят в платную подписку Laracasts, поэтому не описаны здесь. Если вам интересно, ссылка есть выше. Однако, надеюсь, даже без них, вы смогли понять и попробовать, что это такое, написать собственный фронтенд фреймворк. Спасибо за внимание.