Apollo Client
Apollo Client - это библиотека для управления состоянием
React-приложений
, позволяющая управлять как локальными, так и удаленными данными с помощьюGraphQL
. Она может использоваться для получения, кеширования и модификации данных приложения с автоматическим обновлением пользовательского интерфейса.
Введение
Ядром Apollo Client
является библиотека @apollo/client
, предоставляющая встроенную поддержку для React
. Также имеются другие реализации.
Почему Apollo Client
?
Декларативное получение данных
Декларативный подход к получению данных состоит в инкапсуляции логики получения данных, отслеживания состояния загрузки и ошибок, а также обновления UI
с помощью хука useQuery()
. Эта инкапсуляция сильно упрощает интеграцию результатов выполнения запросов с презентационными компонентами.
function Feed() {
const { loading, error, data } = useQuery(GET_DOGS)
if (error) return <Error />
if (loading || !data) return <Loader />
return <DogList dogs={data.dogs} />
}
В число продвинутых возможностей, предоставляемых useQuery()
, кроме прочего, входит оптимистическое обновление UI
, автоматическое выполнение повторных запросов и пагинация.
Автоматическое кеширование
Одной из ключевых особенностей Apollo Client
является встроенный нормализованный кеш.
import { ApolloClient, InMemoryCache } from '@apollo/client'
const client = new ApolloClient({
cache: new InMemoryCache()
})
Нормализация позволяет обеспечивать согласованность данных при их использовании в нескольких компонентах. Рассмотрим пример:
const GET_ALL_DOGS = gql`
query GetAllDogs {
dogs {
id
breed
displayImage
}
}
`
const UPDATE_DISPLAY_IMAGE = gql`
mutation UpdateDisplayImage($id: String!, $displayImage: String!) {
updateDisplayImage(id: $id, displayImage: $displayImage) {
id
displayImage
}
}
`
Запрос GET_ALL_DOGS
получает всех собак и их изображения (displayImage
). Мутация UPDATE_DISPLAY_IMAGE
обновляет изображение определенной собаки. При обновлении изображения определенной собаки, соответствующий элемент списка также должен быть обновлен. Apollo Client
выделяет каждый объект из результата с __typename
и свойством id
в отдельную сущность в кеше. Это гарантирует, что при возврате значения из мутации, каждый запрос на получение объекта с этим id
будет автоматически обновлен. Это также гарантирует, что два запроса, возвращающие одинаковые данные, всегда будут синхронизированы между собой.
Интерфейс политики кеширования (cache policy API) позволяет повторно использовать данные, которые запрашивались ранее. Запрос на получение определенной собаки может выглядеть так:
const GET_DOG = gql`
query GetDog {
dog(id: 'abc') {
id
breed
displayImage
}
}
`
Для того, чтобы в ответ на этот запрос возвращались данные из кеша, необходимо определить кастомную FieldPolicy
(политику поля):
import { ApolloClient, InMemoryCache } from '@apollo/client'
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
dog(_, { args, toReference }) {
return toReference({
__typename: 'Dog',
id: args.id
})
}
}
}
}
})
const client = new ApolloClient({ cache })
Комбинация локальных и удаленных данных
Управление данными с помощью Apollo Client
позволяет использовать GraphQL
в качестве унифицированного интерфейса для всех данных. Это позволяет инспектировать локальные и удаленные (имеется ввиду лежащие на сервере) схемы в Apollo Client Devtools
через GraphiQL
.
const GET_DOG = gql`
query GetDogByBreed($breed: String!) {
dog(breed: $breed) {
images {
id
url
isLiked @client
}
}
}
`
В приведенном примере мы запрашиваем клиентское поле isLiked
вместе с серверными данными.
Начало работы
1. Настройка
Создаем локальный проект с помощью Create React App
или песочницу на CodeSandbox
.
Устанавливаем необходимые зависимости:
yarn add @apollo/client graphql
# или
npm i ...
@apollo/client
- пакет, содержащий все необходимое для настройкиApolloClient
, включая кеш, хранящийся в памяти, управление локальным состоянием, обработку ошибок и основанный наReact
слой представленияgraphql
- утилита для разбора (парсинга) GrapqQL-запросов
2. Инициализация ApolloClient
Создаем экзмпляр ApolloClient
.
Импортируем из @apollo/client
необходимые инструменты в index.js
:
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
useQuery,
gql,
} from '@apollo/client'
Инициализируем ApolloClient
, передавая в конструктор объект с полями uri
и cache
:
const client = new ApolloClient({
uri: 'https://48p1r2roz4.sse.codesandbox.io',
cache: new InMemoryCache()
})
uri
определяет адрес GrapQL-сервераcache
- это экземплярInMemoryCache
, который используется для кеширования запросов
Наш клиент готов к отправке запросов. В index.js
вызываем client.query()
со строкой запроса, обернутой в шаблонную строку gql
:
// const client = ...
client
.query({
query: gql`
query GetRates {
rates(currency: 'USD') {
currency
}
}
`
})
.then((result) => console.log(result))
Запустите код, откройте консоль инст рументов разработчика и изучите объект с результатами. Вы должны увидеть свойство data
с rates
внутри, а также другие свойства, такие как loading
и networkStatus
.
3. Подключение клиента к React
Apollo Client
подключается к React
с помощью ApolloProvider
, который помещает клиента в контекст, чтобы сделать его доступным в любом месте (на любом уровне) дерева компонентов.
import React from 'react'
import { render } from 'react-dom'
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
useQuery,
gql,
} from '@apollo/client'
const client = new ApolloClient({
uri: 'https://48p1r2roz4.sse.codesandbox.io',
cache: new InMemoryCache()
})
function App() {
return (
<div>
<h2>Мое первое Apollo-приложе ние 🚀</h2>
</div>
)
}
const rootEl = document.getElementById('root')
render(
<ApolloProvider>
<App />
</ApolloProvider>,
rootEl
)
4. Получение данных с помощью хука useQuery()
В index.js
определяем запрос с помощью gql
:
const EXCHANGE_RATES = gql`
query GetExchangeRates {
rates(currency: 'USD') {
currency,
rate
}
}
`
Создаем компонент ExchangeRates
, в котором выполняется запрос GetExchangeRates
с помощью хука useQuery()
:
function ExchangeRates() {
const { loading, error, data } = useQuery(EXCHANGE_RATES)
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return data.rates.map(({ currency, rate }) => (
<div key={currency}>
<p>
{currency}: {rate}
</p>
</div>
))
}
При рендеринге этого компонента useQuery()
автоматически выполняет запрос и возвращает объект, содержащий свойства loading
, error
и data
:
Apollo Client
следит за состоянием загрузки и ошибками, что отражается в свойствахloading
иerror
- результат запроса записывается в свойство
data
Добавляем компонент ExchangeRates
в дерево компонентов:
function App() {
return (
<div>
<h2>Мое первое Apollo-приложение 🚀</h2>
<ExchangeRates />
</div>
)
}
После перезагрузки приложения вы должны увидеть сначала индикатор загрузки, а затем список курсов валют.
Получение данных
Запросы / Queries
Выполнение запроса
Хук useQuery()
- основной API
для выполнения запросов в Apollo-приложениях. Для выполнения запроса в компоненте вызывается useQuery()
, которому передается строка запроса GraphQL
. При рендеринге компонента useQuery()
возвращает объект, содержащий свойства loading
, error
и data
, которые могут использоваться для рендеринга UI
.
Создаем запрос GET_DOGS
:
import { gql, useQuery } from '@apollo/client'
const GET_DOGS = gql`
query GetDogs {
dogs {
id
breed
}
}
`
Создаем компонент Dogs
и передаем GET_DOGS
в useQuery()
:
function Dogs({ onDogSelected }) {
const { loading, error, data } = useQuery(GET_DOGS)
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return (
<select onChange={onDogSelected}>
{data.dogs.map((dog) => (
<option key={dog.id} value={dog.breed}>
{dog.breed}
</option>
))}
</select>
)
}
UI
рендерится в зависимости от состояния запроса:
- до тех пор, пока
loading
(индикатор выполнения запроса) имеет значениеtrue
, отображается индикатор загрузки - когда
loading
получает значениеfalse
и в процессе выполнения запроса не возникло ошибки (error
), отображается выпадающий список с породами собак
Когда пользователь выбирает породу, выбранное значение передается родительскому компоненту с помощью колбека onDogSelected()
.
Кеширование результатов запроса
Результат выполненного запроса автоматически записывается в кеш, что делает выполнение последующих аналогичных запросов невероятно быстрым.
Создаем компонент DogPhoto
, принимающий проп breed
, соответствующий текущему значению выпадающего списка в компоненте Dog
:
const GET_DOG_PHOTO = gql`
query Dog($breed: String!) {
dog(breed: $breed) {
id
displayImage
}
}
`
function DogPhoto({ breed }) {
const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
variables: { breed }
})
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return <img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
}
Обратите внимание, что на этот раз мы, кроме запроса, передаем useQuery()
объект с настройкой variables
- объект с переменными, которые мы хотим передать в запрос. В данном случае мы хотим передать breed
из списка.
Выберите несколько пород и обратите внимание на скорость повторной загрузки изображений. Так работает кеш.
Обновление кешированных результатов
Иногда нам нужно, чтобы результаты запросов оставались актуальными (свежими), т.е. соответствовали данным, хранящимся на сервере. Apollo Client
предоставляет для этого 2 стратегии: polling
и refetching
.
Polling
Polling
(создание пула) обеспечивает синхронизацию с сервером посредством периодического запуска повторного выполнения запроса. Этот режим включ ается с помощью настройки pollInterval
со значением в мс:
function DogPhoto({ breed }) {
const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
variables: { breed },
pollInterval: 500,
})
// ...
}
В приведенном примере запрос на получение изображения собаки будет отправляться 2 раза в секунду. Установка значения pollInterval
в 0
отключает "пулинг".
Пулинг можно запускать динамически с помощью функций startPolling()
и stopPolling()
, возвращаемых useQuery()
.
Refetching
Refetching
(повторное выполнение запроса) позволяет обновлять результаты запроса в ответ на определенное действие пользователя. Добавим в компонент DogPhoto
кнопку, при нажатии на которую будет запускаться функция refetch()
:
function DogPhoto({ breed }) {
const { loading, error, data, refetch } = useQuery(GET_DOG_PHOTO, {
variables: { breed }
})
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return (
<div>
<img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
<button onClick={() => refetch()}>Отправить повторный запрос</button>
</div>
)
}
Использование refetching
сопряжено к некоторыми трудностями, связанными с отслеживанием состояния загрузки.
Инспектирование состояния загрузки
useQuery()
предоставляет подробную информацию о статусе запроса в свойстве networkStatus
возвращаемого объекта. Для того, чтобы иметь возможность использовать эту информацию, необходимо установить значение настройки notifyOnNetworkStatusChange
в значение true
:
import { NetworkStatus } from '@apollo/client'
function DogPhoto({ breed }) {
const { loading, error, data, refetch, networkStatus } = useQuery(
GET_DOG_PHOTO,
{
variables: { breed },
notifyOnNetworkStatusChange: true
}
)
if (networkStatus === NetworkStatus.refetch)
return <p>Выполнение повторного запроса...</p>
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return (
<div>
<img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
<button onClick={() => refetch()}>Отправить повторный запрос</button>
</div>
)
}
Установка этой настройки также обеспечивает правильное обновление значения loading
.
Свойство networkStatus
- это перечисление (enum) NetworkStatus
, представляющее различные состояния загрузки. В частности, выполнение повторного запроса представлено NetworkStatus.refetch
.
Инспектирование состояния ошибки
Обработка ошибок может быть кастомизирована с помощью настройки errorPolicy
, которая по умолчанию имеет значение none
. none
означает, что все ошибки расцениваются как ошибки времени выполнения. В этом случае Apollo Client
отбрасывает любые данные, содержащиеся в ответе сервера на запрос, и устанавливает свойство error
объекта, возвращаемого useQuery()
, в значение true
.
Если установить значение errorPolicy
в all
, то результаты запроса отбрасываться не будут, что позволяет рендерить частичный контент.
Ручное выполнение запроса
Для выполнения запросов в ответ на события, отличающиеся от рендеринга компонента, например, в ответ на нажатие пользователем кнопки, используется хук useLazyQuery()
. Он похож на useQuery()
, но вместо выполнения запроса, возвращает функцию для его выполнения.
import React from 'react'
import { useLazyQuery } from '@apollo/client'
function DogPhoto({ breed }) {
const [getDog, { loading, data }] = useLazyQuery(GET_DOG_PHOTO)
if (loading) return <p>Загрузка...</p>
return (
<div>
{data && data.dog && (
<img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
)}
<button onClick={() => getDog({ variables: { breed } })}>
Нажми на меня
</button>
</div>
)
}
Установка политики выполнения запроса
"Дефолтной" политикой выполнения запроса является cache-first
(сначала кеш). Это означает, что при выполнении запроса проверяется, имеются ли соотв етствующие данные в кеше. Если такие данные имеются, они возвращаются без отправки запроса на сервер. Если данных нет, отправляется запрос на сервер, данные записываются в кеш и возвращаются.
Для изменения этого поведения используется настройка fetchPolicy
:
const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
// только сеть
fetchPolicy: 'network-only',
})
Поддерживаемые политики:
Название | Описание |
---|---|
cache-first | см. выше |
cache-only | Ответ на запрос возвращается только из кеша. При отсутствии кеша, выбрасывается исключение |
cache-and-network | Ответ на запрос возвращается из кеша, после чего отправляется запрос на сервер. Если ответ от сервера отличается от кеша, кеш и результат запроса обновляются. Это позволяет максимально быстро возвращать ответ при сохранении кеша в актуальном состоянии |
network-only | Запрос сразу отправляется на сервер, минуя кеш. При этом, кеш все равно обновляется ответом от сервера |
no-cache | То же самое что network-only , но без обновления кеша |
standby | То же самое что cache-first , но без обновления кеша. В данном случае кеш может обновляться вручную с помощью refetch и updateQueries |
useQuery API
Настройки
Хук useQuery()
в качестве второго аргумента принимает объект со следующими настройками:
Настройки, связанные с выполняемой операцией
query
- строка запросаGraphQL
, которая разбирается в абстрактное синтаксическое дерево с помощью шаблонных литераловgql
. Является опциональной, поскольку запрос может передаваться вuseQuery()
в качестве первого аргументаvariables: { [key: string]: any }
- объект с переменными для запроса. Название ключа соответствует названию переменной, а значение - значению переменнойerrorPolicy
- политика обработки ошибок (см. выше)onCompleted: (data | {}) => void
- колбек, который вызывается при успешном завершении запроса без ошибок (или когдаerrorPolicy
имеет значениеignore
). Функция получает объектdata
с результатами запросаonError: (error) => void
- колбек, который вызывается при провале запроса. Функция получает объектerror
, который может быть объектомnetworkError
или массивомgraphQLErrors
в зависимости от типа возникшей ошибкиskip: boolean
- если имеет значениеtrue
, запрос не выполняется (не доступна вuseLazyQuery()
)displayName: string
- название компонента, отображаемое в инструментах разработчикаReact
Настройки, связанные с сетью
pollInterval: number
- определяет периодичность выполнения запроса (в мс)notifyOnNetworkStatusChange: boolean
- если имеет значениеtrue
, изменение сетевого статуса или возникновение сетевой ошибки приводит к повторному рендерингу компонентаcontext
- при использованииApollo Link
данный объект представляет собой начальное значение для объектаcontext
, передаваемого в цепочку ссылок (link chain)ssr: boolean
- если имеет значениеfalse
, выполнение запроса при рендеринге на стороне сервера пропускаетсяclient
- экземплярApolloClient
, который используется для выполнения запроса
Настройки, связанные с кешированием
fetchPolicy
- политика кеширования (см. выше)nextFetchPolicy
- политика кеширования для последующих запросовreturnPartialData: boolean
- если имеет значениеtrue
, запрос может возвращать из кеша часть данных при отсутствии данных для всех запрошенных полей
Результат
Хук useQuery()
возвращает объект со следующими свойствами:
Данные, связанные с выполняемой операцией
data
- объект с результатами запроса. Может иметь значениеundefined
при возникновении ошибки (зависит от значенияerrorPolicy
)previousData
- объект, содержащий результаты предыдущего запроса. Также может иметь значениеundefined
error
- объект, содержащий либо объектnetworkError
, либо массивgraphQLErrors
variables: { [key: string]: any }
- объект, содержащий переменные, переданные в запрос
Данные, связанные с сетью
loading: boolean
- если имеет значениеtrue
, значит, запрос находится в процессе выполненияnetworkStatus
- число-индикатор текущего состояния запроса. Используется совместно сnotifyOnNetworkStatusChange
client
- экземплярApolloClient
, который используется для выполнения запросаcalled: boolean
- если имеет значениеtrue
, значит, соответствующий "ленивый" (отложенный) запрос выполнен
Вспомогательные функции
refetch: (variables?) => Promise
- функцию, позволяющая повторно выполнять запросы. Может принимать новые переменные. В данном случаеfetchPolicy
по умолчанию имеет значениеnetwork-only
fetchMore: ({ query?, variables?, updateQuery: function }) => Promise
- функция для получения следующего набора результатов для поля с пагинациейstartPolling: (interval) => void
- функция для периодического выполнения запроса (динамически)stopPolling: () => void
- функция для остановки пулингаsubscribeToMore: (options: { document, variables?, updateQuery?: function, onError?: function }) => () => void
- функция для подписки, как правило, на определенные поля, в ключенные в запросupdateQuery: (previousResult, options: { variables }) => data
- функция для обновления кешированных результатов запроса без выполнения соответствующей операцииGraphQL
Мутации / Mutations
Выполнение мутации
Хук useMutation()
- основной API
для выполнения мутаций в Apollo-приложениях. Для запуска мутации вызывается useMutation()
, которому передается строка GraphQL
, представляющая мутацию. При рендеринге компонента useMutation()
возвращает кортеж, включающий в себя следующее:
- функцию для запуска мутации
- объект с полями, представляющими текущий статус выполнения мутации
Создаем мутацию ADD_TODO
для добавления задачи в список задач:
import { useMutation, gql } from '@apollo/client'
const ADD_TODO = gql`
mutation AddTodo($type: String!) {
addTodo(type: $type) {
id
type
}
}
`
Создаем компонент AddTodo
с формой для отправки задачи в список. Здесь мы передаем ADD_TODO
в useMutation()
:
function AddTodo() {
let input
const [addTodo, { data }] = useMutation(ADD_TODO)
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
addTodo({ variables: { type: input.value } })
input.value = ''
}}
>
<input
ref={(node) => {
input = node
}}
/>
<button>Добавить задачу</button>
</form>
</div>
)
}
Запуск мутации
Хук useMutation()
не выполняет мутацию автоматически при рендеринге компонента. Вместо этого, он возвращает кортеж с функцией для запуска мутации на первой позиции (в приведенном примере данная функция присвоена переменной addTodo
). Эта функция может вызываться в любой момент. Мы вызываем ее при отправке формы.
Передача настроек
useMutation()
и функция для запуска мутации принимают объекты с настройками. Любая настройка, переданная в мутацию, перезаписывает одноименную настройку, переданную в useMutation()
. В приведенном примере мы указали настройку variables
в addTodo()
, позволяющую передавать переменные для мутации.
Отслеживание статуса мутации
Кроме функции для запуска мутации, useMutation()
возвращает объект, представляющий текущее состояние мутации. Поля этого объекта включают логические значения - индикаторы того, вызывалась ли мутация (called
) или находится ли мутация в процес се выполнения (loading
).
Обновление кеша после мутации
Мутация изменяет данные на сервере. Если эти данные также присутствуют на клиенте, они также должны быть обновлены. Это зависит от того, обновляет ли мутация единичную существующую сущность.
Обновление единичной существующей сущности
В этом случае кеш сущности обновляется автоматически после выполнения мутации. Для этого мутация должна вернуть id
модифицированной сущности и значения модифицированных полей.
Рассмотрим пример модификации значения любой задачи:
const UPDATE_TODO = gql`
mutation UpdateTodo($id: String!, $type: String!) {
updateTodo(id: $id, type: $type) {
id
type
}
}
`
function Todos() {
const { loading, error, data } = useQuery(GET_TODOS)
const [updateTodo] = useMutation(UPDATE_TODO)
if (loading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return data.todos.map(({ id, type }) => {
let input
return (
<div key={id}>
<p>{type}</p>
<form
onSubmit={(e) => {
e.preventDefault()
updateTodo({ variables: { id, type: input.value } })
input.value = ''
}}
>
<input
ref={(node) => {
input = node
}}
/>
<button>Обновить задачу</button>
</form>
</div>
)
})
}
После выполнения UPDATE_TODO
мутация вернет id
модифицированного элемента списка и его новый type
. Поскольку сущности кешируются по id
, Apollo
знает, какую сущность следует обновить в кеше.
Выполнение других обновлений
Если мутация создает, удаляет или модифицирует несколько сущностей, автоматического обновления кеша не происходит. Для его ручного обновления в useMutation()
может быть включена функция обновления.
Цель функции обновления состоит в обеспечении соответствия кешированных данных с данными, хранящимися на сервере, которые были модифицированы мутацией. В приведенном выше примере функция обновления для мутации ADD_TODO
должна добавлять такую же задачу в кешированную версию списка.
const GET_TODOS = gql`
query GetTodos {
todos {
id
}
}
`
function AddTodo() {
let input
const [addTodo] = useMutation(ADD_TODO, {
update(cache, { data: { addTodo } }) {
cache.modify({
fields: {
todos(existingTodos = []) {
const newTodoRef = cache.writeFragment({
data: addTodo,
fragment: gql`
fragment NewTodo on Todo {
id
type
}
`,
})
return [...existingTodos, newTodoRef]
},
},
})
},
})
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
addTodo({ variables: { type: input.value } })
input.value = ''
}}
>
<input
ref={(node) => {
input = node
}}
/>
<button>Добавить задачу</button>
</form>
</div>
)
}
Функция обновления получает объект cache
, который представляет собой кеш приложения. Этот объект предоставляет доступ к таким методам cache API
, как readQuery
, writeQuery
, readFragment
, writeFragment
и modify
. Данные методы позволяют выполнять операции GrapQL
над кешем так, будто вы взаимодействуете с GraphQL-сервером.
Функция обновления также получает объект со свойством data
, которое содержит результат мутации. Это значение может использоваться для обновления кеша с помощью cache.writeQuery
, cache.writeFragment
или cache.modify
.
Обратите внимание: если мутация содержит оптимистический ответ, функция обновления вызывается дважды: первый раз с оптимистическим ответом, второй - с результатом мутации.
При запуске мутации ADD_TODO
созданный и возвращенный объект задачи записывается в кеш. Однако, кешированный ранее список задач, отслеживаемый запросом GET_TODOS
, не обновляется автоматически. Это означает, что GET_TODOS
не получает уведомления о добавлении новой задачи, что, в свою очередь, означает, что запрос не обновляется и новая задача не отображается. Для исправления этой ситуации мы используем cache.modify
, позволяющий добавлять и удалять элементы из кеша путем запуска функции-модификатора. Мы знаем, что результаты запроса GET_TODOS
сохранены в кеше в массиве ROOT_QUERY.todos
, поэтому мы используем функцию-модификатор для обновления этого массива, включая в него ссылку на новую задачу. С помощью cache.writeFragment
мы получаем внутреннюю ссылку на добавленную задачу и сохраняем ее в массиве ROOT_QUERY.todos
.
Любые изменения кешированных данных внутри функции обновления приводят к отправки уведомлений всем заинтересованным в этих данных запросам. Это влечет за собой обновление UI
.
Отслеживание состояния загрузки и ошибок
Перепишем компонент Todos
:
function Todos() {
const { loading: queryLoading, error: queryError, data } = useQuery(GET_TODOS)
const [updateTodo, { loading: mutationLoading, error: mutationError }] =
useMutation(UPDATE_TODO)
if (queryLoading) return <p>Загрузка...</p>
if (queryError) return <p>Ошибка: {queryError.message}</p>
return data.todos.map(({ id, type }) => {
let input
return (
<div key={id}>
<p>{type}</p>
<form
onSubmit={(e) => {
e.preventDefault()
updateTodo({ variables: { id, type: input.value } })
input.value = ''
}}
>
<input
ref={(node) => {
input = node
}}
/>
<button type='submit'>Обновить задачу</button>
</form>
{mutationLoading && <p>Загрузка...</p>}
{mutationError && <p>Ошибка: {mutationError.message}</p>}
</div>
)
})
}
Мы можем деструктурировать loading
и error
из объекта, возвращаемого useMutation()
для отслеживания состояния мутации и его отображения в UI
. useMutation()
также поддерживает onCompleted()
и onError()
, если вы предпочитаете колбеки.
useMutation API
useMutation()
принимает два аргумента:
mutation
- мутация, которая разбирается в абстрактное синтаксическое дерево с помощьюgql
options
- объект с настройками
Настройки
mutation
- данная настройка является опциональной, поскольку мутация может передаваться вuseMutation()
в качестве первого аргументаvariables: { [key: string]: any }
- объект с переменными для мутацииupdate: (cache, mutationResult) => void
- функция для обновления кеша после выполнения мутацииignoreResults: boolean
- если имеет значениеtrue
, свойствоdata
не будет обновляться результатами мутацииoptimisticResponse: object
- ответ от мутации, возвращаемый до получения результатов от сервераrefetchQueries
- массив функций, позволяющий определить, какие запросы должны быть запущены повторно после выполнения мутации. Значениями массива могут быть запросы (с опциональными переменными) или просто названиями запросов в виде строкawaitRefetchQueries: boolean
- запросы, выполняемые повторно как частьrefetchQueries
, обрабатываются асинхронно, поэтому мутация может завершиться до их выполнения. Установка этого значения вtrue
сделает повторно выполняемые запросы частью выполняемой мутации, т.е. мутация будет считаться завершенной только после выполнения этих запросовonCompleted: (data) => void
- колбек, который запускается при успешном выполнении мутацииonError: (error) => void
- колбек, который запускается при возникновении ошибкиcontext
- общий для компонента и сетевого интерфейса (Apollo Link
) контекст. Может использоваться для установки заголовков на основе пропов или отправки информации в функциюrequest
изApollo Boost
client
- экземплярApolloClient
. По умолчаниюuseMutation()
использует клиент, переданный через контекст, но мы вполне можем передать другой клиент
Результат
Результатом, возвращаемым useMutation()
, является кортеж, состоящий из функции для запуска мутации и объекта, представляющего результат мутации.
Функция мутации вызывается для запуска мутации из UI
.
Результат мутации:
data
- данные из мутации. Может иметь значениеundefined
loading: boolean
- индикатор выполнения мутацииerror
- любая ошибка, возникшая в процессе выполнения мутацииcalled: boolean
- индикатор вызова функции мутацииclient
- экземплярApolloClient
. Может использоваться для вызова методов для работы с кешем, таких какclient.writeData
иclient.readQuery
, за пределами контекста функции обновления
Подписки / Subscriptions
В дополнение к запросам и мутациям GraphQL
поддерживает третий тип операций - подписки.
Как и запросы, подписки позволяют получать данные. Но в отличие от запросов, подписки - это длящиеся операции, результаты которых могут меняться со временем. Они могут поддерживать активное соединение с сервером GraphQL
(в основном, через веб-сокеты), позволяя серверу обновлять результаты.
Подписки могут использоваться для уведомления клиента об изменении данных на сервере в режиме реального времени, например, о создании нового объекта или обновлении важного поля.
Случаи использования:
- небольшие инкрементальные изменения больших объектов
- обновления в режиме реального времени (с низкой задержкой)
Определение подписки
Сервер
Подписки определяются в схеме GraphQL
как поля с типом Subscription
. В следующем примере подписка commentAdded
уведомляет подписанного клиента о добавлении нового комментария к определенному посту (на основе postID
):
type Subscription {
commentAdded(postId: ID!): Comment
}
Более подробно настройка подписки на сервере рассматривается в руководстве по Apollo Server
.
Клиент
На стороне клиента определяется форма каждой подписки, подлежащей выполнению:
const COMMENTS_SUBSCRIPTION = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
}
}
`
При выполнении подписки OnCommentAdded
, Apollo Client
устанавливает соединение с сервером и ждет от него ответа. В отличие от запроса, ответ от сервера не поступает сразу. Вместо этого сервер отправляет данные клиенту при возникновении определенного события.
{
"data": {
"commentAdded": {
"id": "123",
"content": "Какой замечательный пост!"
}
}
}
Настройка транспортного протокола
Поскольку подписки используют постоянное соединение, они не должны использовать HTTP
, который Apollo Client
использует для запросов и мутаций. Для обеспечения коммуникации через веб-сокеты используется поддерживаемая сообществом библиотека subscriptions-transport-ws
.
1. Установка библиотек
Apollo Link
- это библиотека, которая помогает кастомизировать сетевые коммуникации. Она может использоваться для определения цепочки ссылок, которые модифицируют операции и направляют их в определенный пунк т назначения.
Для реализации подписки через веб-сокеты можно добавить в цепочку ссылок WebSocketLink
. Данная ссылка требует наличия subscriptions-transport-ws
. Устанавливаем ее:
yarn add subscriptions-transport-ws
# или
npm i ...
2. Инициализация WebSocketLink
Импортируем и инициализируем WebSocketLink
в том же файле, где инициализируется ApolloClient
:
import { WebSocketLink } from '@apollo/client/link/ws'
const wsLink = new WebSocketLink({
uri: 'ws://localhost:4000/subscriptions',
options: {
reconnect: true
}
})
uri
- это конечная точка веб-сокета, используемого подпиской на сервере.
3. Разделение коммуникации по операциям (рекомендуется)
Несмотря на то, что Apollo Client
может использовать WebSocketLink
для выполнения операций всех типов, в случае с запросами и мутациями следует использовать HTTP
. Это объясняется тем, что запросы и мутации не нуждаются в постоянном или длительном соединении с сервером, а также тем, что HTTP
является более эффективным и масштабируемым.
Библиотека @apollo/client
предоставляет функцию split
, которая позволяет использовать одну из указанных link
в зависимости от результата логической проверки.
В следующем примере инициализируется как WebSocketLink
, так и HttpLink
. Для их объединения в одну link
используется функция split
. Использование конкретной ссылки определяется на основе типа выполняемой операции:
import { split, HttpLink } from '@apollo/client'
import { getMainDefinition } from '@apollo/client/utilities'
import { WebSocketLink } from '@apollo/client/link/ws'
const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql',
})
const wsLink = new WebSocketLink({
uri: 'ws://localhost:4000/subscriptions',
options: {
reconnect: true
}
})
/*
Функция `split` принимает 3 параметра:
* Функция, которая выполняется для каждой операции
* Ссылка, которая используется для операции, если функция возвращает истинное значение
* Ссылка, которая используется для операции, если функция возвращает ложное значение
*/
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query)
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
)
},
wsLink,
httpLink
)
Данная логика обеспечит использование HTTP
запросами и мутациями и WebSocket
подписками.
4. Передача цепочки ссылок клиенту
После определения цепочки ссылок, она передается в конструктор клиента:
import { ApolloClient, InMemoryCache } from '@apollo/client'
// ...
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache()
})
Значение настройки link
имеет приоритет над значением настройки uri
.
5. Аутентификация через веб-сокеты (опционально)
Часто возникает необходимость в аутентификации клиента перед предоставлением ему разрешения на получение результатов подписки. Это можно сделать с помощью настройки connectionParams
конструктора WebSocketLink
, например:
import { WebSocketLink } from '@apollo/client/link/ws'
const wsLink = new WebSocketLink({
uri: 'ws://localhost:4000/subscriptions',
options: {
reconnect: true,
connectionParams: {
authToken: user.authToken
}
}
})
WebSocketLink
передает серверу объект connectionParams
при установке соединения. Сервер должен иметь объект SubscriptionServer
для прослушивания соединений. При получении сервером объекта connectionParams
, он используется для выполнения аутентификации, наряду с другими задачами, связанными с соединением.
Выполнение подписки
Для выполнения подписки в компоненте используется хук useSubscription()
. Данный хук возвращает объект со свойствами loading
, error
и data
, которые могут использоваться для рендеринга UI
.
В следующем примере при отправке сервером нового комментария к определенному посту, выполняется повторный рендеринг компонента:
const COMMENTS_SUBSCRIPTION = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
}
}
`
function LatestComment({ postId }) {
const { loading, data } = useSubscription(COMMENTS_SUBSCRIPTION, {
variables: { postId },
})
return <h4>Новый комментарий: {!loading && data.commentAdded.content}</h4>
}
Подписка на обновления запроса
Результат, возвращаемый запросом включает в себя функцию subscribeToMore
. Эта функция может использоваться для выполнения последующей подписки, которая обновляет результат.
Функция subscribeToMore
по своей структуре похожа на функцию fetchMore
, которая, в основном, используется для обработки пагинации. Отличие между ними состоит в том, что fetchMore
выполняет следующий запрос, а subscribeToMore
- следующую подписку.
Определяем запрос на получение всех комментариев к определенному посту:
const COMMENTS_QUERY = gql`
query CommentsForPost($postId: ID!) {
post(postId: $postId) {
comments {
id
content
}
}
}
`
function CommentsPageWithData({ params }) {
const result = useQuery(COMMENTS_QUERY, {
variables: { postId: params.postId },
})
return <CommentsPage {...result} />
}
Предположим, что сервер отправляет обновления клиенту при добавлении нового комментария. Сначала необходимо определить подписку, которая будет выполняться при разрешении COMMENTS_QUERY
:
const COMMENTS_SUBSCRIPTION = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
}
}
`
Далее следует обновить функцию CommentsPageWithData
для добавления пропа subscribeToNewComments
возвращаемому к омпоненту CommentsPage
. Этот проп представляет собой функцию, отвечающую за вызов subscribeToMore
после монтирования компонента:
function CommentsPageWithData({ params }) {
const { subscribeToMore, ...result } = useQuery(COMMENTS_QUERY, {
variables: { postId: params.postId },
})
return (
<CommentsPage
{...result}
subscribeToNewComments={() =>
subscribeToMore({
document: COMMENTS_SUBSCRIPTION,
variables: { postId: params.postId },
updateQuery: (prev, { subscriptionData }) => {
if (!subscriptionData) return prev
const newFeedItem = subscriptionData.data.commentAdded
return Object.assign({}, prev, {
post: {
comments: [newFeedItem, ...prev.post.comments]
}
})
}
})
}
/>
)
}
В приведенном примере мы передаем subscribeToMore
3 параметра:
document
- подписка для выполненияvariables
- переменные для подпискиupdateQuery
- функция для комбинации кешированных результатов запроса (prev
) с новыми данными (subscriptionData
), полученными от сервера. Значение, возвращаемое этой функцией, полностью заменяет кешированные результаты запроса
Наконец, в CommentsPage
мы выполняем подписку на новые комментарии при монтировании компонента:
function CommentsPage({ subscribeToNewComments }) {
useEffect(() => {
subscribeToNewComments()
}, [])
// ...
}
useSubscription API
Настройки
subscription
- подписка для выполнения. Данная настройка является опциональной, поскольку подписка может передаваться хукуuseSubscription()
в качестве первого аргументаvariables: { [key: string]: any }
- переменные для подпискиshouldResubscribe: boolean
- определяет, должна ли подписка выполнять отписку и повторную подпискуskip: boolean
- если имеет значениеtrue
, выполнение подписки пропускаетсяonSubscriptionData: (options) => any
- позволяет зарегистрировать колбек, который будет запускаться при каждом получении данныхfetchPolicy
- политика кеширования (по умолчанию имеет значениеcache-first
)client
- экземплярApolloClient
, используемый для выполнения подписки
Результат
data
- объект с результатами выполнения подписки (по умолчанию пустой объект)loading: boolean
- индикатор выполнения под пискиerror
- массивgraphQLErrors
или объектnetworkError
Фрагменты / Fragments
Фрагмент - это часть логики, которая может распределяться между несколькими запросами и мутациями.
Вот пример фрагмента NameParts
, который может быть использован любым объектом Person
:
fragment NameParts on Person {
firstName
lastName
}
Каждый фрагмент включает набор полей, принадлежащих связанному типу (assosiated type).
Мы можем включать фрагмент NameParts
в любые запросы и мутации, которые ссылаются на объекты Person
:
query GetPerson {
people(id: '123') {
...NameParts,
avatar(size: LARGE)
}
}
Если мы изменим набор полей в NameParts
, набор полей, включаемых в операции, в которых используется фрагмент, также изменится автоматически.
Пример использования
Предположим, что мы разрабатываем приложение для блога, в котором выполняется несколько операций, связанных с комментариями (добавление комментария, получения комментариев к определенному посту и т.д.). Вероятно, все эти операции будут включать одинаковый набор полей типа Comment
.
Для определения такого набора мы можем использовать фрагмент:
import { gql } from '@apollo/client'
export const CORE_COMMENT_FIELDS = gql`
fragment CoreCommentsFields on Comment {
id
postedBy {
username
displayName
}
content
createdAt
}
`
Затем мы можем включить данный фрагмент в операцию следующим образом:
import { gql } from '@apollo/client'
import { CORE_COMMENT_FIELDS } from './fragments'
export const GET_POST_DETAILS = gql`
${CORE_COMMENT_FIELDS}
query CommentsForPost($postId: ID!) {
post(postId: $postId) {
title
body
author
comments {
...CoreCommentFields
}
}
}
`
Совместное размещение (colocating) фрагментов
Структура ответа GraphQL
напоминает дерево компонентов. Благодаря этой схожести фрагменты могут использоваться для разделения логики запросов между компонентами, чтобы каждый компонент запрашивал только те поля, которые ему нужны.
Предположим, что у нас имеется такая иерархия компонентов:
FeedPage
└── Feed
└── FeedEntry
├── EntryInfo
└── VoteButtons
Компонент FeedPage
выполняет запрос на получение списка объектов FeedEntry
. Подкомпонентам EntryInfo
и VoteButtons
требуются определенные поля из объекта FeedEntry
.
Создание совместно размещенных фрагментов
Совместно размещенные фрагменты похожи на обычные, за исключением того, что они присоединяются к компоненту, который использует их поля. Например, дочерний компонент VoteButtons
может использовать поля score
и vote { choice }
из объекта FeedEntry
:
VoteButtons.fragments = {
entry: gql`
fragment VoteButtonsFragment on FeedEntry {
score
vote {
choice
}
}
`
}
После определения фрагмента в дочернем компоненте, родительский компонент может ссылаться на него в собственных совместно размещенных фрагментах:
FeedEntry.fragments = {
entry: gql`
fragment FeedEntryFragment on FeedEntry {
commentCount
repository {
full_name
html_url
owner {
avatar_url
}
}
...VoteButtonsFragment
...EntryInfoFragment
}
${VoteButtons.fragments.entry}
${EntryInfo.fragments.entry}
`
}
Обратите внимание: названия VoteButtons.fragments.entry
и EntryInfo.fragments.entry
- всего лишь часть соглашения.
Импорт фрагментов с помощью Webpack
При загрузке файлов .graphql
с помощью graphql-tag/loader
, мы можем импортировать фрагменты с помощью инструкции import
:
#import './someFragment.graphql'
Это сделает содержимое someFragment.graphql
доступным в текущем файле.
Использование фрагментов с объединениями и интерфейсами
Пример запроса, включающего 3 встроенных фрагмента:
query AllCharacters {
all_characters {
... on Character {
name
}
... on Jedi {
side
}
... on Droid {
model
}
}
}
Запрос all_characters
возвращает список объектов Character
. Тип Character
- это интерфейс, реализующий типы Jedi
и Droid
. Каждый элемент списка получает поле side
, если типом объекта является Jedi
, или поле model
, если типом объекта является Droid
.
Однако для того, чтобы такой запрос работал, необходимо сообщить клиенту о существовании полиморфных отношений между интерфейсом Character
и типами, которые он реализует. Для этого мы можем передать настройку possibleTypes
в InMemoryCache
.
Ручное определение possibleTypes
Мы можем передать в InMemoryCache
настройку possibleTypes
для определения отношений "супертип-подтип" в схеме. Данный объект связывает название интерфейса или объединения (супертипа) с типами, которые он реализует или которые ему принадлежат (подтипы).
Пример определения possibleTypes
:
const cache = new InMemoryCache({
possibleTypes: {
Character: ['Jedi', 'Droid'],
Test: ['PassiveTest', 'FailingTest', 'SkippedTest'],
Snake: ['Viper', 'Python']
}
})
В приведенном примере указано три интерфейса с типами объектов, которые они реализует.
Ручное определение возможных типов подходит для небольшого количества интерфейсов или объединений. При их большом количестве следует предпочесть автоматическую генерацию возможных типов на основе схемы.
Автоматическая генерация possibleTypes
В следующем примере мы преобразуем аналитический запрос GraphQL
в конфигурационный объект possibleTypes
:
const fetch = require('cross-fetch')
const fs = require('fs')
fetch(`${YOUR_API_HOST}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variables: {},
query: `
{
__schema {
types {
kind
name
possibleTypes {
name
}
}
}
}
`
})
})
.then(res => res.json())
.then(res => {
const possibleTypes = {}
res.data.__schema.types.forEach(supertype => {
if (supertype.possibleTypes) {
possibleTypes[supertype.name] = supertype.possibleTypes.map(subtype => subtype.name)
}
})
fs.writeFile('./possibleTypes.json', JSON.stringify(possibleTypes), (err) => {
if (err) console.error('При попытке создания файла "possibleTypes.json" возникла ошибка: ', err)
else console.log('Типы фрагментов успешно извлечены!')
})
})
Затем мы можем импортировать сгенерированный JSON
в файл, где создается InMemoryCache
:
import possibleTypes from './possibleTypes.json'
const cache = new InMemoryCache({
possibleTypes
})
Обработка ошибок
Типы ошибок
Выполнение операции может завершиться ошибкой GraphQL
или сетевой ошибкой.
_Ошибки GraphQL_
Эти ошибки связаны с выполнением операции на стороне сервера:
- синтаксические ошибки (syntax errors) - например, когда запрос неправильно сформирован
- ошибки валидации (validation errors) - например, когда запрос включает поля, отсутствующие в схеме
- ошибки разрешения (resolver errors) - например, ошибки, возникающие при заполнении поля данными (populating)
При возникновении синтаксической ошибки или ошибки валидации, выполнение операции прекращается. При возникновении ошибки разрешения сервер может вернуть частичные данные (partial data).
При возникновении ошибки GraphQL
, в ответ включается массив errors
:
{
"errors": [
{
"message": "Cannot query field \"nonexistentField\" on type \"Query\".",
"locations": [
{
"line": 2,
"column": 3
}
],
"extensions": {
"code": "GRAPHQL_VALIDATION_FAILED",
"exception": {
"stacktrace": [
"GraphQLError: Cannot query field \"nonexistentField\" on type \"Query\".",
"...другие строки",
]
}
}
}
],
"data": null
}
Клиент добавляет эти ошибки в массив error.graphQLErrors
, возвращаемый вызовом useQuery()
(или другого хука).
Если операция не выполняется, статус-кодом ответа является 4xx
. Если ответ содержит хотя бы частичные данные, статус-кодом ответа является 200
.
Частичные данные
Если в процессе выполнения операции возникла ошибка разрешения, ответ может содержать частичные данные. По умолчанию такие данные игнорируются, но это можно изменить с помощью настройки errorPolicy
.
Сетевые ошибки
Эти ошибки возникают при попытке установления соединения с сервером. В этом случае статус-кодом ответа, как правило, является 4xx
или 5xx
.
При возникновении сетевой ошибки, Клиент добавляет ее в поле error.networkError
, возвращаемое вызовом useQuery()
(или другого хука).
Apollo Link
позволяет реализовать логику отправки повторного запроса и другие продвинутые возможности по обработке сетевых ошибок.
Политика обработки ошибок
При возникновении ошибки разрешения ответ сервера может содержать частичные данные в поле data
:
{
"data": {
"getInt": 12,
"getString": null
},
"errors": [
{
"message": "Не удалось получить строку!",
// другие поля
}
]
}
По умолчанию клиент отбрасывает частичные данные и заполняет массив error.graphQLErrors
. Это можно изменить с помощью политики обработки ошибок:
none
- политика по умолчанию. Если ответ содержит ошибки, они возвращаются вerror.graphQLErrors
, а значениеdata
устанавливается вundefined
. В этом случае сетевые ошибки и ошибкиGraphQL
будут иметь одинаковую формуignore
- ошибкиGraphQL
игнорируются (массивerror.graphQLErrors
не заполняется),data
кешируется и рендерится так, будто ошибок не возникалоall
- заполняется какdata
, так иerror.graphQLErrors
, что позволяет рендерить как частичные данные, так и сообщение об ошибке
Установка политики обработки ошибок
Политика обработки ошибок определяется в объекте с настройками, передаваемом в хук (такой как useQuery()
):
const MY_QUERY = gql`
query WillFail {
badField # Разрешение данного поля приводит к ошибке
goodField # Данное поле разрешается (заполняется) успешно
}
`
function ShowingSomeErrors() {
const { loading, error, data } = useQuery(MY_QUERY, { errorPolicy: 'all' })
if (loading) return <div>Загрузка...</div>
return (
<div>
<h2>Хорошо: {data.goodField}</h2>
<pre>Плохо: {error.graphQLErrors.map(({ message }, i) => (
<span key={i}>{message}</span>
))}
</pre>
</div>
)
}
Продвинутая обработка ошибок с помощью Apollo Link
Apollo Link
позволяет реализовать продвинутую обработку ошибок, возникающих при выполнении операции.
Прежде всего, можно добавить ссылку onError
в цепочку ссылок.
В следующем примере мы передаем в конструктор ApolloClient
две ссылки:
onError
- определяет наличиеgraphQLErrors
илиnetworkError
в ответе сервера и выполняет их обработкуHttpLink
- отправляет операцию на сервер
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client'
import { onError } from '@apollo/client/link/error'
const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql'
})
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path}) => {
console.log(
`[Ошибка GraphQL]: Сообщение: ${message}, местонахождение: ${locations}, путь: ${path}`
)
})
}
if (networkError) {
console.log(`[Сетевая ошибка]: ${networkError}`)
}
})
// при передаче цепочки ссылок настройка `uri` не указывается
const client = new ApolloClient({
// функция `from` объединяет массив ссылок в цепочку
link: from([errorLink, httpLink]),
cache: new InMemoryCache()
})
Повторное выполнение операции
Apollo Link
позволяет повторно выполнять провалившиеся запросы. Для этого рекомендуется использовать следующие ссылки:
onError
- для ошибокGraphQL
RetryLink
- для сетевых ошибок
Ошибки GraphQL
Ссылка onError
может выполнять повторную отправку запроса на основе типа ошибки GraphQL
. Например, при использовании основанной на токенах аутентификации можно выполнить автоматическую повторную аутентификацию при "протухании" токена (окончании времени его жизни).
Для повторного выполнения операции колбек onError
должен вернуть forward(operation)
:
onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
switch(err.extensions.code) {
// `Apollo Server` устанавливает код в значение `UNAUTHENTICATED`,
// когда резолвер выбрасывает `AuthenticationError`
case 'UNAUTHENTICATED':
// модифицируем контекст операции с помощью нового токена
const oldHeaders = operation.getContext().headers
operation.setContext({
headers: {
...oldHeaders,
authorization: getNewToken()
}
})
// повтор запроса
// возвращается новая наблюдаемая сущность (observable)
return forward(operation)
}
}
}
// для повторного выполнения запроса в случае возникновения сетевой ошибки
// рекомендуется использовать ссылку `RetryLink`
// здесь мы просто выводим ошибку в консоль
if (networkError) {
console.log(`[Сетевая ошибка]: ${networkError}`)
}
})
Если повторная операция завершается ошибкой, эта ошибка не передается в onError
во избежание бесконечного цикла. Это означает, что onError
повторно отправляет определенный запрос только один раз.
Сетевые ошибки
Для повторного выполнения операций при возникновении сетевых ошибок используется ссылка RetryLink
. Данная ссылка позволяет настраивать логику повторного выполнения операции, например, автоматически увеличивающуюся задержку между выполнениями запросов или общее количество попыток.
Игнорирование ошибок
Для условного игнорирования ошибок можно установить response.errors
в значение null
в onError
:
onError(({ response, operation }) => {
if (operation.operationName === 'IgnoreErrorsQuery') {
response.errors = null
}
})
Лучшие практики выполнения запросов
При создании запросов и мутаций рекомендуется придерживаться следующих правил.
Все операции должны иметь названия
Следующие два запроса запрашивают одинаковые данные:
# 👍
query GetBooks {
books {
title
}
}
# 👎
query {
books {
title
}
}
Первые запрос является именованным, второй - анонимным.
Использование именованных запросов предоставляет следующие преимущества:
- позволяет уточнять название каждой операции
- позволяет комбинировать несколько операций в одном запросе
- помогает в отладке, позволяя идентифицировать операции, вызывающие проблемы
Apollo Studio
предоставляет метрики на уровне операций, которые требуют наличия именованных операций
Аргументы должны передаваться в виде переменных
Следующие два запроса запрашивают объект Dog
с идентификатором 5
:
# 👍
query GetDog($dogId: ID!) {
dog(id: $dogId) {
name
breed
}
}
# 👎
query GetDog {
dog(id: '5') {
name
breed
}
}
В первом запросе для передачи аргумента в запрос используется переменная $dogId
. Это позволяет запрашивать собаку с любым id
, что делает запрос переиспользуемым.
Значение переменной передается в useQuery()
(или другой хук) следующим образом:
const GET_DOG = gql`
query GetDog($dogId: ID!) {
dog(id: $dogId) {
name
breed
}
}
`
function Dog({ id }) {
const { loading, error, data } = useQuery(GET_DOG, {
variables: {
dogId: id
}
})
// ...
}
Недостатки явно определенных аргументов
- снижение эффективности кеширования - два идентичных запроса с разными явно определенными аргументами считаются разными запросами
- ум еньшение приватности информации - значением аргумента может быть чувствительная информация. Если такая информация включается в строку запроса, она кешируется вместе с запросом
Должны запрашиваться только необходимые данные и в нужное время
Одним из главных преимуществ GraphQL
по сравнению с традиционным REST API
является поддержка декларативного получения данных. Каждый компонент может (и должен) запрашивать только те поля, которые необходимы ему для рендеринга.
Если корневой компонент выполняет огромный запрос на получение данных для всех потомков, он может запрашивать данные для компонентов, которые не рендерятся на основе текущего состояния. Это может привести к более длительному ответу и ограничить преимущества доставки контента из кеша.
В большинстве случаев запрос, приведенный ниже, должен разделяться на несколько более мелких запросов, распределенных по соответствующим компонентам:
# 👎
query GetGlobalStatus {
stores {
id
name
address {
street
city
}
employees {
id
}
manager {
id
}
}
products {
id
name
price {
amount
currency
}
}
employees {
id
role
name {
firstName
lastName
}
store {
id
}
}
offers {
id
products {
id
}
discount {
discountType
amount
}
}
}
- если у нас имеется коллекция компонентов, которые всегда рендерятся вместе, мы можем использовать фрагменты для дистрибуции структуры запроса между ними
- если список элементов, возвращаемых в ответ на запрос, больше списка элементов, необходимых компоненту для рендеринга, следует использовать пагинацию
Для инкапсуляции набора связанных полей должны использоваться фрагменты
Фрагмент - это набор полей, который может использоваться в нескольких операциях. Пример определения фрагмента:
# 👍
fragment NameParts on Person {
title
firstName
middleName
lastName
}
Скорее всего, полное имя пользователя потребуется нескольким компонентам приложения. Фрагмент NameParts
позволяет сохранять соответствующие запросы согласованными, чи таемыми и короткими:
# 👍
query GetAttendees($eventId: ID!) {
attendees(id: $eventId) {
id
rsvp
...NameParts # включаем все поля из фрагмента
}
}
Избегайте создания лишних или нелогичных фрагментов
Использование большого количества фрагментов может сделать запрос нечитаемым:
# ⛔
query GetAttendees($eventId: ID!) {
attendees(id: $eventId) {
id
rsvp
...NameParts
profile {
...VisibilitySettings
events {
...EventSummary
}
avatar {
...ImageDetails
}
}
}
}
Фрагменты следует определять только для набора логически связанных между собой полей. Не создавайте фрагменты только потому, что несколько одинаковых полей встречаются в разных запросах:
# 👍
fragment NameParts on Person {
title
firstName
middleName
lastName
}
# 👎
fragment SharedFields on Country {
population
neighboringCountries {
capital
rivers {
name
}
}
}
Глобальные и локальные данные должны запрашиваться раздельно
Некоторые поля возвращают одни и те же данные независимо от запрашивающего их пользователя:
# возвращаются все элементы периодической таблицы
query GetAllElements {
elements {
atomicNumber
name
symbol
}
}
Другие поля возвращают разные данные:
# возвращаются документы, принадлежащие текущему пользователю
query GetMyDocuments {
myDocuments {
id
title
url
updatedAt
}
}
Для повышения производительности кеширования ответа на ст ороне сервера эти запросы должны выполняться раздельно. Это позволит серверу кешировать один ответ для запросов GetAllElements
и разные ответы для запросов GetMyDocuments
.
Кеширование
Настройка кеша
Клиент записывает результаты запросов в нормализованный, хранящиеся в памяти кеш. Это позволяет отвечать на последующие запросы без обращения к серверу.
Инициализация
Для инициализации кеша создается объект InMemoryCache
, который передается в конструктор ApolloClient
:
import { InMemoryCache, ApolloClient } from '@apollo/client'
const client = new ApolloClient({
// ...другие настройки
cache: new InMemoryCache(options)
})
Настройки
Дефолтные настройки кеша подходят для большинства приложений. Тем не менее, мы можем:
- определять кастомные основные (первичные) ключи (primary keys)
- кастомизировать запись и чтение определенных полей
- кастомизировать интерпретацию аргументов полей
- определять паттерны для пагинации
- управлять локальным состоянием на стороне клиента
Для настройки кеширования в конструктор InMemoryCache
передается объект options
со следующими полями:
addTypename: boolean
- еслиtrue
(по умолчанию), кеш автоматически добавляет поля__typename
во все исходящие запросыresultCaching: boolean
- еслиtrue
(по умолчанию), кеш возвращает идентичные (===
) объекты ответа на одинаковые запросы до тех пор, пока данные остаются неизменнымиpossibleTypes: object
- данный объект позволяет определять полиморфные отношения между типами схемы. Это позволяет выполнять поиск кешированных данных с помощью интерфесов и объединений. Ключ объекта - это__typename
интерфейса или объединения, а значение - массив типов, принадлежащих объединению или реализуемых интерфейсомtypePolicies: object
- данный объект позволяет кастомизировать поведение кеша на основе отношений между типами. Ключ объекта - это__typename
кастомизируемого типа, а значение - объектTypePolicy
Нормализация данных
InMemoryCache
нормализует объекты ответа на запрос перед их сохранением во внутреннем хранилище данных. Нормализация включает в себя следующие шаги:
- для каждого объекта генерируется уникальный
id
- эти
id
записываются в кеш в плоскую (одноуровневую) таблицу для поиска (lookup table) - при записи объекта с аналогичным
id
поля объектов объединяются (merge)- общие поля перезаписываются
- уникальные поля сохраняются
Нормализация создает частичную копию графа данных на клиенте в формате, оптимизированном для чтения и обновления графа при изменении состояния приложения.
Генерация уникальных идентификаторов
По умолчанию InMemoryCache
генерирует уникальный идентификатор для любого объекта, включающего поле __typename
. Для этого __typename
комбинируется с полем id
или _id
. Эти поля разделяются двоеточием (:
).
Например, идентификатор для объекта с __typename
Task
и id
10
будет Task:10
.
Генерацию уникальных id
можно кастомизировать.
Визуализация кеша
Для изучения кеша рекомендуется использовать Apollo Client Devtools
.
Это расширение для браузера позволяет увидеть все нормализованные объекты, хранящиеся в кеше.
Поля TypePolicy
Для кастомизации того, как кеш взаимодействует с определенными типами схемы, можно передать объект, с вязывающий строки __typename
с объектами TypePolicy
, в новый объект InMemoryCache
.
Чтение и запись в кеш
Мы можем читать и писать прямо в кеш без взаимодействия с сервером. Мы можем работать с данными, полученными от сервера, а также с данными, доступными только локально.
Клиент поддерживает несколько стратегий для работы с кешем:
Стратегия | API | Описание |
---|---|---|
Запросы | readQuery / writeQuery | Позволяет использовать обычные запросы для управления как удаленными, так и локальными данными |
Фрагменты | readFragment / writeFragment | Позволяет получать поля кешированного объекта без выполнения запросов |
Прямая модификация | cache.modify | Позволяет манипулировать кешированными данными без использования GraphQL |
Использование запросов
readQuery
Метод readQuery
позволяет обращаться напрямую к кешу, например:
const READ_TODO = gql`
query ReadTodo($id: ID!) {
todo(id: $id) {
id
text
completed
}
}
`
// получаем кешированную задачу с `id === 5`
const { todo } = client.readQuery({
query: READ_TODO,
variables: { // передаем переменную
id: 5
}
})
Если кеш содержит данные для всех запрашиваемых полей, readQuery
возвращает объект, совпадающий с формой запроса (query shape):
{
todo: {
__typename: 'Todo', // `__typename` включается автоматически
id: 5,
text: 'Купить апельсины 🍊',
completed: true
}
}
Возвращаемый объект нельзя модифицировать напрямую. Один и тот же объект может возвращаться для разных компонентов. Для обновления данных в кеше следует создавать новый объект и передавать его в writeQuery
.
Если в кеше отсутствуют данные хотя бы для одного поля, readQuery
возвращает null
. При этом, с сервера данные не запрашиваются.
Запрос в readQuery
может включать поля, которых нет в схеме на сервере (локальные поля).
writeQuery
Метод writeQuery
позволяет записывать данные в кеш в форме, соответствующей запросу. Он похож на readQuery
, но требует наличия настройки data
:
client.writeQuery({
query: gql`
query WriteTodo($id: ID!) {
todo(id: $id) {
id
text
completed
}
}
`,
data: { // данные для записи
todo: {
__typename: 'Todo',
id: 5,
text: 'Купить виноград 🍇',
completed: false
}
},
variables: {
id: 5
}
})
В данном случае мы создаем (или редактируем) кешированный объект Todo
с id === 5
.
Обратите внимание:
- изменения, выполняемые с помощью
writeQuery
, не от правляются на сервер. Это означает, что при перезагрузке среды они исчезнут - форма запроса не валидируется с помощью схемы. Это означает, что запрос может включать поля, которых нет в схеме, а также, что значения полей могут быть невалидными с точки зрения схемы
Использование фрагментов
Фрагменты позволяют получать доступ к более специфичным кешированным данным, чем readQuery / writeQuery
.
readFragment
Перепишем приведенный выше пример с readQuery
на readFragment
:
const todo = client.readFragment({
id: 'Todo:5', // значение уникального идентификатора задачи
fragment: gql`
fragment MyTodo on Todo {
id
text
completed
}
`
})
В отличие от readQuery
, readFragment
требует наличия настройки id
. Значением данной настройки является уникальный идентификатор объекта, хранящегося в кеше.
readFragment
вернет null
, если в кеше нет объекта Todo
с id === 5
, или объект существует, но у него нет свойства text
или completed
.
writeFragment
Пример локального обновления поля completed
объекта Todo
с id === 5
:
client.writeFragment({
id: 'Todo:5',
fragment: gql`
fragment MyTodo on Todo {
completed
}
`,
data: {
completed: true
}
})
Все компоненты, подписанные на эту часть кеша (включая все активные запросы) будут соответствующим образом обновлены.
Комбинация чтения и записи
Мы можем комбинировать readQuery
и writeQuery
(или readFragment
и writeFragment
) для получения кешированных данных и их выборочной модификации. В следующем примере мы создаем новую задачу и добавляем ее в кешированный список (помните, что эти изменения не отправляются на сервер):
// запрос на получение всех задач
const query = gql`
query MyTodoQuery {
todos {
id
text
completed
}
}
`
// получаем список задач
const data = client.readQuery({ query })
// создаем новую задачу
const newTodo = {
id: '6',
text: 'Начать изучение Apollo Client',
completed: false,
__typename: 'Todo'
}
// добавляем задачу в список
client.writeQuery({
query,
data: {
todos: [...data.todos, newTodo]
}
})
Использование cache.modify
Метод modify
из InMemoryCache
позволяет напрямую модифицировать значения определенных кешированных полей или даже удалять их.
- Подобно
writeQuery
иwriteFragment
модификация приводит к обновлению всех активных запросов, основанных на модифицированных полях (до тех пор, пока не указана настройкаbroadcast: false
) - В отличие от
writeQuery
иwriteFragment
:modify
изменяет функции объединения. Это означает, что поля перезаписываются точно теми значениями, которые были определеныmodify
не добавляет поля при их отсутствии
- наблюдаемые запросы могут управлять тем, что происходит при их инвалидации после обновления кеша с помощью настроек
fetchPolicy
иnextFetchPolicy
, переданных вclient.watchQuery
или хукuseQuery
Параметры
Метод modify
принимает следующие параметры:
- идентификатор модифицируемого объекта, который рекомендуется извлекать с помощью
cache.identity
- карту функций-модификаторов (по одной для каждого поля)
- опциональные логические значения
broadcast
иoptimistic
для кастомизации поведения
Функция-модификатор применяется к конкретному полю. Она принимает текущее значение поля и возвращает новое значение.
Пример вызова modify
для преобразования значения поля name
в верхний регистр:
cache.modify({
id: cache.identity(myObj),
fields: {
name(cachedName) {
return cachedName.toUpperCase()
}
},
// broadcast: false // отключение автоматического обновления запроса
})
Когда мы определяем функцию-модификатор для поля, содержащего скалярное значение, перечисление или список этих базовых типов, функция получает точное значение поля. Например, если мы определяем модификатор для поля quantity
, текущим значением которого является 5
, функция получит значение 5
.
Однако, при определении модификатора для поля, содер жащего объект или список объектов, функция получает ссылки на эти объекты. Каждая ссылка указывает на соответствующий объект в кеше по идентификатору. Если модификатор возвращает другую ссылку, то изменяется другой объект, содержащийся в этом поле. В этом случае оригинальный объект останется прежним.
В качестве второго опционального аргумента модификатор принимает объект с несколькими вспомогательными функциями (такими как функция readField
и сторожевой (sentinel) объект DELETE
).
Примеры
Удаление задачи из списка
Предположим, что у нас имеется блог, в котором каждый Post
содержит массив Comment
. Вот как мы можем удалить определенный комментарий:
const idToRemove = '123'
cache.modify({
id: cache.identity(myPost),
fields: {
comments(existingCommentRefs, { readField }) {
return existingCommentRefs.filter(
commentRef => idToRemove !== readField('id', commentRef)
)
}
}
})
- в поле
id
мы используемcache.identity
для извлечения идентификатора кешированного объектаPost
, из которого мы хотим удалить комментарий - в поле
fields
мы передаем объект со списком модификаторов. В данном случае мы определяем один модификатор для поляcomments
- модификатор в качестве параметра принимает кешированный массив комментариев (
existingCommentRefs
). В нем используется утилитаreadField
, помогающая читать значения кешированных полей - модификатор возвращает отфильтрованный массив комментариев. Этот массив заменяет собой кешированный
Добавление задачи в список
Рассмотрим пример добавления Comment
в Post
:
const newComment = {
__typename: 'Comment',
id: '123',
text: 'Отличный пост!'
}
cache.modify({
id: cache.identity(myPost),
fields: {
comments(existingCommentRefs = [], { readField }) {
const newCommentRef = cache.writeFragment({
data: newComment,
fragment: gql`
fragment NewComment on Comment {
id
text
}
`
})
// если новый комментарий уже имеется в кеше
// нам не нужно снова его туда добавлять
if (existingCommentRefs.some(
ref => readField('id', ref) === newComment.id
)) {
return existingCommentRefs
}
return [...existingCommentRefs, newCommentRef]
}
}
})
При запуске модификатора сначала вызывается writeFragment
для записи данных newComment
в кеш. writeFragment
возвращает ссылку (newCommentRef
) на созданный комментарий.
Затем мы определяем наличие нового комментария в массиве ссылок на существующие комментарии (existingCommentRefs
). Если такой комментарий отсутствует, мы добавляем ссылку на него в массив и возвращаем полный список ссылок для сохранения в кеше.
Обновление кеша после мутации
При вызове writeFragment
с объектом options.data
, с помощью которого он может быть идентифицирован в кеше на основе __typename
и поля с основным ключом, options.id
можно не передавать.
При явной передаче options.id
или если writeFragment
определяет его самостоятельно на основе options.data
, writeFragment
возвращает Reference
на идентифицируемый объект.
Такое поведение делает writeFragment
хорошим инструментом для получения ссылки на кешированный объект, что может быть использовано в функции обновления хука useMutation
:
const [addComment] = useMutation(ADD_COMMENT, {
update(cache, { data: { addComment } }) {
cache.modify({
id: cache.identity(myPost),
fields: {
comments(existingCommentRefs = [], { readField }) {
const newCommentRef = cache.writeFragment({
data: addComment,
fragment: gql`
fragment NewComment on Comment {
id
text
}
`
})
return [...existingCommentRefs, newCommentRef]
}
}
})
}
})
В приведенном примере useMutation
автоматически создает Comment
и добавляет его в кеш, но он не знает, как автоматически добавить его в соответствующий список комментариев Post
. Это означает, что запросы, наблюдающие за списком комментариев к этому посту, не будут обновлены.
Для решения этой проблемы мы используем колбек обновления для вызова cache.modify
. Как и в предыдущем примере мы добавляем новый комментарий в список. В отличие от предыдущего примера, комментарий уже добавлен в кеш useMutation
. Следовательно, cache.writeFragment
возвращает ссылку на существующий объект.
Удаление поля из существующего объекта
В качестве второго опционального параметра функция-модификатор принимает объект с несколькими полезными утилитами, такими как canRead
и isReference
, а также сторожевой объект DELETE
.
Для удаления поля определенного кешированного объекта достаточно вернуть DELETE
из модификатора:
cache.modify({
id: cache.identity(myPost),
fields: {
comments(existingCommentRefs, { DELETE }) {
return DELETE
}
}
})
Инвалидация полей внутри кешированного объекта
Как правило, модификация или удаление значения приводит к инвалидации соответствующего поля, что, в свою очередь, приводит к повторному вычислению запросов, потребляющих это поле.
cache.modify
позволяет инвалидировать поле без изменения или удаления его значения через возврат сторожевого объекта INVALIDATE
:
cache.modify({
id: cache.identity(myPost),
fields: {
comments(existingCommentRefs, { INVALIDATE }) {
return INVALIDATE
}
}
})
Если требуется инвалидировать все поля определенного объекта, модификатору в качестве значения следует передать настройку fields
:
cache.modify({
id: cache.identity(myPost),
fields(fieldValue, details) {
return details.INVALIDATE
}
})
При испол ьзовании такой формы cache.modify
названия инвалидируемых полей можно определить с помощью details.fieldName
. Данная техника может применяться к любому модификатору, а не только к тем, что возвращают INVALIDATE
.
Получение кастомных идентификаторов
Если кешированный тип использует кастомный идентификатор (или идентификатор отсутствует) метод cache.identity
позволяет получать идентификатор объекта этого типа. Данный метод принимает объект и вычисляет его id
на основе __typename
и уникальных полей. Это означает, что нам не нужно помнить, какие поля являются идентификаторами соответствующих типов.
Кастомизация поведения кешированных полей
Для кастомизации записи и чтения поля из кеша используется политика поля, которая может включать в себя следующее:
- функцию чтения (read), которая вызывается при извлечении значения п оля
- функцию объединения (merge), которая вызывается при записи значения поля
- массив ключей, помогающих избежать дублирования данных
Кастомизируемые поля определяются внутри объекта TypePolicy
, содержащего ссылку на соответствующий тип. Пример определения политики поля name
типа Person
:
const cache = new InMemoryCache({
typePolicies: {
Person: {
fields: {
name: {
read(name) {
// возвращает имя в верхнем регистре
return name.toUpperCase()
}
}
}
}
}
})
Функция read
При определении функции read
она вызывается при каждом запросе соответствующего поля. В ответе на запрос поле заполняется значением, возвращаемым read
, вместо кешированного значения.
Первым параметром, принимаемым read
, является текущее кешированное значение, если таковое существует.
Вторым параметром является объект, содержащий несколько полезных свойств и утилит.
В следующем примере read
присваивает полю name
типа Person
дефолтное значение UNKNOWN
при отсутствии соответствующего значения в кеше:
const cache = new InMemoryCache({
typePolicies: {
Person: {
fields: {
name: {
read(name = 'UNKNOWN') {
return name
}
}
}
}
}
})
Если поле принимает аргументы, второй параметр включает их значения. В следующем примере read
проверяет наличие аргумента maxLength
при запросе поля name
. Если такой аргумент есть, возвращаются только первые maxLength
имени пользователя. Если такого аргумента нет, возвращается полное имя пользователя:
const cache = new InMemoryCache({
typePolicies: {
Person: {
fields: {
name(name, { args }) {
if (args && typeof args.maxLength === 'number') {
return name.substring(0, args.maxLength)
}
return name
}
}
}
}
})
Функция read
может определяться для полей, отсутствующих в схеме. В следующем примере read
позволяет запрашивать поле userId
, которое заполняется данными, хранящимися локально:
const cache = new InMemoryCache({
typePolicies: {
Person: {
fields: {
userId() {
return localStorage.getItem('loggedInUserId')
}
}
}
}
})
Обратите внимание: для запроса локальных полей, эти поля должны сопровождаться директивой @client
, чтобы Клиент не включал их в запрос к серверу.
Другие случаи использования read
:
- Преобразование кешированных данных, например, округление чисел с плавающей точкой до ближайших целых
- Вычисление производных данных (локальных полей) на основе полей, определенных в схеме одного объекта (например, вычисление возраста пользователя на основе его даты рождения)
- Вычисление производных данных (локальных полей) на основе полей, определенных в схемах нескольких объектов
Функция merge
Функция merge
вызывается при записи в поле входящего значения (например, при летевшего с сервера). Это означает, что в поле записывается не оригинальное входящее значение, а значение, возвращаемое функцией merge
.
Объединение массивов
merge
часто используется для определения способа записи поля, содержащего массив. По умолчанию сущестующий массив полностью заменяется входящим. Как правило, более предпочтительным является объединение этих массивов:
const cache = new InMemoryCache({
typePolicies: {
Agenda: {
fields: {
tasks: {
merge(existing = [], incoming) {
return [...existing, ...incoming]
}
}
}
}
}
})
Обратите внимание, что при первом вызове этой функции exisitng
будет иметь значение undefined
. Это объясняется тем, что в этот момент кеш еще не содержит никаких данных для поля. Передача параметра по умолчанию (existing = []
) решает эту проблему.
Объединение ненормализованных объектов
Другим распространенным случаем использования merge
является объединение вложенных объектов, у которых нет идентификаторов, но которые представляют один логический объект, например, имеют общий родительский объект.
Предположим, что тип Book
имеет поле author
, которое является объектом, содержащим такую информацию как name
, language
и dateOfBirth
. Объект Book
имеет __typename: 'Book'
и уникальное поле isbn
, поэтому кеш может определить, когда результат в виде двух об ъектов Book
представляет одну логическую сущность. Однако, по какой-то причине запрос на получение Book
не запрашивает достаточное количество информации об объекте book.author
. Вероятно, для типа Author
не было определено keyFields
и отсутствует дефолтное поле id
.
Недостаток информации является проблемой для кеша, поскольку он не может автоматически определить, что два объекта Author
являются одинаковыми. Если несколько запросов получают разную информацию об авторе книги, порядок их выполнения имеет важное значение, поскольку объект favouriteBook.author
из второго запроса не может быть безопасно объединен с объектом favouriteBook.author
из первого запроса, и наоборот:
query BookWithAuthorName {
favoriteBook {
isbn
title
author {
name
}
}
}
query BookWithAuthorLanguage {
favoriteBook {
isbn
title
author {
language
}
}
}
В таких ситуациях кеш по умолчанию заменяет существующие данные favouriteBook.author
входящими без объединения полей name
и language
, поскольку риск несогласованности этих полей (в случае, когда они принадлежат разным авторам) является слишком большим.
Эту проблему можно решить путем модификации запроса для получения поля id
из объектов favouriteBook.author
или путем определения кастомных keyFields
в политике типа Author
, таких как ['name', 'dateOfBirth']
. С этой информацией кеш может произвести безопасное объединение полей. Данный подход является рекомендуемым.
Тем не менее, можно столкнуться с ситуацией, когда граф данных не содержит уникальных идентификаторов для объектов Author
. В таких редких случаях можно предположить, что определенная книга имеет одного и только одного автора, который никогда не меняется. Другими словами, идентификация автора выполняется на основе идентификации книги.
В таких ситуациях можно определить кастомную функцию merge
для поля author
внутри политики типа Book
:
const cache = new InMemoryCache({
typePolicies: {
Book: {
fields: {
author: {
merge(existing, incoming) {
// лучше, но не идеально
return { ...existing, ...incoming }
}
}
}
}
}
})
В качестве альтернативы замену существующих данных входящими можно определить явно:
const cache = new InMemoryCache({
typePolicies: {
Book: {
fields: {
author: {
merge(existing, incoming) {
return incoming
}
}
}
}
}
})
Сокращенная форма:
const cache = new InMemoryCache({
typePolicies: {
Book: {
fields: {
author: {
merge: false
}
}
}
}
})
При испоьзовании { ...existing, ...incoming }
объекты Author
с разными полями name
и dateOfBirth
объединяются без потерь, что определенно лучше, чем слепая замена.
Но что если тип Author
определяет собственную функцию merge
для полей объекта incoming
? При использовании синтаксиса распаковки объекта такие поля будут перезап исаны полями из existing
без запуска вложенных функций merge
. Поэтому синтаксис { ...existing, ...incoming }
является улучшением, но не решением.
К счастью, в нашем распоряжении имеется вспомогательная функция options.mergeObjects
в настройках, передаваемых в функцию merge
, которая ведет себя как { ...existing, ...incoming }
и также вызывает вложенные merge
перед объединением полей existing
и incoming
:
const cache = new InMemoryCache({
typePolicies: {
Book: {
fields: {
author: {
merge(existing, incoming, { mergeObjects }) {
return mergeObjects(existing, incoming)
}
}
}
}
}
})
Сокращенная форма:
const cache = new InMemoryCache({
typePolicies: {
Book: {
fields: {
author: {
merge: true
}
}
}
}
})
Использование функции merge
в типах
Вместо настройки merge
в разных полях, которые может содержать объект Author
, ее можно настроить в политике типа Author
:
const cache = new InMemoryCache({
typePolicies: {
Book: {
fields: {
// больше не требуется
}
},
Author: {
merge: true
}
}
})
Объединение массивов ненормализованных объектов
Предположим, что Book
может иметь нескольких авторов:
query BookWithAuthorNames {
favoriteBook {
isbn
title
authors {
name
}
}
}
query BookWithAuthorLanguages {
favoriteBook {
isbn
title
authors {
language
}
}
}
Теперь поле favoriteBook.authors
- это не объект, но массив объектов (авторов), поэтому в данном случае очень важно определить кастомную функцию merge
во избежание потери данных в результате замены:
const cache = new InMemoryCache({
typePolicies: {
Book: {
fields: {
authors: {
merge(existing, incoming, { readField, mergeObjects }) {
const merged = existing ? existing.slice(0) : []
const authorNameToIndex = Object.create(null)
if (existing) {
existing.forEach((author, index) => {
authorNameToIndex[readField('name', author)] = index
})
}
incoming.forEach((author) => {
const name = readField('name', author)
const index = authorNameToIndex[name]
if (typeof index === 'number') {
merged[index] = mergeObjects(merged[index], author)
} else {
authorNameToIndex[name] = merged.length
merged.push(author)
}
})
return merged
}
}
}
}
}
})
Вместо слепой замены существующего массива авторов входящим массивом, приведенный код конкатенирует массивы, выполняя проверку на дублирование имен авторов, объединяя поля любых повторяющихся объектов author
.
Утилита readField
является более надежной, чем author.name
, поскольку она учитывает, что author
может быть объектом Reference
, ссылающимся на какие-то кешированные данные, что может иметь место при определении keyFields
типа Author
.
Приведенный пример также демонстрирует, что функции merge
быстро становятся сложными. Однако ничто не мешает нам вынести общую логику в отдельную утилиту:
const cache = new InMemoryCache({
typePolicies: {
Book: {
fields: {
authors: {
merge: mergeArrayByField('name')
}
}
}
}
})
Обработка пагинации
Когда поле содержит массив, часто бывает полезным пагинировать его результаты, поскольку общее количество элементов может быть огромным.
Как правило, запрос содержит аргументы для пагинации, которые определяют:
- начальный элемент массива - его индекс или
id
- количество возвращаемых элементов
При реализации пагинации для поля важно помнить о соответствующих аргументах при реализации функций read
и write
:
const cache = new InMemoryCache({
typePolicies: {
Agenda: {
fields: {
tasks: {
merge(existing, incoming, { args }) {
const merged = existing ? existing.slice(0) : []
// вставляем входящие элементы в правильное место на основе аргументов
const end = args.offset + Math.min(args.limit, incoming.length)
for (let i = args.offset; i < end; ++i) {
merged[i] = incoming[i - args.offset]
}
return merged
},
read(existing, { args }) {
// если прочитаем поле до того, как данные будут записаны в кеш,
// функция вернет `undefined`, что будет указывать на отсутствие поля
const page = existing && existing.slice(
args.offset,
args.offset + args.limit
)
// если размер запрашиваемой страницы будет больше существующего массива,
// `page.length` будет иметь значение `0`,
// поэтому вместо пустого массива вернется `undefined`
if (page && page.length > 0) {
return page
}
}
}
}
}
}
})
Этот пример показывает, что функция read
часто должна взаимодействовать с функцией merge
для обработки тех же аргументов в обратном порядке.
Если мы хотим получить результаты, начиная с определенного id
, вместо args.offset
следует использовать вспомогательную функцию readField
:
const cache = new InMemoryCache({
typePolicies: {
Agenda: {
fields: {
tasks: {
merge(existing, incoming, { args, readField }) {
const merged = existing && existing.slice(0) : []
// получаем набор всех существующих `id`
const existingIdSet = new Set(
merged.map((task) => readField('id', task))
)
// удаляем входящие задачи, которые есть в существующих данных
incoming = incoming.filter(
(task) => !existingIdSet.has(readField('id', task))
)
// получаем `id` задачи, находящейся перед первой входящей задачей
const afterIndex = merged.findIndex(
(task) => args.afterId === readField('id', task)
)
if (afterIndex > -1) {
merged.splice(afterIndex + 1, 0, ...incoming)
} else {
merged.push(...incoming)
}
return merged
},
read(existing, { args, readField }) {
if (existing) {
const afterIndex = existing.findIndex(
(task) => args.afterId === readField('id', task)
)
if (afterIndex > -1) {
const page = existing.slice(
afterIndex + 1,
afterIndex + 1 + args.limit
)
if (page && page.length > 0) {
return page
}
}
}
}
}
}
}
}
})
Обратите внимание: вызов readField(fieldName)
возвращает значение указанного поля текущего объекта, а вызов readField(fieldName, object)
(например, readField('id', task)
) - значение поля указанного объекта.
Приведенный код может показаться сложным, но после реализации предпочтительной стратегии пагинации, ее можно повторно применять в отношении любого поля, независимо от типа. Например:
const afterIdLimitPaginationPolicy = () => ({
merge(existing, incoming, { args, readField }) {
// ...
},
read(existing, { args, readField }) {
// ...
}
})
const cache = new InMemoryCache({
typePolicies: {
Agenda: {
fields: {
tasks: afterIdLimitPaginationPolicy
}
}
}
})
Определение аргументов-ключей
Если поле принимает аргументы, в typePolicy
можно определить массив keyAgrs
. Данный массив содержит элементы, которые будут использоваться в качестве ключей при вычислении значений полей. Определение такого массива позволяет уменьшить количество дублирующихся данных в кеше.
Предположим, что в нашей схеме имеется тип Query
с полем monthForNumber
. Данное поле возвращает числовое значение месяца (например, для January
возвращается 1
). Аргумент number
является ключом для данного поля, поскольку он используется для вычисления результата:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
monthForNumber: {
keyArgs: ['number']
}
}
}
}
})
Примером аргументов, которые не используются в качестве ключа, является токен доступа, который используется для авторизации, а не для вычисления результата. Если montForNumber
также принимает аргумент accessToken
, значение данного аргумента не будет влиять на возвращаемый результат.
Продвинутые техники по работе с кешем
Отключение кеша
Для отключения кеша используется политика no-cache
:
const { loading, error, data } = useQuery(GET_DOGS, {
fetchPolicy: 'no-cache'
})
Постоянное хранение кеша
Для сохранения кеша в asyncStorage
или localStorage
используется метод persistCache
из библиотеки apollo3-cache-persist
:
import { AsyncStorage } from 'react-native'
import { InMemoryCache } from '@apollo/client'
import { persistCache } from 'apollo3-cache-persist'
const cache = new InMemoryCache()
persistCache({
cache,
storage: AsyncStorage
}).then(() => {
// дальнейшая настройка Клиента
})
Сброс кеша
Вызов метода resetStore
приводит к сбросу кеша:
export default withApollo(graphql(PROFILE_QUERY, {
props: ({ data: { loading, currentUser }, ownProps: { client } }) => ({
loading,
currentUser,
resetOnLogout: async () => client.resetStore()
})
})(Profile))
Для сброса кеша без повторного выполнения активных запросов вместо resetStore
следует использовать clearStore
.
Обработка сброса кеша
Колбек для обработки сброса кеша регистрируется с помощью onResetStore
. В следующем примере мы записываем в кеш дефолтные значения. Это может быть полезным при управлении локальным состоянием и вызове resetStore
в любом месте приложения:
import { ApolloClient, InMemoryCache } from '@apollo/client'
import { withClientState } from 'apollo-link-state'
import { resolvers, defaults } from './resolvers'
const cache = new InMemoryCache()
const stateLink = withClientState({ cache, resolvers, defaults })
const client = new ApolloClient({
cache,
link: stateLink
})
client.onResetStore(stateLink.writeDefaults)
Инкрементальная загрузка: fetchMore
fetchMore
может использоваться для обновления результатов запроса на основе результатов другого запроса. Это используется, в частности, для реализации бесконечной прокрутки.
В следующем примере мы рендерим список репозиториев GitHub
с кнопкой Load More
, при нажатии на которую выполняется загрузка следующей порции данных. При этом, мы не теряем ранее полученную информацию:
const FEED_QUERY = gql`
query Feed($type: FeedType!, $offset: Int, $limit: Int) {
currentUser {
login
}
feed(type: $type, offset: $offset, limit: $limit) {
id
# ...
}
}
`
const FeedWithData = ({ match }) => (
<Query
query={FEED_QUERY}
variables={{
type: match.params.type.toUpperCase() || 'TOP',
offset: 0,
limit: 10
}}
fetchPolicy='cache-and-network'
>
{({ data, fetchMore }) => (
<Feed
entries={data.feed || []}
onLoadMore={() =>
fetchMore({
variables: {
offset: data.feed.length
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev
return Object.assign({}, prev, {
feed: [...prev.feed, ...fetchMoreResult.feed]
})
}
})
}
/>
)}
</Query>
)
Пагинация
GraphQL
позволяет запрашивать только необходимые поля. Это делает ответы от сервера маленькими (по размеру) и быстрыми.
Однако GraphQL
не гарантирует, что ответы всегда будут такими. Это особенно актуально для полей, содержащих списки. Список может содержать бесконечное количество элементов, что может привести к громадному ответу на простой запрос, вроде следующего:
query GetBookTitles {
books {
title
}
}
Что если наш граф данных содержит тысячи или миллионы книг? Для решения этой проблемы сервер может "пагинировать" результаты запроса.
Когда клиент запрашивает поле с пагинированным списком, сервер возвращает только порцию (страницу, page) элементов. Клиентский запрос включает аргументы для определения запрашиваемой страницы.
Существуют различные стратегии реализации пагинации: на основе отступа, на основе курсора (cursor-based), на основе номера страницы (page-number-based), опережающая (forwards), ретроспективная (backwards) и т.д. Поскольку выбор той или иной стратегии зависит от конкретной ситуации, ни Apollo
, ни спецификация GraphQL
не определяют стратегии по умолчанию.
Вместо этого, Apollo
предоставляет гибкий интерфейс кеширования, позволяющий объединять результаты запросов полей с пагинированными списками, независимо от используемой стратегии. И поскольку мы можем создавать кастомные стратегии пагинирования в виде функций без состояния, мы можем использовать эти функции для всех полей, использующих одинаковые стратегии.
Ядро интерфейса пагинации
Функция fetchMore
Пагинация предполагает отправку запросов на получение дополнительных результатов. Рекомендуемым подходом для реализации пагинации является использование функции fetchMore
. Данная функция является частью объекта ObservableQuery
, возвращаемого client.watchQuery
. Она также включается в объект, возвращаемый хуком useQuery
:
const { loading, data, fetchMore } = useQuery(GET_ITEMS, {
variables: {
offset: 0,
limit: 10
}
})
При вызове fetchMore
, ей передается набор variables
в объекте options
:
fetchMore({
variables: {
offset: 10,
limit: 10
}
})
Кроме variables
, можно использовать любую другую форму query
.
Объединение пагинируемых результатов
Определение политики поля
Политика поля определяет порядок чтения и записи поля в InMemoryCache
. Мы можем определить политику для объединения результатов пагинации в один список.
Пример серверной схемы ленты сообщений, в котором используется пагинация на основе отступа:
type Query {
feed(offset: Int, limit: Int): [FeedItem!]
}
type FeedItem {
id: String!
message: String!
}
На клиенте мы хотим определить политику поля для Query.feed
, чтобы возвращаемые "страницы" списка объединялись в один список в кеше. Для этого мы используем настройку typePolicies
в конструкторе InMemoryCache
:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
// отключаем кеширование отдельных результатов на основе
// любого аргумента данного поля
keyArgs: false,
// объединяем входящий список элементов
// с существующим
merge(existing = [], incoming) {
return [...existing, ...incoming]
}
}
}
}
}
})
keyArgs
определяет список аргументов, которые приводят к сохранению в кеше отдельных значений поля для каждой уникальной комбинации этих аргументовmerge
определяет порядок объединенияincoming
(входящих данных) сexisting
(кешированными данными) для определенного поля. Без этой функции входящие данные просто заменят кешированные
После определения политики результаты всех запросов со следующей структурой будут объединяться, независимо от значений передаваемых аргументов:
const FEED_QUERY = gql`
query Feed($offset: Int, $limit: Int) {
feed(offset: $offset, limit: $limit) {
id
message
}
}
`
Проектирование функции merge
В приведенном выше примере функция merge
делает рискованное предположение о том, что клиент всегда запрашивает страницы в правильном порядке, она игнорирует значения offset
и limit
. В более продвинутой версии merge
для определения порядка объединения incoming
и existing
может использоваться options.args
:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
// то же самое, что `false`
keyArgs: [],
merge(existing, incoming, { args: { offset = 0 } }) {
// необходимо копировать существующие данные, поскольку они
// является иммутабельными и "заморожены" в режиме для разработки
const merged = existing ? existing.slice(0) : []
for (let i = 0; i < incoming.length; ++i) {
merged[offset + i] = incoming[i]
}
return merged
}
}
}
}
}
})
По сути, данная функция аналогична предыдущей, но она учитывает возможность повторения, перекрытия результатов, записи в неправильном порядке, исключая дублирование элементов в списке.
Функция read
Функция merge
помогает объединять результаты пагинации в один список, а функция read
- читать этот список.
Функция read
определяется в политике поля, наряду с функцией merge
и keyArgs
. При определении read
для поля, данная функция будет вызываться при любом запросе к полю. Она будет получать кешированное значение в качестве первого аргумента. Ответ на запрос будет содержать результат, возвращенный этой функцией, вместо существующих данных.
Функция read
, как правило, используется для:
- повторной пагинации - кешированный список разбивается на страницы
- получения всего списка
Обычно, лучшим решением является возврат всего списка. Это позволяет исключить необходимость в дополнительном чтении кеша.
Функция read
и повторная пагинация
read
может использоваться для выполнения повторной пагинации кешированного списка на стороне клиента. Она также может выполнять дополнительные операции, такие как сортировка или фильтрация списка.
Возвращаемые страницы могут отличаться от серверных, поскольку read
может принимать любые значения offset
и limit
:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
read(existing, { args: { offset, limit } }) {
// функция `read` всегда должна возвращать `undefined`, если `existing` имеет значение
// `undefined`. Возврат `undefined` означает отсутствие поля в кеше, что приводит к отправке запроса
// на получение его значения к серверу
return existing && existing.slice(offset, offset + limit)
},
// то же самое
keyArgs: [],
merge(existing, incoming, { args: { offset = 0 }}) {
const merged = existing ? existing.slice(0) : []
for (let i = 0; i < incoming.length; ++i) {
merged[offset + i] = incoming[i]
}
return merged
}
}
}
}
}
})
В зависимости от конкретной ситуации, можно предоставить дефолтные значения для offset
и limit
:
read(existing, {
args: {
// если `offset` и `limit` не указаны,
// возвращаем весь список
offset = 0,
limit = existing?.length,
} = {},
}) // ...
Пагинация на основе отступа
В данном случае поле, содержащее список, принимает аргумент offset
- индикатор того, с какого элемента сервер должен возвращать элементы в ответ на запрос, и аргумент limit
- максимальное количество возвращаемых в ответ на запрос элементов:
type Query {
feed(offset: Int, limit: Int): [FeedItem!]
}
type FeedItem {
id: ID!
message: String!
}
Эта стратегия хорошо подходит для иммутабельных списков, т.е. списков, индексы элементов которого остаются неизменнными. Для списков, порядок расположения элементов которых может меняться, лучше использовать пагинацию на основе курсора.
Вспомогательная функция offsetLimitPagination
Данная функция может использоваться для генерации политики любого релевантного поля:
import { InMemoryCache } from '@apollo/client'
import { offsetLimitPagination } from '@apollo/client/utilities'
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: offsetLimitPagination()
}
}
}
})
Пример совместного использования offsetLimitPagination
и fetchMore
const FeedData = () => {
const { loading, data, fetchMore } = useQuery(FEED_QUERY, {
variables: {
offset: 0,
limit: 10
}
})
if (loading) return <Loading/>
return (
<Feed
entries={data.feed || []}
onLoadMore={() => fetchMore({
variables: {
offset: data.feed.length
}
})}
/>
)
}
Это был пример использования функции read
для возврата всего списка.
Пример повторной пагинации
import { InMemoryCache } from "@apollo/client"
import { offsetLimitPagination } from "@apollo/client/utilities"
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
...offsetLimitPagination(),
read(existing, { args }) {
// реализация
}
}
}
}
}
})
Для отображения всех полученных данных предыдущий пример может быть переписан следующим образом:
const FeedData = () => {
const [limit, setLimit] = useState(10)
const { loading, data, fetchMore } = useQuery(FEED_QUERY, {
variables: {
offset: 0,
limit
}
})
if (loading) return <Loading />
return <Feed
entries={data.feed || []}
onLoadMore={() => {
const currentLength = data.feed.length
fetchMore({
variables: {
offset: currentLength,
limit: 10
}
}).then((fetchMoreResult) => {
// обновляем `variables.limit`
setLimit(currentLength + fetchMoreResult.data.feed.length)
})
}}
/>
}
Установка keyArgs
Если пагинируемое поле принимает аргументы, отличные от offset
и limit
, такие аргументы можно передать в offsetLimitPagination
в виде массива:
fields: {
// результаты будут принадлежать к тому же списку только при совпадении
// аргументов `type` и `userId`
feed: offsetLimitPagination(['type', 'userId'])
}
Пагинация на основе курсора
Начало страницы может быть определено с помощью какого-либо уникального идентификатора, принадлежащего каждому элементу списка.
Таким идентификатором может быть id
объекта, что позволяет запрашивать дополнительные страницы с помощью id
последнего объекта в списке вместе с аргументом list
.
Поскольку элементы списка могут быть нормализованными объектами Reference
, следует использовать вспомогательную функцию options.readField
для чтения поля id
в функциях merge
и read
:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
keyArgs: ['type'],
merge(existing, incoming, {
args: { cursor },
readField
}) {
const merged = existing ? existing.slice(0) : []
let offset = offsetFromCursor(merged, cursor, readField)
// при отсутствии курсора данные добавляются в конец списка
if (offset < 0) offset = merged.length
// остальная логика аналогична `offsetLimitPagination`
for (let i = 0; i < incoming.length; ++i) {
merged[offset + i] = incoming[i]
}
return merged
},
// эта функция не нужна в случае, когда мы хотим возвращать весь список
read(existing, {
args: { cursor, limit = existing.length },
readField
}) {
if (existing) {
let offset = offsetFromCursor(existing, cursor, readField)
// при отсутствии курсора, возвращаем весь список
if (offset < 0) offset = 0
return existing.slice(offset, offset + limit)
}
}
}
}
}
}
})
function offsetFromCursor(items, cursor, readField) {
// начинаем поиск с конца списка, поскольку курсор -
// это, как правило, `id` последнего объекта
for (let i = items.length - 1; i >= 0; --i) {
const item = items[i]
// `readField` работает как для ненормализованных объектов (возвращающих `item.id`),
// так и для нормализованных ссылок (возвращающих `id` из соответствующего объекта)
if (readField('id', item) === cursor) {
// прибавляем 1, поскольку курсор - это идентификатор элемента,
// предшествующего первому элементу возвращаемой страницы
return i + 1
}
}
// сообщаем об отсутствии курсора
return -1
}
Поскольку элементы могут удаляться, добавляться или перемещаться без изменения их id
, данная страт егия пагинации является более надежной, чем пагинация на основе отступа.
Использование карты для хранения уникальных элементов
Функция merge
может возвращать данные в любом формате. Главное, чтобы функция read
умела преобразовывать этот формат обратно в список:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
keyArgs: ['type'],
// несмотря на то, что `args.cursor` по-прежнему может играть важную роль в запросе страницы,
// в функции `merge` он больше не нужен
merge(existing, incoming, { readField }) {
const merged = { ...existing }
incoming.forEach((item) => {
merged[readField('id', item)] = item
})
return merged
}
// возвращаем все сохраненные элементы, чтобы не заботиться об их порядке
read(existing) {
return existing && Object.values(existing)
}
}
}
}
}
})
Сохранение курсоров в отдельной сущности
Как правило, курсором является id
элемента, но так бывает не всегда. В случаях, когда список содержит дубликаты или когда список сортируется или фильтруется на основе какого-то критерия, курсор может вычисляться не только на основе позиции элемента в списке, но и на основе логики сортировки или фильтрации. В таких ситуациях курсор может возвращаться отдельно от списка:
const MORE_COMMENTS_QUERY = gql`
query MoreComments($cursor: String, $limit: Int!) {
moreComments(cursor: $cursor, limit: $limit) {
cursor
comments {
id
author
text
}
}
}
`
function CommentsWithData() {
const {
data,
loading,
fetchMore
} = useQuery(MORE_COMMENTS_QUERY, {
variables: { limit: 10 }
})
if (loading) return <Loading />
return (
<Comments
entries={data.moreComments.comments || []}
onLoadMore={() => fetchMore({
variables: {
cursor: data.moreComments.cursor
}
})}
/>
)
}
Пример Query.moreComments
, который использует карту, но возвращает массив уникальных comments
:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
moreComments: {
more(existing, incoming, { readField }) {
const comments = existing ? { ...existing.comments } : {}
incoming.comments.forEach((comment) => {
comments[readField('id', comment)] = comment
})
return {
cursor: incoming.cursor,
comments
}
},
read(existing) {
if (existing) {
return {
cursor: existing.cursor,
comments: Object.values(existing.comments)
}
}
}
}
}
}
}
})
Интерфейс keyArgs
В дополнение к функциям merge
и read
, политика поля InMemoryCache
может содержать настройку keyArgs
, которая определяет массив аргументов, названия которых сериализуются и добавляются к названию поля для создания отдельных ключей хранилища для значения, запеисываемого в кеш.
Политика keyArgs: ['type']
означает, что type
- единственный аргумент, который должен учитываться кешем (в дополнение к названию поля и идентификатору родительского объекта) при доступе к значению этого поля. Настройка keyArgs: false
означает, что значение поля будет идентифицироваться только по его названию (внутри некоторого StoreObject
), без сериализации и добавления аргументов.
Какие аргументы следует указывать в keyArgs
По умолчанию InMemoryCache
включает в keyArgs
все аргументы. Это означает, что любое уникальное сочетание аргументов приводит к созданию отдельной записи в кеше. В функцих read
и merge
эти внутренние данн ые поля доступны через параметр existing
, который будет иметь значение undefined
, если комбинация аргументов ранее не сохранялась в кеше. В данном случае кеш будет использоваться повторно только при полном совпадении аргументов. Это существенно увеличивает размер кеша, но обеспечивает уникальность кешированных данных, когда разница в аргументах имеет принципиальное значение.
С другой стороны, при использовании настройки keyArgs: false
ключом поля будет только его название, аргументы учитываться не будут. Поскольку read
и merge
имеют доступ к аргументам через options.args
, мы можем использовать их для реализации поведения keyArgs
. В этом случае ответственность за определение возможности повторного использования кеша и преобразование кешированных данных возлагается на функцию read
.
Пример использования keyArgs: false
вместо keyArgs: ['type']
для реализации политики поля Query.feed
:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
keyArgs: false,
read(existing = {}, { args: { type, offset, limit } }) {
return existing[type] &&
existing[type].slice(offset, offset + limit)
},
merge(existing = {}, incoming, { args: { type, offset = 0} }) {
const merged = existing[type] ? existing[type].slice(0) : []
for (let i = 0; i < incoming.length; ++i) {
merged[offset + i] = incoming[i]
}
existing[type] = merged
return existing
}
}
}
}
}
})
В данном случае мы можем безопасно возложить ответственность за обработку type
на keyArgs
и упростить read
и merge
до обработки одной feed
за раз.
Если кратко, то в случае, когда логика записи и извлечения данных является одинаковой для разных значений определенного аргумента (например, type
), и эти значения логически независимы друг от друга, тогда этот аргумент следует указывать в keyArgs
.
С другой стороны, аргументы, которые ограничивают, фильтруют, сортируют или каким-либо другим способом преобразовывают существующие данные, обычно, не принадлежат keyArgs
, поскольку помещение их в keyArgs
сделает ключи хранилища более дифференцированными, увеличив размер кеша и ограничив возможность использования разных аргументов для извлечения разных представлений одних и тех же данных (без отправки дополнительных сетевых запросов).