Skip to content

Latest commit

 

History

History
4579 lines (3567 loc) · 219 KB

client.md

File metadata and controls

4579 lines (3567 loc) · 219 KB

Apollo Client Guide 🤘

На главную

Содержание

Введение

Apollo Client - это библиотека для управления состоянием, позволяющая управлять как локальными, так и удаленными данными с помощью GraphQL. Она может использоваться для получения, кеширования и модификации данных приложения с автоматическим обновлением UI.

Ядром 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 сделает ключи хранилища более дифференцированными, увеличив размер кеша и ограничив возможность использования разных аргументов для извлечения разных представлений одних и тех же данных (без отправки дополнительных сетевых запросов).

Управление локальным состоянием

Клиент позволяет управлять локальным состоянием наряду с удаленным (полученным от сервера). Это позволяет взаимодействовать с состоянием всего приложения через один общий интерфейс.

Локальное состояние может храниться как угодно (например, в локальном хранилище (localStorage) или кеше Apollo). Логика получения и заполнения локальных полей при запросе определенного поля определяется в запросе. С помощью одного запроса можно получать как локальные, так и удаленные данные.

Существует два механизма для управления локальным состоянием: политики поля (field policies) и реактивные переменные (reactive variables).

Политики поля

Политики поля позволяют определять, что происходит при запросе конкретного поля, включая поля, которые отсутствуют в серверной схеме. Локальные поля могут заполняться данными, хранящими где угодно.

Один запрос может включать как локальные, так и удаленные поля. В политике поля для каждого локального поля определяется функция для заполнения этого поля данными.

Реактивные переменные

Реактивные переменные позволяют читать и записывать локальные данные в любом месте приложения без выполнения операций. Политика локального поля может использовать реактивную переменную для заполнения текущего значения поля.

Реактивные переменные не хранятся в кеше, поэтому они не должны следовать никакому контракту. Мы можем хранить в них что угодно.

При изменения значения такой переменной клиент обнаруживает его автоматически. Каждый активный запрос, содержащий поле, которое зависит от такой переменной, будет автоматически обновлено.

Локальные поля

Запрос может включать локальные поля, отсутствующие в серверной схеме:

query ProductDetails($productId: ID!) {
  product(id: $productId) {
    name
    price
    isInCart @client # Это локальное поле
  }
}

Значения таких полей вычисляются с помощью любой логики, например, посредством чтения данных из локального хранилища.

Как видно в приведенном выше примере, запрос может включать как локальные поля, так и поля, которые запрашиваются от сервера.

Определение локального поля

Предположим, что мы разрабатываем Интернет-магазин. Большая часть информации о товарах хранится на сервере, однако мы хотим локально определить логический индикатор Product.isInCart. Сначала необходимо создать политику поля для isInCart.

Политика определяет кастомную логику для чтения и записи поля в кеш. Политики определяются в конструкторе InMemoryCache. Каждая политика типа является потомком определенной политики типа.

const cache = new InMemoryCache({
  typePolicies: { // Карта политик типа
    Product: {
      fields: { // Карта политик поля для типа `Product`
        isInCart: { // Политика поля для поля `isInCart`
          read(_, { variables }) { // Функция чтения для поля `isInCart`
            return localStorage.getItem('CART').includes(
              variables.productId
            )
          }
        }
      }
    }
  }
})

При запросе поля вызывается функция read, вычисляющая его значение. В данном случае read возвращает индикатор наличия товара с указанным id в массиве CART, хранящемся в localStorage.

read может использоваться для реализации любой логики, например:

  • ручное выполнение операций над кешем
  • вызов вспомогательных утилит или библиотек для подготовки, валидации или обезвреживания данных
  • запроса данных из другого хранилища
  • логирования метрик использования

Выполнение запросов

После определения политики поля мы можем включить данное поле в запрос на получение данных от сервера:

const GET_PRODUCT_DETAILS = gql`
  query ProductDetails($productId: ID!) {
    product(id: $productId) {
      name
      price

      isInCart @client
    }
  }
`

Директрива @client сообщает Клиенту, что isInCart является локальным полем. isInCart не включается в запрос, отправляемый на сервер. Финальный результат запроса возвращается только после заполнения всех серверных и локальных полей.

