обложка статьи


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