В прошлый раз, когда мы делали to-do на Alpine.js, меня очень сильно расстроило, что, хоть я и могу создавать вложенные компоненты, я не могу получать данные из родителя. Через какую-нибудь переменную, $parent
, например.
Поэтому мне пришлось запихивать все яйца в одну корзину. Свойства и методы, отвечающие за добавление новых задач, перемешались со всеми остальными. Я хотел выделить отдельный компонент, но необходимость доступа к массиву todos
меня ограничивала.
Если вы подумали, что это не очень хорошо, то вы не правы. На самом деле, это ужасно.
Всё, расходимся? Нет. Я еще раз полистал документацию и вспомнил про магическое свойство $dispatch
. Ну, конечно… однопоточная связь, проброс событий. Ну давайте попробуем. А потом еще переосмыслим всё с глобальным store.
Проброс событий
Для начала откроем наш код.
Первое, что надо сделать, – превратить обертку над input
в компонент и перенести в него inputValue
.
<div x-data="{ inputValue: '' }" class="add-todo">
<input type="text" x-model="inputValue" placeholder="Новая задача" />
<button @click="addTodo()">Добавить</button>
</div>
Естественно, наш новый компонент понятия не имеет, что такое addTodo()
. Ему и не надо. Вместо этого мы будем диспатчить CustomEvent
.
<button @click="$dispatch('add', inputValue)">Добавить</button>
Мы диспатчим событие add
и передаем ему значение inputValue
как payload (в спецификации он называется detail
).
Теперь это событие надо принять. Примет его наш корневой компонент. И вызовет addTodo()
с нашим inputValue
.
<div x-data="todos()" x-init="fetchTodos()" @add="addTodo($event.detail)" class="app">
...
</div>
Осталось немного поправить addTodo()
и готово.
addTodo: function (inputValue) {
if (!inputValue) {
return;
}
this.todos.push({
id: Date.now(),
title: inputValue,
completed: false,
});
}
Всё круто, вот только теперь inputValue
не отчищается. В этой функции, естественно, мы это сделать не можем. Это нужно делать внутри компонента.
<button @click="$dispatch('add', inputValue); inputValue = ''">Добавить</button>
А можно?.. Нет. $dispatch
доступен только в разметке. В принципе, так тоже терпимо. Если бы нам нужно было делать больше логики, мы могли бы вместо inputValue = ''
вызвать конкретную функцию, которую бы определили в <script>
.
А можно?.. Ну конечно можно! Несмотря на то, что $dispatch
недоступен нам, в отличие от остальных магических свойств, в this
компонента, мы можем использовать хитрость и передать его как параметр функции.
Для удобства выделим функцию в <script>
для нашего внутреннего компонента.
function add() {
return {
inputValue: '',
dispatchAdd: function ($dispatch) {
$dispatch('add', this.inputValue);
this.inputValue = '';
}
}
};
Template теперь выглядит так.
<div x-data="add()" class="add-todo">
<input type="text" x-model="inputValue" placeholder="Новая задача" />
<button @click="dispatchAdd($dispatch)">Добавить</button>
</div>
У нас получилось перенести inputValue
в отдельный компонент. Неплохо, но хотелось бы и addTodo()
перенести. Как сделать это?
На самом деле, элементарно. Просто передайте в detail
вместо inputValue
уже готовый объект to-do и запушьте его в todos
.
...
А знаете что? Попробуйте сами. Сделаем статью более обучающей :) И не забудьте отчистить inputValue
после. Вот как я это сделал.
Глобальное хранилище данных
Вы, наверное, заметили, что в заголовке заявлена еще одна тема. Всё, что мы делали выше, – это, конечно, круто. Но масштабируемость сильно хромает. И рано или поздно придет мысль: "Было бы круто все данные хранить в отдельном store, как это делает Redux/Mobx/Vuex и т.п. И обращаться уже к нему, не прокидывая ничего вверх без надобности."
Знакомьтесь, Spruce. 2 килобайта чистой годноты.
Вернемся к оригинальному коду и сделаем все по новому. Для начала подключим, а разберемся по ходу.
<head>
...
<script src="https://cdn.jsdelivr.net/gh/ryangjchandler/spruce@0.6.0/dist/spruce.umd.js"></script>
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.3.5/dist/alpine.min.js"></script>
</head>
До Alpine.js и удалить у Alpine defer
. Такую цену придется заплатить. Довольно дешево, я беру.
Теперь подписываем наш корневой компонент к store
. Данные внутри компонента нам больше не понадобятся.
<div x-data x-subscribe class="app">
...
</div>
Spruce дает нам одноименную переменную в window
. Чтобы создать store, используется Spruce.store(<название>, <объект>)
.
Spruce.store('data', {
todos: [],
inputValue: '',
});
*store может быть неограниченное количество.
Теперь, чтобы дотянутся до значений, можно использовать $store.data.todos
.
Сделаем разделение по Vue Options API – данные будем хранить в data
, а методы – в methods
.
Spruce.store('methods', {
toggleTodo: (id) => {
const todo = $store.data.todos.find((todo) => todo.id === id);
if (todo != null) {
todo.completed = !todo.completed;
}
},
addTodo: function () {
if (!$store.data.inputValue) {
return;
}
$store.data.todos.push({
id: Date.now(),
title: $store.data.inputValue,
completed: false,
});
$store.data.inputValue = '';
},
deleteTodo: function (id) {
$store.data.todos = $store.data.todos.filter((todo) => todo.id !== id);
},
});
Слышите этот звук? Это скрипят зубы у евангелистов иммутабельности. Здесь её нет, она и не нужна, так как масштабы не те. Иммутабельность сложнее, требует больше написанного кода и очень редко когда приносит реальную пользу. Сложность точно не уровня Alpine.
Ну и, собственно, наш template.
<div x-data x-subscribe class="app">
<h1>Планы на сегодня:</h1>
<ul>
<template x-for="todo in $store.data.todos" :key="todo.id">
<li @click="$store.methods.toggleTodo(todo.id)" :class="{'completed': todo.completed}">
<span x-text="todo.title" class="title"></span>
<span @click="$store.methods.deleteTodo(todo.id)" class="delete-todo">×</span>
</li>
</template>
</ul>
<div class="add-todo">
<input type="text" x-model="$store.data.inputValue" placeholder="Новая задача" />
<button @click="$store.methods.addTodo()">Добавить</button>
</div>
</div>
Последний штрих – нужно получить данные с API. В Spruce для этого есть удобный метод. Если Spruce.on(<событие>, <колбэк>)
предназначен для навешивания событий, то Spruce.once(<событие>, <колбэк>)
как раз для выполнения какого-то действия один раз. Событие init
– то, что мы ищем.
Spruce.once('init', async ({ store }) => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos');
const data = await response.json();
store.data.todos = data.slice(0, 10);
});
Mission accomplished.
Можно легко превратить наш Options API в "Composition API", где каждый store отвечает за конкретную функциональность. Делите ваши данные, как заблагорассудится.
При этом нам не нужны внутренние компоненты и проброс данных через события, так как все компоненты имеют равный доступ к $store
. Актуально на фоне борьбы с архитектурами master/slave :)
Полезные ссылки:
*Photo by Jakub Kapusnak on Unsplash