Хранение данных

Реактивные переменные

Реактивные переменные отлично подходят для хранения локального состояния:

  • реактивные переменные могут читаться и записываться в любом месте приложения без выполнения операций
  • в отличие от кеша, реактивные переменные не осуществляют нормализацию данных. Это означает, что данные могут храниться в любом формате
  • если значение поля зависит от реактивной переменной, и значение этой переменной изменяется, каждый активный запрос, включающий такое поле, будет автоматически обновлен

Вернемся к нашему приложению. Допустим, что мы хотим получить список id товаров, находящихся в корзине пользователя, и такой список хранится локально. Запрос будет выглядеть так:

export const GET_CART_ITEMS = gql`
  query GetCartItems {
    cartItems @client
  }
`

Для создания реактивной переменной используется функция makeVar():

import { makeVar } from '@apollo/client'

export const cartItemsVar = makeVar([])

Это инициализирует реактивную переменную с помощью пустого массива (в makeVar() может быть передано любое начальное значение). Обратите внимание, что значение, возвращаемое makeVar(), это не переменная, а функция. Для получения значения переменной используется cartItemsVar(), а для записи значения - cartItemsVar(newValue).

Далее определяем политику для cartItems:

export const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {

        cartItems: {
          read() {
            return cartItemsVar()
          }
        }
      }
    }
  }
})

При запросе поля функция read возвращает значение реактивной переменной.

Создаем компонент кнопки для добавления товара в корзину:

import { cartItemsVar } from './cache'
// другие импорты

export function AddToCartButton({ productId }) {
  return (
    <div class="add-to-cart-button">

      <Button onClick={() => cartItemsVar([...cartItemsVar(), productId])}>
        Добавить в корзину
      </Button>
    </div>
  )
}

Нажатие кнопки обновляет значение cartItemsVar - в массив добавляется соответствующий productId. Когда это происходит, Клиент уведомляет каждый активный запрос, включающий поле cartItems.

Вот пример компонента Cart, который используется запрос GET_CART_ITEMS и поэтому автоматически обновляется при изменении значения cartItemsVar:

export const GET_CART_ITEMS = gql`
  query GetCartItems {
    cartItems @client
  }
`

export function Cart() {
  const { data, loading, error } = useQuery(GET_CART_ITEMS)

  if (loading) return <Loading />
  if (error) return <p>Ошибка: {error.message}</p>

  return (
    <div class="cart">
      <Header>Моя корзина</Header>
      {data && data.cartItems.length === 0 ? (
        <p>В корзине нет товаров</p>
      ) : (
        <>
          {data && data.cartItems.map(productId => (
            <CartItem key={productId} />
          ))}
        </>
      )}
    </div>
  )
}

Вместо запроса cartItems, компонент Cart может читать и реагировать на изменения реактивной переменной напрямую с помощью хука useReactiveVar:

import { useReactiveVar } from '@apollo/client'

export function Cart() {

  const cartItems = useReactiveVar(cartItemsVar)

  return (
    <div class="cart">
      <Header>Моя корзина</Header>
      {cartItems.length === 0 ? (
        <p>В корзине нет товаров</p>
      ) : (
        <>
          {cartItems.map(productId => (
            <CartItem key={productId} />
          ))}
        </>
      )}
    </div>
  )
}

Кеш

Хранение локального состояния в кеше имеет некоторые преимущества, но, обычно, требует большего количества кода, чем использование реактивных переменных:

  • политику поля определять не нужно. При запросе поля, для которого не определена функция read, Клиент сразу обращается к кешу
  • при модификации кеша с помощью writeQuery или writeFragment каждый активный запрос, включающий такое поле, обновляется автоматически

Предположим, что у нас имеется такой запрос:

const IS_LOGGED_IN = gql`
  query IsUserLoggedIn {
    isLoggedIn @client
  }
`

Поле isLoggedIn является локальным. Для его прямой модификации в кеше можно использовать метод writeQuery:

cache.writeQuery({
  query: IS_LOGGED_IN,
  data: {
    isLoggedIn: !!localStorage.getItem('token')
  }
})

