Apollo управление состоянием в приложениях Vue
🤔 Зачем нам локальное управление состоянием в Apollo?
Представьте себе приложение Vue, извлекающее некоторые данные из REST API. Где вы обычно храните эти данные? Возможно, ответ будет «в локальном компоненте состояния (local component state)» или, если приложение достаточно большое, «в хранилище Vuex вместе с остальными данными». Этот верный ответ, потому что в приложении должен быть единственный источник данных.
Теперь представьте приложение, извлекающее данные из конечной точки GraphQL с помощью клиента Apollo. По умолчанию Apollo сохраняет эти данные в своем кеше. Но что, если мы используем локальное состояние приложения, хранящееся в Vuex? Если мы будем копировать данные из кэша Apollo в Vuex, мы удвоим наши данные и у нас, будут два источника данных. Как правильно хранить локальные данные?
Ранее существовала библиотека apollo-link-state для управления локальными данными. Но начиная с релиза Apollo 2.5 она нам больше не нужна, потому что эта функциональность теперь является частью ядра Apollo. Таким образом, мы можем просто управлять локальным состоянием без добавления каких-либо новых зависимостей 🎉.
🏗️ Что мы собираемся создать
Давайте попробуем создать простое приложение с клиентом Vue и Apollo.
Для экономии времени я уже подготовила исходное приложение (созданное через Vue CLI) с некоторыми пользовательскими стилями. Вы можете найти его исходный код здесь.
Склонируйте его себе локально и перейдите в ветку initial. Далее для запуска запусти следующие команды:
npm install npm run serve
Должен запуститься локальным сервер и если вы перейдете по адресу: http://localhost:8080/ должны увидеть следующее:
🔧 Добавление Apollo в приложение Vue
Первое, что нам нужно сделать, это установить клиент Apollo и интегрировать его в наше приложение Vue. Для интеграции мы будем использовать плагин vue-apollo.
Чтобы установить все, что нам нужно, введите в терминале следующую команду:
npm install --save vue-apollo graphql apollo-boost
или
yarn add vue-apollo graphql apollo-boost
Затем откройте файл main.js и добавьте следующее:
// main.js import VueApollo from 'vue-apollo'; Vue.use(VueApollo);
Таким образом, мы добавляем плагин vue-apollo в наше приложение Vue.
Теперь нам нужно настроить клиент Apollo. Сначала добавим импорт в начало файла main.js:
// main.js ... import VueApollo from 'vue-apollo'; import ApolloClient from 'apollo-boost'; // rest of imports Vue.use(VueApollo);
Затем давайте создадим клиента:
// main.js import VueApollo from 'vue-apollo'; import ApolloClient from 'apollo-boost'; // rest of imports Vue.use(VueApollo); const apolloClient = new ApolloClient({});
Добавим провайдера на основе созданного клиента и внедрите его в экземпляр приложения Vue:
// main.js const apolloProvider = new VueApollo({ defaultClient: apolloClient, }); new Vue({ render: h => h(App), apolloProvider, //here goes your Apollo provider }).$mount('#app');
Теперь мы готовы создать хранилище на базе Apollo.
🗃️ Инициализация кеша Apollo
Мы собираемся инициализировать кэш Apollo, где мы будем хранить наши задачи. Для этого Apollo имеет конструктор InMemoryCache :
// main.js import VueApollo from 'vue-apollo'; import ApolloClient from 'apollo-boost'; import { InMemoryCache } from 'apollo-cache-inmemory'; // rest of imports ... const cache = new InMemoryCache(); ...
Теперь нам нужно добавить его к нашему клиенту:
// main.js ... const apolloClient = new ApolloClient({ cache, }); ...
Пока что наш cache пуст, и мы собираемся добавить в него некоторые данные. Но сначала давайте создадим локальную схему. Этот шаг может быть необязательным, но так же, как схема является первым шагом к определению нашей модели данных на сервере, написание локальной схемы — это первый шаг, который мы предпринимаем для клиента.
📃 Создание локальной схемы
Давайте на минутку подумаем: как должен выглядеть наш список задач? Он определенно должен иметь текст, но что еще? Вероятно, нам нужно какое-то свойство, чтобы определить, сделана ли уже задача или нет, а также идентификатор ID, чтобы отличить один todo-элемент от другого. Итак, это должен быть объект с тремя свойствами:
{ id: 'uniqueId', text: 'some text', done: false }
Теперь мы готовы добавить item type в локальную схему GraphQL.
Давайте создадим новый файл resolvers.js в папке src и добавим в него следующий код
import gql from 'graphql-tag'; export const typeDefs = gql` type Item { id: ID! text: String! done: Boolean! } `;
gql
обозначает буквальный тег шаблона JavaScript, который анализирует строки запроса GraphQL.
Великолепно! Давайте импортируем typeDef и добавим его в наш клиент Apollo:
// main.js ... import ApolloClient from 'apollo-boost'; import { InMemoryCache } from 'apollo-cache-inmemory'; import { typeDefs } from './resolvers'; // rest of imports .... const apolloClient = new ApolloClient({ cache, typeDefs, resolvers: {}, });
Обратите внимание на пустой объект resolvers: если мы не назначим ему параметры и он остается пустым в клиенте Apollo, то клиент не распознает запросы к локальному состоянию и попытается вместо этого отправить запрос на удаленный URL
Теперь нам нужно добавить некоторые начальные данные в наш кеш. Чтобы написать это прямо здесь, мы будем использовать метод writeQuery:
// main.js ... // apollo client code cache.writeData({ data: { todoItems: [ { __typename: 'Item', id: 'dqdBHJGgjgjg', text: 'test', done: true, }, ], }, }); // apollo provider code ...
Мы только что добавили массив todoItems в данные нашего кеша и указываем, что у каждого элемента есть typename Item (указанное в нашей локальной схеме).
Теперь мы готовы запрашивать наши локальные данные из нашего компонента Vue!
🔦 Запрос к локальным данным
Во-первых, нам нужно создать запрос GraphQL для извлечения данных. Давайте создадим папку src/graphql, добавим в нее файл queries.js и импортируем в нее graphql-tag.
// queries.js import gql from 'graphql-tag';
Теперь давайте напишем запрос:
// queries.js import gql from 'graphql-tag'; export const todoItemsQuery = gql` { todoItems @client { id text done } } `;
Итак, мы определили здесь имя запроса (todoItems) и указали, что этот запрос не должен выполняться для удаленного API GraqhQL. Директива @client сообщает клиенту Apollo, что он должен получить результаты в локальном хранилище данных.
Наконец, мы готовы отправить запрос из компонента Vue. Для этого откроем наш App.vue, импортировав туда константу запроса:
<script> import { todoItemsQuery, } from "./graphql/queries.js"; export default { // rest of App.vue ...
и создадим запрос Apollo в компоненте:
// App.vue export default { ... apollo: { todoItems: { query: todoItemsQuery } }, ...
Можете ли вы поверить, что этого достаточно, чтобы получить все, что нам нужно? На самом деле да! Этот запрос будет эффективно извлекать наши локальные данные и сохранять их в свойстве todoItems App.vue.
✏️ Изменение локальных данных
Теперь нам нужно найти способ изменять данные в хранилище: добавить новый элемент, удалить элемент или переключить свойство done элемента.
Мы уже изменили локальные данные, когда устанавливали начальные todoItems в кеше. Этот способ называется прямой записью в кэш и полезен для начальной настройки или внесения некоторых очень простых изменений.
Для более сложных изменений в GraphQL мы используем мутации (mutations). Итак, давайте вернемся к нашей схеме и определим там некоторые мутации!
// resolvers.js export const typeDefs = gql` type Item { id: ID! text: String! done: Boolean! } type Mutation { changeItem(id: ID!): Boolean deleteItem(id: ID!): Boolean addItem(text: String!): Item } `;
Мы только что добавили три мутации для выполнения различных операций с нашими задачами. Две из них (changeItem и deleteItem) принимают идентификатор элемента ID; Для создания нового элемента addItem требуется text, и мы собираемся создавать в нем уникальный идентификатор ID.
Установка/снятие отметки с todo-item
Мы начнем с мутации changeItem. Прежде всего, давайте добавим его в наш файл queries.js:
// queries.js ... export const checkItemMutation = gql` mutation($id: ID!) { checkItem(id: $id) @client } `;
Мы определили локальную мутацию (потому что у нас здесь есть директива @client), которая будет принимать ID. Теперь нам нужен resolver: функция, которая распознает значение для типа или поля в схеме.
В нашем случае resolver определит, какие изменения мы хотим внести в наш локальный кеш Apollo, когда у нас есть определенная мутация. Локальные resolvers имеют ту же сигнатуру функции, что и удаленные resolvers ((parent, args, context, info) => data). Фактически нам понадобятся только args (аргументы, передаваемые мутации) и context (нам понадобится его свойство cache для чтения и записи данных)
Давайте начнем с добавления константу resolvers в наш файл resolvers.js.
// resolvers.js export const resolvers = { Mutation: { checkItem: (_, { id }, { cache }) => {}, } };
Итак, мы создали resolver для checkItem, но пока он ничего не делает. Мы передали в него идентификатор id из аргументов мутации и cache из context, используя деструктуризацию объектов ES6. Давайте прочитаем наш кеш, чтобы получить текущие todoItems:
// resolvers.js ... import { todoItemsQuery } from './graphql/queries'; .... export const resolvers = { Mutation: { checkItem: (_, { id }, { cache }) => { const data = cache.readQuery({ query: todoItemsQuery }); }, } };
Как вы видите, мы импортировали наш todoItemsQuery, чтобы сообщить нашему resolver, что именно мы читаем из кэша Apollo. Теперь давайте добавим логику, чтобы изменить значение свойства done на противоположное:
// resolvers.js import { todoItemsQuery } from './graphql/queries'; export const resolvers = { Mutation: { checkItem: (_, { id }, { cache }) => { const data = cache.readQuery({ query: todoItemsQuery }); const currentItem = data.todoItems.find(item => item.id === id); currentItem.done = !currentItem.done; }, };
Наконец, нам нужно записать наши измененные данные обратно в кеш и вернуть значение currentItem.done:
// resolvers.js import { todoItemsQuery } from './graphql/queries'; export const resolvers = { Mutation: { checkItem: (_, { id }, { cache }) => { const data = cache.readQuery({ query: todoItemsQuery }); const currentItem = data.todoItems.find(item => item.id === id); currentItem.done = !currentItem.done; cache.writeQuery({ query: todoItemsQuery, data }); return currentItem.done; }, } };
Теперь наш resolver готов, и мы собираемся вызвать нашу мутацию из компонента Vue. Вернемся к App.vue, импортируем туда мутацию и изменим метод checkItem:
<script> import { todoItemsQuery, checkItemMutation, } from "./graphql/queries.js"; export default { ... methods: { checkItem(id) { this.$apollo.mutate({ mutation: checkItemMutation, variables: { id } }); }, } }; </script>
Что тут происходит? Мы вызываем метод $apollo.mutate (предоставляемый плагином vue-apollo) и передаем созданную ранее мутацию в queries.js и переменную id (идентификатор передается из шаблона, в котором мы проверяем элемент):
<ListItem v-for="(item, index) in todoItems" :key="index" :content="item" @toggleDone="checkItem(item.id)" @delete="deleteItem(item.id)" />
Далее нам нужно подключить наш resolver в файле main.js:
// main.js ... import { typeDefs, resolvers } from './resolvers'; ... const apolloClient = new ApolloClient({ cache, typeDefs, resolvers }); ...
Теперь, когда мы нажимаем на флажок, мы запускаем мутацию, которая изменяет наше локальное состояние. Мы сразу видим, что наш массив todoItems изменяется с этой мутацией, поэтому флажок становится отмеченным или не отмеченным.
Удаление элемента
Теперь нам нужен способ удалить элемент. Давайте начнем снова с создания мутации deleteItem:
// queries.js export const deleteItemMutation = gql` mutation($id: ID!) { deleteItem(id: $id) @client } `;
Как видите, это очень похоже на предыдущий метод: опять же, мы передаем ID в качестве параметра. Теперь давайте добавим для него resolver:
// resolvers.js export const resolvers = { Mutation: { ... deleteItem: (_, { id }, { cache }) => { const data = cache.readQuery({ query: todoItemsQuery }); const currentItem = data.todoItems.find(item => item.id === id); data.todoItems.splice(data.todoItems.indexOf(currentItem), 1); cache.writeQuery({ query: todoItemsQuery, data }); return true; }, ...
Опять же, мы читаем todoItemsQuery из кэша в качестве первого шага и записываем его позже (и мы просто возвращаем true, чтобы показать, что запрос был успешным). Но вместо изменения currentItem мы просто удаляем его из массива todoItems.
Теперь давайте добавим эту мутацию в App.vue.
<script> import { todoItemsQuery, checkItemMutation, deleteItemMutation } from "./graphql/queries.js"; export default { ... methods: { deleteItem(id) { this.$apollo.mutate({ mutation: deleteItemMutation, variables: { id } }); } } }; </script>
Очень похоже на checkItem, не так ли?
Добавление нового элемента
Хотя две предыдущие мутации были действительно похожи друг на друга, addItem будет отличаться. Прежде всего, мы будем передовать текст, а не идентификатор ID:
// queries.js export const addItemMutation = gql` mutation($text: String!) { addItem(text: $text) @client { id text done } } `;
Можно предположить, что resolver также будет более сложным: нам нужно как-то сгенерировать уникальный идентификатор. Для этого проекта мы будем использовать библиотеку shorttid:
npm install --save shortid
или
yarn add shortid
Теперь давайте начнем создавать наш resolver:
// resolvers.js import shortid from 'shortid'; export const resolvers = { Mutation: { ... addItem: (_, { text }, { cache }) => { const data = cache.readQuery({ query: todoItemsQuery }); const newItem = { __typename: 'Item', id: shortid.generate(), text, done: false, }; }, } }
Как видите, теперь мы берем text из наших аргументов мутации и устанавливаем свойство newItem.text равным ему. Для идентификатора мы генерируем новый уникальный идентификатор с помощью метода shorttid.generate. Что касается свойства done, мы всегда устанавливаем его в false при создании нового элемента todo (потому что, очевидно, это еще не сделано!).
Теперь нам нужно только поместить этот вновь созданный элемент в массив todoItems, записать данные обратно в кеш и вернуть newItem в качестве результата мутации.
// resolvers.js addItem: (_, { text }, { cache }) => { const data = cache.readQuery({ query: todoItemsQuery }); const newItem = { __typename: 'Item', id: shortid.generate(), text, done: false, }; data.todoItems.push(newItem); cache.writeQuery({ query: todoItemsQuery, data }); return newItem; },
Мы готовы вызвать нашу мутацию addItem из компонента! Давайте импортировать его в App.vue …
<script> import { todoItemsQuery, checkItemMutation, addItemMutation, deleteItemMutation } from "./graphql/queries.js"; ... </script>
… и добавим его в метод addItem:
addItem() { if (this.newItem) { this.$apollo.mutate({ mutation: addItemMutation, variables: { text: this.newItem } }); this.newItem = ""; } },
newItem здесь представляет строку из поля ввода, и мы собираемся вызывать мутацию, только когда у нас есть некоторый фактический текст для отправки. Также, после того, как мы добавили новый элемент, мы очищаем ввод.
Ура, наше приложение готово! 🎉
Вы можете найти полный исходный код приложения здесь.
Оригинальная статья Natalia Tepluhina: Apollo state management in Vue application
Наташа, хорошая статья.
Подскажи где найти серию по apollo для vue? Для чего нужен Apollo и apollo-boost?
Лучше спрашивать в оригинальной статье, я чудом увидела этот коммент 🙂
Серии по Apollo для Vue, к сожалению, нет (за исключением самых базовых кейсов). Даже в случае тестирования приходится ходить по граблям.
Apollo Client — удобный клиент для GraphQL API, который «из коробки» дает нам реактивный кэш, loading и error плюс возможность в этом кэше хранить, что нам нужно.
apollo-boost — просто сборка нескольких Apollo-библиотек в одну
Боюсь что код cache.writeData устарел и в версии > 3 apollo-client он незаработает