Всем привет! Сегодня я представлю вашему вниманию relay-angular, молодую инновационную библиотеку, которая доказала свою стабильность в процессе создания библиотек react-relay-offline и relay-hooks.
Признаюсь, что не являюсь большим поклонником Angular, но появление Relay позволило мне оценить многие его аспекты и осознать, что они отлично сочетаются. Предлагаю начать с краткого описания трех основных компонентов.
1. Angular — создание производительных и прогрессивных приложений
Angular — это платформа и фреймворк для построения одностраничных клиентских приложений при помощи HTML и TypeScript. Написан он на TypeScript и реализует как основную, так и настраиваемую функциональность в виде набора библиотек TS, которые вы импортируете в свои приложения.
2. Relay — подготовленный к производственной среде клиент GraphQL
Relay — это JS-фреймворк для создания ориентированных на данные приложений React на основе GraphQL. В его основе заложена простота использования, расширяемость и, что самое важное, производительность. Relay реализует все это посредством статических запросов и предварительной генерации кода.
Relay Modern состоит из трех основных модулей и одного плагина babel:
- Компилятор Relay: оптимизирующий компилятор GraphQL, предоставляющий общие утилиты для преобразования и оптимизации запросов, а также генерации артефактов сборки. Его новаторская особенность заключается в упрощении работы с настраиваемыми директивами, недавно введенными в GraphQL. По сути она позволяет легко переводить использующий такие директивы код в стандарт, соответствующий спецификации GraphQL.
- Среда выполнения Relay: полнофункциональная высокопроизводительная среда GraphQL, которую можно использовать для сборки высокоуровневых клиентских API. К ее возможностям относятся нормализованный кэш объектов, оптимизированные операции чтения и записи, общее абстрагирование для инкрементно увеличивающегося запроса данных полей (например, при разбивке на страницы), сбор мусора для удаления неиспользуемых записей кэша, оптимистические мутации с произвольной логикой, поддержка сборки подписок и живых запросов, а также многое другое.
- React/Relay: высокоуровневый API продукта, интегрирующий Relay Runtime с React. Это главный публичный интерфейс Relay для большинства разработчиков продуктов, который содержит API для получения данных для запроса или определения зависимостей данных для переиспользуемых компонентов (контейнеров).
- Плагин Babel: служит для преобразования GraphQL в артефакты среды выполнения.
Обратите внимание, что эти модули слабосвязанные. Например, компилятор генерирует представления запросов в четком формате, который используется средой выполнения так, что в результате реализация компилятора может быть при желании заменена. React/Relay опирается только на хорошо документированный публичный интерфейс среды выполнения, в связи с чем действительная реализация и может быть заменена (фактически, мы улучшили классическое ядро Relay, дополнительно реализовав тот же API). Команда Relay надеется, что такая слабая связь позволит сообществу находить новые варианты применения, например разрабатывать с помощью Relay Runtime специализированные API продуктов или интегрировать эту среду и в другие библиотеки представления помимо React.
3. Relay Angular — подготовленный к производственной среде GraphQL клиент для Angular
Теперь я готов подтвердить, что эта слабая связь позволила мне легко и уверенно интегрировать библиотеку в Angular.
Для этого я создал новый корневой модуль, замещающий relay/react. Помимо этого, он оказался необходим для создания нового плагина, так как Angular официально Babel не поддерживает.
- Relay Angular: высокоуровневый API продукта, интегрирующий среду выполнения Relay с Angular.
- Плагин Relay Angular: является эквивалентом плагина Babel для Relay, но построен с помощью библиотеки ngx-build-plus, позволяющей расширять предустановленное поведение Angular CLI без сброса.
Начало
Сначала давайте установим необходимые пакеты:
relay-angular
при помощиyarn
илиnpm
:
yarn add relay-angular relay-runtime
relay-angular-plugin
иrelay-compiler
с помощьюyarn
илиnpm
:
yarn add relay-angular-plugin ngx-build-plus relay-compiler relay-config
1. Настройка компилятора Relay
По этой ссылке можно найти официальную документацию по настройке компилятора Relay (англ.)
Пример файла конфигурации Relay relay.config.js:
module.exports = {
// ...
// Настройки конфигурации, принятые инструментом командной строки `relay-compiler`, `babel-plugin-relay` и`relay-angular-plugin`
src: './src',
schema: '../server/data/schema.graphql',
language: 'typescript',
artifactDirectory: './src/__generated__/relay/',
};
Настройка ngx-build-plus
Доступна по следующей ссылке (англ.)
- angular.json
Измените builder
на сервис и сборку:
{
"build": {
"builder": "ngx-build-plus:browser",
...
},
"serve": {
"builder": "ngx-build-plus:dev-server",
...
}
}
Настройка package.json
"scripts": {
...
"build": "ng build --plugin relay-angular-plugin",
"start": "ng serve --plugin relay-angular-plugin",
"compile": "relay-compiler"
...
}
2. Настройка Relay Runtime
Здесь вы найдете официальную документацию по ее настройке (англ.).
3. Подключение Relay Runtime к Angular
Подключение осуществляется с помощью поставщика RelayProvider
. Он обертывает приложение Angular и помещает среду в контекст, что позволяет обращаться к ней откуда угодно.
RelayProvider
RelayProvider
получает environment
и устанавливает ее в контекст.
Пример использования в app.module.ts:
import { RelayProvider } from 'relay-angular';
import { Environment, Network, RecordSource, Store } from 'relay-runtime';
async function fetchQuery(operation, variables, cacheConfig, uploadables) {
const response = await fetch('http://localhost:3000/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: operation.text,
variables,
}),
});
return response.json();
}
const modernEnvironment: Environment = new Environment({
network: Network.create(fetchQuery),
store: new Store(new RecordSource()),
});
@NgModule({
declarations: [
...
],
imports: [
...
],
providers: [[RelayProvider(modernEnvironment)]],
bootstrap: [AppComponent]
})
export class AppModule {
}
Если вам нужно изменить среду в приложении, примените EnvironmentContext — расширение BehaviorSubject от rxjs.
Пример использования в app.component.ts:
import { Component } from '@angular/core';
import { EnvironmentContext } from 'relay-angular';
import EnvironmentError from '../relay/errorRelay';
import EnvironmentRight from '../relay/relay';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
constructor(private environmentContext: EnvironmentContext) {}
// Обрабатываем нажатие кнопки
handleRightEnv() {
this.environmentContext.next(EnvironmentRight);
}
// Обрабатываем нажатие кнопки
handleWrongEnv() {
this.environmentContext.next(EnvironmentError);
}
}
4. Relay в качестве декоратора Angular
@Query
Он применяется для получения GraphQL-запроса. Он не использует environment
в качестве аргумента — он считывает набор environment
в контексте. В дополнение к первому аргументу query
и второму variables
@query
принимает третий аргумент options
.
Аргументы:
query:
размеченный запросgraphql
. К сведению:relay-compiler
именует запрос как<FileName>Query
. При желании этот запрос можно не указывать, тогда будет возвращен пустой объектprops
.variables:
объект, содержащий набор переменных для передачи в GraphQL-запросе, т.е. отображения из имени переменной в значение. К сведению: в случае передачи нового набора переменных,@Query
получит запрос повторно.options:
описаны ниже.
Аргументы опций:
fetchPolicy:
определяет, нужно ли использовать данные, кэшированные в хранилище Relay, и нужно ли отправлять сетевой запрос. Варианты могут быть следующие:
store-or-network
(по умолчанию): повторно использовать данные, кэшированные в хранилище. Если весь запрос кэширован, пропустить сетевой запрос.store-and-network
: повторно использовать данные, кэшированные в хранилище. Всегда отправлять сетевой запрос.network-only
: не использовать кэшированные данные повторно. Всегда отправлять сетевой запрос. Это предустановленное поведение декоратора@Query
.store-only
: повторно использовать кэшированные данные. Никогда не отправлять сетевой запрос.
fetchKey
: [не обязателен] его можно передать для принудительного повторного получения текущего запроса и переменных при повторной отрисовке компонента, даже если переменные не менялись, или компонент не монтировался повторно (аналогично тому, как передача другого ключа в компонент React вызовет его повторное монтирование). Если fetchKey
отличается от используемого в предыдущей отрисовке, текущий запрос и переменные будут получены повторно.
networkCacheConfig
: [не обязателен] объект, содержащий опции настройки кэша для сетевого слоя. К сведению: сетевой слой может содержать дополнительный кэш ответа на запрос, который будет повторять сетевые ответы для идентичных запросов. Если вы хотите полностью обойти этот кэш, передайте для этой опции значение {force: true}
.
skip
: [не обязателен] если skip
установлен как true
, запрос будет полностью пропущен.
Возвращаемые значения:
props
: объект, содержащий данные, полученные из запроса. Форма этого объекта будет совпадать с формой запроса. Если данный объект не определен, значит данные находятся в процессе получения.error
: определяется, если при получении запроса происходит ошибка.retry
: функция, повторно загружающая данные.
Пример использования:
query.component.ts:
import { Component, Input } from '@angular/core';
import { graphql } from 'relay-runtime';
import { Query, RenderProps } from 'relay-angular';
import { todoQueryQuery } from '../../__generated__/relay/todoQueryQuery.graphql';
export const QueryApp = graphql`
query todoQueryQuery($userId: String) {
user(id: $userId) {
id
...todoApp_user
}
}
`;
@Component({
selector: 'todo-query',
templateUrl: './todo-query.component.html',
styleUrls: ['./todo-query.component.css'],
})
export class TodoQueryComponent {
@Input()
userId;
@Query<todoQueryQuery>(function() {
return {
query: QueryApp,
variables: { userId: this.userId },
};
})
result: RenderProps<todoQueryQuery>;
}
query.component.html:
<todo-app *ngIf="result && result.props && result.props.user; else loading"
[fragmentRef]="result.props.user">
{{result}}
</todo-app>
<ng-template #loading>
<div *ngIf="!result.error; else error">
Loading...</div>
</ng-template>
<ng-template #error>
<div>
Error {{ result.error }}
</div>
</ng-template>
@Fragment
@fragment
позволяет компонентам указывать свои требования к данным. Контейнер не получает данные напрямую, вместо этого он объявляет спецификацию необходимых для отрисовки данных, после чего Relay обеспечивает доступ этих данных до выполнения отрисовки.
Данный декоратор автоматически подписывается на обновления фрагментов данных. Если данные для конкретного User
обновятся в любом месте приложения (например, после получения новых данных или изменения существующих), этот компонент автоматически выполнит отрисовку повторно в соответствии с ними.
Аргументы:
fragment
: GraphQL-фрагмент, определенный при помощи шаблонного литералаgraphql
.fragmentReference
: непрозрачный объект Relay, с помощью которого из хранилища считываются данные для фрагмента. Если говорить точнее, то он содержит информацию о том, из какого именно экземпляра объекта нужно считать данные. Тип этой ссылки можно импортировать из набора сгенерированных типов Flow/TypeScript, находящихся в файле<fragment_name>.graphql.js
. После этого его можно использовать для объявления типа вашихProps
. Имя типаfragment reference
будет выглядеть так:<fragment_name>$key
.
Возвращаемое значение:
data
: объект с данными, считанными из хранилища Relay. По форме он совпадает с указанным фрагментом.
Пример использования:
fragment.component.ts:
import { Component, Input } from '@angular/core';
import { Fragment } from 'relay-angular';
import { graphql } from 'relay-runtime';
import { todoListItem_todo$key, todoListItem_todo$data } from '../../__generated__/relay/todoListItem_todo.graphql';
const fragmentNode = graphql`
fragment todoListItem_todo on Todo {
complete
id
text
}
`;
@Component({
selector: 'app-todo-list-item',
templateUrl: './todo-list-item.component.html',
styleUrls: ['./todo-list-item.component.css'],
})
export class TodoListItemComponent {
@Input()
fragmentRef: todoListItem_todo$key;
@Fragment<todoListItem_todo$key>(function() {
return {
fragmentNode,
fragmentRef: this.fragmentRef,
};
})
todo: todoListItem_todo$data;
}
fragment.component.html:
<div class="view">
<input class="toggle" type="checkbox" (click)="toggleTodoComplete()" [checked]="todo.complete">
<label>{{todo.text}}</label>
<button class="destroy" (click)="removeTodo(todo)"></button>
</div>
@Refetch
Этот декоратор можно использовать, когда нужно получить и повторно отрисовать фрагмент с новыми данными.
Аргументы:
Такие же, как для @fragment
.
Возвращаемое значение:
data
: тот же объект, что и в случае с@fragment
, но уже с ключомrefetch
, являющимся функцией для повторного получения фрагмента с потенциально новым набором переменных.
Пример использования:
refetch.component.ts:
import { Component, Input } from '@angular/core';
import { Refetch, RefetchDecorator } from 'relay-angular';
import { graphql } from 'relay-runtime';
import { todoListFooter_user$data } from '../../__generated__/relay/todoListFooter_user.graphql';
import { QueryApp } from '../todo-query/todo-query.component';
const fragmentNode = graphql`
fragment todoListFooter_user on User {
id
userId
completedCount
todos(
first: 2147483647 # max GraphQLInt
) @connection(key: "TodoList_todos") {
edges {
node {
id
complete
}
}
}
totalCount
}
`;
@Component({
selector: 'app-todo-list-footer',
templateUrl: './todo-list-footer.component.html',
styleUrls: ['./todo-list-footer.component.css'],
})
export class TodoListFooterComponent {
@Input()
fragmentRef: any;
@Refetch((_this) => ({
fragmentNode,
fragmentRef: _this.fragmentRef,
}))
data: RefetchDecorator<todoListFooter_user$data>;
// Обрабатываем нажатие кнопки
handleRefresh() {
const { refetch, userId } = this.data;
refetch(QueryApp, {
userId,
});
}
}
refetch.component.html:
<footer class="footer">
<span class="todo-count">
<strong>{{data.totalCount - data.completedCount}}</strong>
{{data.totalCount - data.completedCount == 1 ? 'item' : 'items'}} left
</span>
<button
class="clear-completed"
(click)="handleRefresh($event)">
Refresh
</button>
</footer>
@Pagination
Этот декоратор применяется для отрисовки фрагмента, использующего @connection
, и выполнения для него разбивки на страницы.
Аргументы:
Те же самые, что и для useFragment
.
Возвращаемое значение:
data
: тот же объект, что и@fragment
, но с добавлением ключейloadMore
,hasMore
,isLoading
,refetchConnection
hasMore
Эта функция указывает, есть ли на сервере дополнительные страницы для получения.
hasMore: (connectionConfig?: ConnectionConfig) => boolean,
Аргументы:
connectionConfig
[необязателен]:
direction
: либоforward
для указания прямой нумерации страниц при помощиafter/first
, либо для указания обратной при помощиbefore/last
. Если не определен, Relay выведет направление на основе переданной директивы@connection
.getConnectionFromProps
: функция, указывающая, какое подключение нужно разбивать на страницы, учитываяprops
фрагмента (т.е.props
, соответствующиеfragmentSpec
). Это нужно в большинстве случаев, так как Relay не может автоматически понять, какое подключение вы хотите разбить на страницы (контейнер может получать несколько фрагментов и подключений, но разбивку на страницы может выполнять только для одного из них). Если функция не передана, Relay постарается распознать требующее разбивки на страницы подключение на основе переданной директивы@connection
.getFragmentVariables
: функция, возвращающая набор переменных, используемых для чтения данных из хранилища при повторной отрисовке компонента. Она получает предыдущий набор переменных, переданный дляquery
@Pagination
, а также общее количество элементов, полученных на данный момент. Если эту функцию не указать, Relay по умолчанию использует все предыдущие переменные и общее число для переменнойcount
.getVariables
: функция, возвращающая переменные для передачи вquery
@Paginatio
при их получении с сервера на основе текущихprops
,count
иcursor
. Здесь можно указывать любые переменные, а также изменять установки по умолчанию, чтобы использовать аргументыafter/first/before/last
.query
: размеченный запросgraphql
, используемый в качестве запроса на разбивку страниц для получения дополнительных данных при вызовеloadMore
.
isLoading
Функция, указывающая, продолжается ли выполнение предыдущего вызова loadMore()
. Это помогает избежать повторных вызовов загрузки.
isLoading: () => boolean,
loadMore
loadMore()
можно вызвать для получения дополнительных элементов с сервера на основе переданной в контейнер connectionConfig
. В случае отсутствия дополнительных элементов для загрузки она вернет null
, в противном случае она получит оставшиеся элементы и вернет Disposable
, который можно будет использовать для отмены получения.
loadMore(
connectionConfig: ConnectionConfig,
pageSize: number,
callback: ?(error: ?Error) => void,
options?: RefetchOptions
): ?Disposable
Аргументы:
connectionConfig
[необходимый]: тот же, что и функцияhasMore
pageSize
: количество дополнительных элементов для получения (не общее).callback
: функция, вызываемая при получении новой страницы. Если в процессе повторного запроса произошла ошибка, эта функция получит ошибку в виде аргумента.options
: необязательный объект, содержащий набор опций.force
: если сетевой слой настроен на использование кэша, эта опция будет обеспечивать повторное получение данных и переменных для запроса независимо от их наличия в кэше.
refetchConnection
Вы можете вызвать refetchConnection
для перезапуска нумерации страниц подключения с начала, при желании используя для передачи в query
разбивки на страницы полностью новый набор переменных. Это полезно, например, если вы делаете разбивку на страницы коллекции на основе userID
, который изменяется. В этом случае вам понадобится выполнить нумерацию страниц новой коллекции для нового пользователя:
refetchConnection:(
connectionConfig: ConnectionConfig,
totalCount: number,
callback: (error: ?Error) => void,
refetchVariables: ?Variables,
) => ?Disposable,
Аргументы:
connectionConfig
[необходимый]: аналогичен функцииhasMore
.totalCount
: общее число элементов для получения.callback
: функция, вызываемая при получении новой страницы. Если в процессе получения произойдет ошибка, эта функция получит данную ошибку в виде аргумента.refetchVariables
: потенциально новый набор переменных для передачи вquery
пагинации при его получении с сервера.
Пример использования:
pagination.component.ts:
import { Component, Input } from '@angular/core';
import { Pagination, PaginationDecorator } from 'relay-angular';
import { graphql } from 'relay-runtime';
import { todoListFooter_user$data } from '../../__generated__/relay/todoListFooter_user.graphql';
import { QueryApp } from '../todo-query/todo-query.component';
const fragmentSpec = graphql`
fragment todoListFooter_user on User {
id
idUser
todos(idUser: $idUser, first: $first, after: $after)
@connection(key: "TodoList_todos", filters: ["idUser"]) {
nextToken
edges {
node {
id
complete
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
const connectionConfig = {
query: QueryApp,
getVariables: (props, paginationInfo) => ({
first: 2,
after: paginationInfo.cursor,
idUser: props.idUser
})
};
@Component({
selector: 'app-todo-list-footer',
templateUrl: './todo-list-footer.component.html',
styleUrls: ['./todo-list-footer.component.css'],
})
export class TodoListFooterComponent {
@Input()
fragmentRef: any;
@Pagination((_this) => ({
fragmentNode,
fragmentRef: _this.fragmentRef,
}))
data: PaginationDecorator<todoListFooter_user$data>;
// Обрабатываем нажатие кнопки
handleLoadMore() {
const { hasMore, isLoading, loadMore } = this.data;
hasMore() && !isLoading() && loadMore(connectionConfig, 2, () => null, undefined)
}
}
pagination.component.html:
<footer class="footer">
...
<button
class="clear-completed"
(click)="handleLoadMore()">
Load More
</button>
</footer>
@RelayEnvironment
Этот декоратор служит для доступа к environment
Relay, установленной RelayProvider
:
todo-app.component.ts:
<footer class="footer">
...
<button
class="clear-completed"
(click)="handleLoadMore()">
Load More
</button>
</footer>
4. Мутация
Функция mutate
служит для создания и выполнения мутаций. Сигнатура же у нее следующая:
Аргументы:
options:
описаны ниже.
Аргументы опций:
mutation
: размеченный запрос мутацииgraphql
.variables
: объект, содержащий необходимые для мутации переменные. Например, если мутация определяет переменную$input
, этот объект должен содержать ключinput
, форма которого, согласно GraphQL-схеме, должна совпадать с формой данных, ожидаемых мутацией.onCompleted
: функция обратного вызова, выполняемая при завершении запроса и обновлении функциейupdater
размещенного в памяти хранилища Relay. Получает объектresponse
, представляющий обновленный ответ из хранилища, иerrors
— массив, содержащий ошибки сервера.onError
: функция обратного вызова, выполняемая, если Relay сталкивается во время запроса с ошибкой.optimisticResponse
: объект, содержащий данные для оптимистического обновления локального хранилища в памяти, т.е. сразу до завершения запроса мутации. Этот объект, согласно GraphQL-схеме, должен иметь ту же форму, что и тип ответа мутации. Если его указать, Relay будет использовать данныеoptimisticResponse
для обновления полей соответствующих записей в локальном хранилище данных до выполненияoptimisticUpdater
. Если в процессе запроса мутации произойдет ошибка, оптимистическое обновление будет отменено.optimisticUpdater
: функция, используемая для оптимистического обновления локального хранилища в памяти, т.е. сразу до завершения запроса мутации. Если в процессе этого запроса произойдет ошибка, оптимистическое обновление будет отменено. Эта функция получаетstore
, представляющее прокси-сервер расположенного в памяти Relay Store. В этой функции клиент определяет, как обновлять локальные данные через экземплярstore
. Обратите внимание: обычно лучше просто передавать опциюoptimisticResponse
вместоoptimisticUpdater
, если только вам не нужно выполнять обновления локальных записей, что сложнее простого обновления полей (например, при удалении записей или добавлении элементов в коллекции). Если вы решите использоватьoptimisticUpdater
, то зачастую он будет аналогичен функцииupdater
.updater
: функция, используемая для обновления локального хранилища в памяти на основе реального ответа сервера из мутации. Еслиupdater
не указана, то по умолчанию Relay будет автоматически обновлять поля записей, к которым ведут ссылки в ответе мутации. Тем не менееupdater
следует указать, если требуется сделать более сложные обновления, чем обновления полей. При возвращении ответа сервера Relay сначала отменяет любые изменения, произведенныеoptimisticUpdater
илиoptimisticResponse
, а затем выполняетupdater
. Эта функция получаетstore
, представляющее прокси-сервер Relay Store в памяти. В ней клиент определяет, как обновлять локальные данные на основе ответа сервера через экземплярstore
.configs
: массив объектов, описывающих конфигурацииoptimisticUpdater
/updater
.configs
предоставляет удобный способ указания поведенияupdater
без написания функцииupdater
.cacheConfig?
: необязательный объект, содержащий набор опций конфигурации кэша.
Пример использования:
changeTodoStatus.ts:
import { mutate } from 'relay-angular';
import { graphql } from 'relay-runtime';
export const mutation = graphql`
mutation changeTodoStatusMutation($input: ChangeTodoStatusInput!) {
changeTodoStatus(input: $input) {
todo {
id
complete
}
user {
id
completedCount
}
}
}
`;
function commit(complete: boolean, todo: any, user: any): any {
const input: any = {
complete,
userId: user.userId,
id: todo.id,
};
return mutate({
mutation,
variables: {
input,
},
});
}
export default { commit };
todo-list-item.component.ts:
import { Component } from '@angular/core';
import changeTodoStatus from '../mutations/changeTodoStatus';
@Component({
selector: 'app-todo-list-item',
templateUrl: './todo-list-item.component.html',
styleUrls: ['./todo-list-item.component.css'],
})
export class TodoListItemComponent {
// Обрабатываем кнопку
toggleTodoComplete(todo, user) {
changeTodoStatus.commit(!todo.complete, todo, user);
}
}
Выводы:
Рассмотренная библиотека упрощает управление данными, предлагает высокое удобство в разработке и имеет открытый исходный код.
Если вы дочитали до этого момента, вам наверняка будет интересно увидеть тандем relay-angular в действии.
Здесь вы сможете найти образец проекта и поделиться своими мыслями по его поводу.
Читайте также:
- Как создать полезную офлайн-страницу для веб-приложения
- Создайте собственный AdBlocker за 10 минут
- Создание предметно-ориентированных микросервисов
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи lorenzo di giacomo: Relay for Angular