После этого компоненты нашего приложения могут выполнять условный рендеринг на основе значения поля isLoggedIn без определения для него функции read:

function App() {
  const { data } = useQuery(IS_LOGGED_IN)
  return data.isLoggedIn ? <Pages /> : <Login />
}

Полный пример

import React from 'react'
import ReactDOM from 'react-dom'
import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
  gql
} from '@apollo/client'

import Pages from './pages'
import Login from './pages/login'

const cache = new InMemoryCache()

const client = new ApolloClient({
  uri: 'http://localhost:4000/graphql',
  cache
})

const IS_LOGGED_IN = gql`
  query IsUserLoggedIn {
    isLoggedIn @client
  }
`

cache.writeQuery({
  query: IS_LOGGED_IN,
  data: {
    isLoggedIn: !!localStorage.getItem('token'),
  }
})

function App() {
  const { data } = useQuery(IS_LOGGED_IN)
  return data.isLoggedIn ? <Pages /> : <Login />
}

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById('root'),
)

Обратите внимание: бывает полезным определять read даже для локальных полей, значения которых хранятся в кеше, поскольку read может выполнять некоторые полезные операции, такие как возврат значения по умолчанию в случае, когда значение в кеше отсутствует.

Модификация

Способ модификации значения локального поля зависит от способа его хранения:

  • при использовании реактивной переменной все, что нужно сделать, это присвоить ей новое значение. Клиент автоматически обнаружит это изменение и обновит все активные запросы, включающие соответствующее поле
  • при использовании кеша для модификации полей вызывается writeQuery, writeFragment или cache.modify. Как и в случае с реактивными переменными, вызов любого из этих методов приводит к инвалидации активных запросов
  • при использовании другого способа, такого как localStorage, установка нового значение не приводит к автоматической инвалидации активных запросов. Это необходимо делать вручную, например, с помощью метода cache.evict, принимающего id объекта и название локального поля

Использование локальных полей в качестве переменных

Если в запросе используются переменные, локальные поля могут предоставлять для них значения.

Для этого используется директива @exports(as: 'variableName'):

const GET_CURRENT_AUTHOR_POST_COUNT = gql`
  query CurrentAuthorPostCount($authorId: Int!) {

    currentAuthorId @client @export(as: "authorId")
    postCount(authorId: $authorId)
  }
`

В приведенном примере результат локального поля currentAuthorId используется в качестве значения переменной $authorId, которая передается в postCount.

Так можно делать даже в случае, когда postCount также является локальным полем (@client).

Реактивные переменные

Реактивные переменные - полезный механизм для представления локального состояния приложения за пределами кеша. Состояние в таких переменных может храниться в любом виде.

Самое главное - изменение реактивной переменной влечет обновление всех активных запросов, основанных на этой переменной. Это также обновляет состояние компонентов, в которых используется хук useReactiveVar.

Создание реактивной переменной

Для создания реактивной переменной используется функция makeVar:

import { makeVar } from '@apollo/client'

const cartItemsVar = makeVar([])

В данном случае начальным значением переменной является пустой массив. Обратите внимание, что makeVar возвращает функцию.

Чтение

Для чтения реактивной переменной следует вызвать функцию - результат вызова makeVar - без аргументов:

const cartItemsVar = makeVar([])

// вывод: []
console.log(cartItemsVar())

Модификация

Для модификации реактивной переменной следует вызвать функцию - результат вызова makeVar - с новым значением:

const cartItemsVar = makeVar([])

cartItemsVar([100, 101, 102])

// вывод: [100, 101, 102]
console.log(cartItemsVar())

cartItemsVar([456])

// вывод: [456]
console.log(cartItemsVar())

Производительность

Предварительное получение данных

Предварительное получение данных (prefetching) означает загрузку данных в кеш перед тем, как возникнет необходимость в их использовании. Как правило, мы хотим загружать данные для представления (view) как только нам стали ясны намерения пользователя.

Мы можем реализовать это с помощью нескольких строк кода, вызвав client.query при наведении пользователям курсора на ссылку:

