
В прошлый раз, когда мы делали 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
