Relay для Angular

Всем привет! Сегодня я представлю вашему вниманию 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 в действии.

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

Relay и Angular в действии

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи lorenzo di giacomo: Relay for Angular