function Feed() {
  const { loading, error, data, client } = useQuery(GET_DOGS)

  let content
  if (loading) {
    content = <Fetching />
  } else if (error) {
    content = <Error />
  } else {
    content = (
      <DogList
        data={data.dogs}
        renderRow={(type, data) => (
          <Link
            to={{
              pathname: `/${data.breed}/${data.id}`,
              state: { id: data.id }
            }}
            onMouseOver={() =>
              client.query({
                query: GET_DOG,
                variables: { breed: data.breed }
              })
            }
            style={{ textDecoration: "none" }}
          >
            <Dog {...data} url={data.displayImage} />
          </Link>
        )}
      />
    )
  }

  return (
    <View style={styles.container}>
      <Header />
      {content}
    </View>
  )
}

Другие подходящие для предварительной загрузки данных случаи:

  • следующий шаг в многоступенчатой структуре
  • маршрут (роут) для кнопки, нажатие которой запускает операцию
  • все данные для определенной части приложения

Оптимистические обновления

Предположим, что мы разрабатываем приложение для блога, в котором имеется такая мутация:

type Mutation {
  updateComment(commentId: ID!, content: String!): Comment!

  # другие мутации
}

Если пользователь редактирует существующий комментарий к посту, приложение выполняем мутацию updateComment, которая возвращает объект Comment с обновленным content.

Наше приложение знает, на что будет похож Comment, поэтому оно может оптимистически обновить UI для отображения обновления до получения ответа от сервера.

Настройка optimisticResponse

Для реализации оптимистического обновления в функцию мутации передается настройка optimisticResponse:

// определение мутации
const UPDATE_COMMENT = gql`
  mutation UpdateComment($commentId: ID!, $commentContent: String!) {
    updateComment(commentId: $commentId, content: $commentContent) {
      __typename
      id
      content
    }
  }
`

// определение компонента
function CommentPageWithData() {
  const [mutate] = useMutation(UPDATE_COMMENT)

  return (
    <Comment
      updateComment={({ commentId, commentContent }) =>
        mutate({
          variables: { commentId, commentContent },

          optimisticResponse: {
            updateComment: {
              __typename: "Comment",
              id: commentId,
              content: commentContent
            }
          }
        })
      }
    />
  )
}

Как видно в примере, значением optimisticResponse является объект такой же формы, что и ответ, который мы ожидаем получить от сервера. Обратите внимание, что этот объект включает поля id и __typename. Кеш использует эти значения для генерации уникального идентификатора для кеша (например, Comment:5).

Жизненный цикл оптимистической мутации

  1. При вызове mutate в кеш записывается объект Comment со значениями полей, определенными в optimisticResponse. Однако, кешированный Comment при этом не перезаписывается. Вместо этого создается отдельная оптимистичная версия этого объекта. Это предоставляет страховку на случай, если optimisticResponse окажется неверным
  2. Все активные запросы, включающие обновленный комментарий, получают соответствующее уведомление. Эти запросы обновляются, связанные с ними компоненты подвергаются повторному рендерингу для отображения оптимистичных данных. Поскольку это не требует выполнения сетевых запросов, обновление происходит очень быстро
  3. Сервер возвращает настоящий объект Comment
  4. Из кеша удаляется оптимистичная версия Comment и записывается каноническая (canonical)
  5. Запросы снова получают уведомления. Связанные компоненты подвергаются повторному рендерингу, но если ответ от сервера совпадает с optimisticResponse, этот ререндеринг происходит незаметно для пользователя

Пример

Что насчет оптимистического создания объекта, отсутствующего в кеше? Это работает точно также. Единственным отличием является то, что в кеше отсутствует id объекта (или другое поле для его идентификации). Это означает необходимость предоставления фиктивного id, чтобы клиент мог записать объект в кеш.

Пример оптимистической мутации, создающей новый элемент списка задач:

optimisticResponse: {
  addTodo: {
    id: 'temp-id',
    __typename: 'Todo',
    description: input.value
  }
}

Рендеринг на стороне сервера

Рендеринг на стороне сервера (SSR) - это технология оптимизации, позволяющая рендерить начальное состояние приложения в готовую разметку и стили перед передачей состояния браузеру. Это означает, что пользователям не нужно ждать, пока браузер загрузит и инициализирует React (Vue, Angular и т.д.), чтобы сделать контент доступным.

Клиент предоставляет интерфейс для рендеринга на стороне сервера, включая функцию, кторая выполняет все запросы, необходимые для рендеринга дерева компонентов.

Отличия от рендеринга на стороне клиента

  • необходимо использовать совместимый с сервером роутер для React, такой как React Router (приложение оборачивается в StaticRouter вместо Browser Router)
  • относительные ссылки должны быть заменены на абсолютные там, где это возможно
  • немного отличается порядок иникиализации Клиента (⬇)

Инициализация Клиента

Пример серверной инициализации Клиента:

import {
  ApolloClient,
  createHttpLink,
  InMemoryCache
} from '@apollo/client'

const client = new ApolloClient({
  ssrMode: true,
  link: createHttpLink({
    uri: 'http://localhost:3010',
    credentials: 'same-origin',
    headers: {
      cookie: req.header('Cookie'),
    }
  }),
  cache: new InMemoryCache()
})
  • указывается настройка ssrMode: true. Это отключает повторное выполнение запросов и позволяет использовать функцию getDataFromTree (⬇)
  • вместо настройки uri указывается настройка link с экземпляром HttpLink. Это позволяет добавлять данные для аутентификации при отправке любого запроса к серверу

Пример

Рассмотрим пример SSR в приложении Node.js. В нем используется Express и React Router v4.

Пример файла app.js без кода для рендеринга React в HTML и CSS:

import {
  ApolloProvider,
  ApolloClient,
  createHttpLink,
  InMemoryCache
} from '@apollo/client'
import Express from 'express'
import React from 'react'
import { StaticRouter } from 'react-router'

// см. ниже
import Layout from './routes/Layout'

const app = new Express()

app.use((req, res) => {
  const client = new ApolloClient({
    ssrMode: true,
    link: createHttpLink({
      uri: 'http://localhost:3010',
      credentials: 'same-origin',
      headers: {
        cookie: req.header('Cookie'),
      },
    }),
    cache: new InMemoryCache()
  })

  const context = {}

  const App = (
    <ApolloProvider client={client}>
      <StaticRouter location={req.url} context={context}>
        <Layout />
      </StaticRouter>
    </ApolloProvider>
  )

  // TODO: код рендеринга (см. ниже)
})

app.listen(basePort, () => console.log(
  `Сервер запущен по адресу http://localhost:${basePort}`
))

При получении запроса сервер инициализирует Клиента и создает дерево React, которое обернуто в ApolloProvider и StaticRouter. Контент дерева зависит от URL запроса и определенных в StaticRouter маршрутов.

Выполнение запросов с помощью getDataFromTree

Функция getDataFromTree проходит по дереву и выполняет каждый встреченный запрос (включая вложенные). Она возвращает промис, который разрешается, когда все данные в кеше готовы к использованию.

После разрешения промиса дерево React может быть отрендерено и возвращено вместе с текущим состоянием кеша.

Обратите внимание: для рендеринга дерева в виде строки вместо getDataFromTree следует использовать renderToStringWithData, которая обеспечит правильную работу гидратации на стороне клиента с помощью ReactDOMServer.renderToString.

Заменяем TODO в приведенном выше примере на следующий код:

// импортируем функцию в начале файла
import { getDataFromTree } from "@apollo/client/react/ssr"

// заменяем этим `TODO`
getDataFromTree(App).then((content) => {
  // извлекаем текущее состояние кеша
  const initialState = client.extract()

  // добавляем контент страницы и состояние кеша в компонент верхнего уровня
  const html = <Html content={content} state={initialState} />

  // рендерим компонент в статическую разметку и возвращаем его
  res.status(200)
  res.send(`<!doctype html>\n${ReactDOM.renderToStaticMarkup(html)}`)
  res.end()
})

Определение компонента Html выглядит так:

export function Html({ content, state }) {
  return (
    <html>
      <body>
        <div id="root" dangerouslySetInnerHTML={{ __html: content }} />
        <script dangerouslySetInnerHTML={{
          __html: `window.__APOLLO_STATE__=${JSON.stringify(state).replace(/</g, '\\u003c')}`
        }} />
      </body>
    </html>
  )
}

Содержимое в виде разметки помещается в контейнер с идентификатором root, а состояние кеша присваевается глобальному объекту __APOLLO_STATE__.

Обратите внимание: вызов replace обеспечивает замену символа < для предотвращения межсайтового скриптинга через передачу </script> в строке.

Регидратация кеша на стороне клиента

Помещение серверного кеша в __APOLLO_STATE__ не делает его доступным для клиентского кеша. InMemoryCache предоставляет вспомогательную функцию restore для регидратации состояние кеша с данными, извлеченными (extracted) из другого экземпляра кеша.

const client = new ApolloClient({
  cache: new InMemoryCache().restore(JSON.parse(window.__APOLLO_STATE__)),
  uri: 'https://example.com/graphql'
})

После этого запросы на стороне клиента выполняются незамедлительно, поскольку их результаты доставляются из кеша.

Изменение политики выполнения запроса

Если в некоторых из начальных запросов используется политика network-only или cache-and-network, для того, чтобы пропустить их выполнение во время инициализации приложения можно использовать настройку ssrForceFetchDelay:

const client = new ApolloClient({
  cache: new InMemoryCache().restore(JSON.parse(window.__APOLLO_STATE__)),
  link,
  ssrForceFetchDelay: 100, // в мс
})

Локальные запросы

Если конечная точка GraphQL находится на том же сервере, который выполняет рендеринг, можно отключить использование сети при выполнении SSR-запросов.

Одним из способов это сделать является использование Apollo Link для получения данных с помощью локальной схемы вместо отправки сетевого запроса. Для этого при создании Клиента на сервере вместо createHttpLink используется SchemaLink, которая использует схему и контекст для незамедлительного выполнения запроса без отправки сетевых запросов:

import { ApolloClient, InMemoryCache } from '@apollo/client'
import { SchemaLink } from '@apollo/client/link/schema'

// ...

const client = new ApolloClient({
  ssrMode: true,
  // !
  link: new SchemaLink({ schema }),
  cache: new InMemoryCache()
})

Пропуск запроса

Для того, чтобы пропустить выполнение запроса, можно использовать ssr: false в настройках запроса:

function withClientOnlyUser() {
  useQuery(GET_USER_WITH_ID, { ssr: false })

  return <span>Мой запрос не будет запущен на сервере</span>
}

Работа с сетью

Основные возможности

Клиент имеет встроенную поддержку для взаимодействия с сервером поверх HTTP. Для этого достаточно передать настройку uri в конструктор:

import { ApolloClient, InMemoryCache } from '@apollo/client'

const client = new ApolloClient({
  uri: 'https://api.example.com',
  cache: new InMemoryCache()
})

Настройка credentials используется для передачи на сервер полномочий пользователя (данных для аутентификации, куки и т.д.):

import { ApolloClient, InMemoryCache } from '@apollo/client'

const client = new ApolloClient({
  uri: 'https://api.example.com',
  cache: new InMemoryCache(),
  // разрешаем отправку куки в другой источник
  credentials: 'include'
})

Возможные значения credentials:

  • same-origin - полномочия передаются только если сервер находится в том же источнике, что и клиент (значение по умолчанию)
  • omit - полномочия не передаются
  • include - полномочия передаются всегда, даже между разными источниками

Настройка headers позволяет отправлять на сервер кастомные заголовки:

import { ApolloClient, InMemoryCache } from '@apollo/client'

const client = new ApolloClient({
  uri: 'https://api.example.com',
  cache: new InMemoryCache(),
  headers: {
    // хранить токены в локальном хранилище небезопасно
    authorization: localStorage.getItem('token'),
    'client-name': 'WidgetX Ecom [web]',
    'client-version': '1.0.0'
  }
})

Продвинутые возможности

Библиотека Apollo Link позволяет кастомизировать запросы, отправляемые на сервер, и ответы, полученные от него.

При использовании Apollo Link поведение сети определяется в виде коллекции объектов link (ссылка), которые выполняются последовательно, управляя потоком данных. По умолчанию Клиент использует HttpLink из Apollo Link для отправки запросов поверх HTTP.

Кастомизация логики отправки запросов

В следующем примере мы используем ссылку, которая добавляет заголовок Authorization в каждый запрос перед его отправкой с помощью HttpLink:

import { ApolloClient, HttpLink, ApolloLink, InMemoryCache } from '@apollo/client'

const httpLink = new HttpLink({ uri: '/graphql' })

const authMiddleware = new ApolloLink((operation, forward) => {
  // добавляем заголовок
  operation.setContext(({ headers = {} }) => ({
    headers: {
      ...headers,
      // хранить токены в локальном хранилище можно только при определенных условиях
      authorization: localStorage.getItem('token') || null
    }
  }))

  return forward(operation)
})

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: concat(authMiddleware, httpLink)
})

В следующем примере мы используем несколько ссылок, передавая их в виде массива:

import { ApolloClient, HttpLink, ApolloLink, InMemoryCache } from '@apollo/client'

const httpLink = new HttpLink({ uri: '/graphql' })

const authMiddleware = new ApolloLink((operation, forward) => {
  // добавляем заголовок авторизации
  operation.setContext(({ headers = {} }) => ({
    headers: {
      ...headers,
      authorization: localStorage.getItem('token') || null
    }
  }))

  return forward(operation)
})

const activityMiddleware = new ApolloLink((operation, forward) => {
  // добавляем заголовок последней активности
  operation.setContext(({ headers = {} }) => ({
    headers: {
      ...headers,
      'recent-activity': localStorage.getItem('lastOnlineTime') || null
    }
  }))

  return forward(operation)
})

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: from([
    authMiddleware,
    activityMiddleware,
    httpLink
  ])
})

В данном случае ссылка authMiddleware устанавливает каждому запросу заголовок Authorization, ссылка activityMiddleware устанавливает каждому запросу заголовок Recent-Activity, а ссылка httpLink отправляет модифицированный запрос.

Кастомизация логики получения ответа

Кастомные ссылки могут использоваться для добавления, модификации или удаления полей из response.data. Для этого следует вызвать map на результате вызова forward(operation):

import { ApolloClient, InMemoryCache, HttpLink, ApolloLink } from '@apollo/client'

const httpLink = new HttpLink({ uri: '/graphql' })

const addDateLink = new ApolloLink((operation, forward) => {
  return forward(operation).map(response => {
    response.data.date = new Date()
    return response
  })
})

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: addDateLink.concat(httpLink)
})

В данном случае addDateLink добавляет поле date в каждый ответ.

Обратите внимание, что forward(operation).map() не поддерживает выполнение асинхронных операций. Для таких операций следует использовать функцию asyncMap() из @apollo/client/utilities:

import {
  ApolloClient,
  InMemoryCache,
  HttpLink,
  ApolloLink
} from "@apollo/client"
import { asyncMap } from "@apollo/client/utilities"

import { usdToEur } from './currency'

const httpLink = new HttpLink({ uri: '/graphql' })

const usdToEurLink = new ApolloLink((operation, forward) => {
  return asyncMap(forward(operation), async (response) => {
    let data = response.data
    if (data.price && data.currency === "USD") {
      data.price = await usdToEur(data.price)
      data.currency = "EUR"
    }
    return response
  })
})

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: usdToEurLink.concat(httpLink)
})

В примере usdToEurLink использует asyncMap для преобразования значения поля price из долларов США в евро с помощью внешнего API.

Объект HttpLink

Данный объект используется для отправки на сервер запросов поверх HTTP.

Использование

import { HttpLink } from "@apollo/client"

const link = new HttpLink({ uri: "/graphql" })

Настройки

  • uri - конечная точка в виде строки или функция, разрешающаяся адресом сервера для отправки запросов (по умолчанию /graphql)
  • includeExtensions - значение true позволяет отправлять на сервер поле extensions (по умолчанию false)
  • fetch - совместимое с fetch API для отправки запросов
  • headers - объект с заголовками
  • credentials - строка, определяющая политику включения полномочий в запросы
  • fetchOptions - используется для перезаписи значений некоторых настроек, передаваемых в вызов fetch
  • useGETForQueries - если true, HttpLink использует GET-запросы вместо POST-запросов для выполнения запросов (но не мутаций) (по умолчанию false)

Большинство настроек конструктора может быть перезаписано посредством модификации объекта context.

Настройки контекста

  • headers
  • credentials
  • uri
  • fetchOptions
  • response - необработанный ответ после выполнения fetch
  • http - объект, позволяющий контролировать различные аспекты HttpLink, такие как сохранение запросов

Пример использования context для установки кастомного заголовка для определенного запроса:

import { ApolloClient, InMemoryCache } from "@apollo/client"

const client = new ApolloClient({
  cache: new InMemoryCache(),
  uri: "/graphql"
})

client.query({
  query: MY_QUERY,
  context: {
    headers: {
      special: "Special header value"
    }
  }
})

Кастомизация выполнения запросов

Настройка fetch может быть использована для кастомизации выполнения запросов. Это может быть полезным при необходимости модификации запроса на основе заголовков или вычисления URI на основе операции.

Кастомная аутентификация

const customFetch = (uri, options) => {
  const { header } = Hawk.client.header(
    "http://example.com:8000/resource/1?b=1&a=2",
    "POST",
    { credentials: credentials, ext: "some-app-data" }
  )
  options.headers.Authorization = header
  return fetch(uri, options)
}

const link = new HttpLink({ fetch: customFetch })

Динамический URI

const customFetch = (uri, options) => {
  const { operationName } = JSON.parse(options.body)
  return fetch(`${uri}/graph/graphql?opname=${operationName}`, options)
}

const link = new HttpLink({ fetch: customFetch })

Использование других ссылок

Apollo Link содержит много ссылок для особых случаев. Среди них можно назвать WebSocketLink для взаимодействия с сервером через веб-сокеты, BatchHttpLink для объединения нескольких операций в один запрос и др.

Аутентификация

Куки

Настройка credentials позволяет управлять передачей куки между браузером и сервером. Настройка credentials: 'same-origin' позволяет передавать куки в пределах одного источника, а настройка credentials: 'include' - между разными источниками.

const link = createHttpLink({
  uri: '/graphql',
  credentials: 'same-origin'
})

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link
})

Заголовок

Другим способом идентификации пользователя является использование HTTP-заголовока Authorization:

import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'

const httpLink = createHttpLink({
  uri: '/graphql'
})

const authLink = setContext((_, { headers }) => {
  // получаем токен из локального хранилища
  const token = localStorage.getItem('token')
  // записываем заголовки в контекст, чтобы ссылка (link) могла их прочитать
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : ""
    }
  }
})

const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache()
})

Сервер может использовать данный заголовок для аутентификации пользователя и добавления его в контекст, чтобы поведение резолверов определялось на основе роли и разрешений пользователя.

Сброс хранилища при выходе пользователя из системы

Поскольку Клиент кеширует результаты запросов, важно избавляться от них при изменении состояния авторизации.

Вызов метода client.resetStore() приводит к очистке хранилища и повторному выполнению активных запросов. Метод client.clearStore() позволяет очистить хранилище без повторной отправки запросов. Перезагрузка страницы имеет такой же эффект.

const PROFILE_QUERY = gql`
  query CurrentUserForLayout {
    currentUser {
      login
      avatar_url
    }
  }
`

function Profile() {
  const { client, loading, data: { currentUser } } = useQuery(
    PROFILE_QUERY,
    { fetchPolicy: "network-only" }
  )

  if (loading) {
    return <p className="navbar-text navbar-right">Загрузка...</p>
  }

  if (currentUser) {
    return (
      <span>
        <p className="navbar-text navbar-right">
          {currentUser.login}
          &nbsp;
          <button
            onClick={() => {
              // вызываем метод для выхода из системы и очищаем хранилище
              App.logout().then(() => client.resetStore())
            }}
          >
            Выйти из системы
          </button>
        </p>
      </span>
    )
  }

  return (
    <p className="navbar-text navbar-right">
      <a href="/login/github">Войти с помощью GitHub</a>
    </p>
  )
}