Angular Resource или почему я никогда не использовал NgRX

от автора

Около 5 лет назад я пересел с Реакта на второй Ангуляр и первое, чего мне там не хватило был модуль angular-resource из первого Ангуляра. Вменяемых аналогов я не нашел, поэтому за неделю написал свою библиотеку. Решение оказалось настолько удачным, что практически без изменений дошло до сегодняшнего дня. Используется в куче проектов, работает стабильно (не смотря на то, что до сих пор там нет ни одного теста), в общем, есть о чем рассказать.

Промисы наше всё

Пойдем от простого к сложному. Чаще всего в коде можно увидеть такое использование:

import { Component } from '@angular/core'; import { UsersResource } from './_resources/users.resource';  @Component({   selector: 'app-root',   templateUrl: './app.component.html' }) export class AppComponent {   users = []      constructor(private usersResource: UsersResource) {}    loadUsers() {     this.usersResource.query().then(users => {       this.users = users;     });   } }

Вспоминается старый добрый первый Ангуляр. Значение получается в нативном всем понятном промисе и присваивается свойству компонента. Вся эта история с RxJS, который команда Ангуляра пытается нам продать, мне изначально показалась сомнительной, поэтому по-умолчанию работаем с промисами, которых в 90% случаев хватает за глаза.

Теперь посмотрим как выглядит UsersResource. Если приложение работает с грамотным REST API, то большинство ресурсов будут выглядеть так:

import { Injectable } from '@angular/core'; import { ApiResource, HttpConfig } from './_resources/api.resource';  @Injectable() @HttpConfig({   url: '/users/:id', }) export class UsersResource extends ApiResource {}

Далее посмотрим что из себя представляет ApiResource. Там больше кода, но и пишется он обычно один раз в начале проекта.

import { Injectable } from '@angular/core'; import { ReactiveResource } from '@angular-resource/core'; import { HttpConfig, Get, Post, Put, Patch, Delete } from '@angular-resource/http';  @Injectable() @HttpConfig({   host: 'http://127.0.0.1',   headers: {},   withCredentials: true,   transformResponse(response, options) {     if (Array.isArray(response?.data) && options.isArray) {       return response.data     }     return response   } }) export class ApiResource extends ReactiveResource {   query = Get({ isArray: true })   get = Get()   create = Post()   update = Patch()   replace = Put()   delete = Delete() } export from '@angular-resource/core' export from '@angular-resource/http'

Видим, что работает обычное наследование. Причем декораторы тоже наследуются (в UsersResource мы расширили наш конфиг параметром url). Таким образом можем расширить любой наш ресурс дополнительным методом и/или конфигурацией. Очень удобно.

import { Injectable } from '@angular/core'; import { ApiResource, HttpConfig, Get } from './_resources/api.resource';  @Injectable() @HttpConfig({   url: '/users/:id', }) export class UsersResource extends ApiResource {   getMeta = Get({ url: '/users/meta-data' }) }

RxJS иногда тоже полезен

Итак, у нас есть модуль для работы с REST API, как в первом Ангуляре. Такое себе достижение конечно и не стал бы писать статью только ради этого, так что идем дальше. Все ресурсы наследуются от некого класса ReactiveResource, который содержит под капотом шину событий, построенную на основе ReplaySubject с сохранением последнего состояния. Это позволяет делать интересные вещи. Например, мы можем легко переписать наш код на событийный манер:

import { Component } from '@angular/core'; import { UsersResource } from './_resources/users.resource';  @Component({   selector: 'app-root',   templateUrl: './app.component.html' })  export class AppComponent {   users = []      constructor(private usersResource: UsersResource) {}    loadUsers() {     this.usersResource.query()     this.usersResource.actions.subscribe(action => {       if (action.type === 'query') {         this.users = action.payload       }     });      // или используя синтаксический сахар     this.usersResource.action('query').subscribe(payload => {       this.users = payload     });      // и даже можем послать своё событие в поток ресурса     this.usersResource.action('query').next([])   } }

Здесь, как и завещала команда Ангуляра, мы работаем с http-запросами как с потоками данных. Легко комбинируем их с реактивными формами и можем с помощью RxJS операторов разрулить ситуацию любой сложности.

До этого мы работали с http-запросами, но раз уж эта вундервафля основана на шине событий, то кто мешает работать с Вебсокетами, например? Правильно, никто. Мы сами можем написать какие угодно декораторы-адаптеры. Из коробки помимо HttpConfig доступны WebSocketConfig, SocketIoConfig и LocalStorageConfig, так что давайте напишем чат. Создадим новый ресурс:

import { Injectable } from '@angular/core'; import { ReactiveResource } from '@angular-resource/core'; import { HttpConfig, Get } from '@angular-resource/http'; import { SocketIoConfig, CloseSocketIo, OpenSocketIo, SendSocketIoEvent } from '@angular-resource/socket-io';  @Injectable() @HttpConfig({   host: 'http://127.0.0.1:3000',   url: '/messages/:id' }) @SocketIoConfig({   url: 'ws://127.0.0.1:3000' }) export class ChatResource extends ReactiveResource {   getMessages = Get()   connect = OpenSocketIo()   disconnect = CloseSocketIo()   sendMessage = SendSocketIoEvent('sendMessage') }

Компонент примет следующий вид:

import { Component } from '@angular/core'; import { ChatResource } from './_resources/chat.resource';  @Component({   selector: 'app-root',   templateUrl: './app.component.html' }) export class AppComponent {   messages = []    constructor(private chatResource: ChatResource) {     this.chatResource.getMessages()       .then(messages => {         this.messages = messages       })       .catch(error => {         console.log('HTTP error', error)       })      this.chatResource.connect()       .catch(error => {         console.log('WS error', error)       })      // Предполагаем, что SocketIO-сервер шлет все новые сообщения (в т.ч. наше) в событии 'newMessage'      this.chatResource.action('newMessage').subscribe(message => {       this.messages.push(message)     })   }    sendMessage() {     this.chatResource.sendMessage({ text: 'My message' })       .then(() => {         console.log('Message was sended by SocketIO')       });   } }

Здесь мы загружаем историю сообщений HTTP-запросом, а дальше взаимодействуем с сервером через Вебсокеты и все это в одном ресурсе. 

В заголовке статьи было что-то про NgRX

И раз уж пошла такая пьянка, давайте напишем какой-нибудь примитивный счетчик (догадываетесь к чему клоню?). Для этого есть особый декоратор StateConfig. Мы же храним внутри ReplaySubject последнее состояние ресурса, почему бы это не использовать?

import { Injectable } from '@angular/core'; import { ReactiveResource, StateConfig } from '@angular-resource/core';  @Injectable() @StateConfig({   initialState: {     counter: 0,     updatedAt: 0   },   updateState: (state, action) => {     // Reducer     if (action.error) {       return state     }     switch (action.type) {       case 'increase':         return {...state, counter: state.counter + action.payload}                case 'decrease':         return {...state, counter: state.counter - action.payload}        case 'updateAt':         return {...state, updatedAt: action.payload}        default:         return state     }   } })  export class CounterStore extends ReactiveResource {   // Actions   increase = (num)  => this.action('increase').next(num)   decrease = (num)  => this.action('decrease').next(num)   updateAt = (date) => this.action('updateAt').next(date) }

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

Взглянем на код компонента:

import { Injectable } from '@angular/core'; import { CounterStore } from './_resources/counter.store';  @Component({   selector: 'app-root',   templateUrl: './app.component.html' }) export class AppComponent {   counter = 0   updatedAt = 0    constructor(private counterStore: CounterStore) {     this.counterStore.state.subscribe(state => {       this.counter = state.counter       this.updatedAt = state.updatedAt     });      // Effects     this.counterStore.action('increase', 'decrease').subscribe(payload => {       this.counterStore.updateAt(Date.now())     });   }    increase(num) {     this.counterStore.increase(num)   }    decrease(num) {     this.counterStore.decrease(num)   } }

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

this.counterStore.state.subscribe(state => {   this.counter = state.counter   this.updatedAt = state.updatedAt   this.counterStore.updateAt(Date.now()) });

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

Используем все возможности

Соберем в кучу наши знания и перепишем чат, добавив туда некоторые плюшки. ChatResource примет такой вид:

import { Injectable } from '@angular/core'; import { ReactiveResource, StateConfig } from '@angular-resource/core'; import { HttpConfig, Get } from '@angular-resource/http'; import { SocketIoConfig, CloseSocketIo, OpenSocketIo, SendSocketIoEvent } from '@angular-resource/socket-io';  @Injectable() @HttpConfig({   host: 'http://127.0.0.1:3000',   url: '/messages/:id' }) @SocketIoConfig({   url: 'ws://127.0.0.1:3000' }) @StateConfig({   initialState: {     messages: [],     isLoading: false,     isError: false   },   updateState: (state, action) => {     switch (action.type) {       case 'getMessages:start':         return {...state, isLoading: true}        case 'getMessages':         return !action.error           ? {...state, messages: action.payload, isError: false, isLoading: false}           : {...state, isError: true, isLoading: false}                case 'newMessage':         return {...state, messages: [...state.messages, action.payload]};        case 'connect':         return {...state, isError: !!action.error}        default:         return state;     }   } }) export class ChatResource extends ReactiveResource {   getMessages = Get();   connect = OpenSocketIo();   disconnect = CloseSocketIo();   sendMessage = SendSocketIoEvent('sendMessage');      constructor() {     super()      this.error('getMessages').subscribe(error => {       setTimeout(() => {         console.log('HTTP reconnect...')         this.getMessages()       }, 5000)     })     this.error('connect').subscribe(error => {       setTimeout(() => {         console.log('WS reconnect...')         this.connect()       }, 7000)     })   } }

Думаю, вы уже догадались, что название событий берутся из названий методов в ресурсах. Если предполагается отложенное получение данных, то инициализирующее событие будет иметь суффикс :start

Теперь вся логика хранится в ресурсе и компонент будет ожидаемо маленьким:

import { Injectable } from '@angular/core'; import { ChatResource } from './_resources/chat.resource';  @Component({   selector: 'app-root',   templateUrl: './app.component.html' }) export class AppComponent {   messages = []   isLoading = false   isError = false    constructor(private chatResource: ChatResource) {     this.chatResource.state.subscribe(state => {       this.messages  = state.messages       this.isLoading = state.isLoading       this.isError   = state.isError     });     this.chatResource.connect()     this.chatResource.getMessages()   }    sendMessage() {     this.chatResource.sendMessage({ text: 'My message' });   } }

В принципе, можно было бы переписать компонент в ангуляровском стиле, а в шаблоне использовать | async:

this.messages  = this.chatResource.state.pipe(messageSelector) this.isLoading = this.chatResource.state.pipe(isLoadingSelector) this.isError   = this.chatResource.state.pipe(isErrorSelector)

но мне такой подход не нравится. Во-первых, в шаблоне появляется логика не относящаяся к отображению, что нарушает парадигму MCV, во-вторых, люди с React/Vue бэкграундом или бэкендщики, которым придется читать этот код, вам точно спасибо не скажут (привет ребятам из Тинькова и другим, кто так делает). Мемоизация, которую мы получаем в этом случае, зачастую не дает ощутимого выигрыша в скорости, да и реализовать ее можно в другом месте. Тем более вы сами выбрали Ангуляр своим фреймворком, так что не жалуйтесь.

Итоги

В общем, это и вся демонстрация. На первый взгляд такой подход к проектированию приложений кажется чересчур гибким и приносящим хаос в проект. На деле всё наоборот. Разработчики в большинстве случаев просто пишут на промисах, как показано в начале статьи и в ус не дуют. Лишь когда задача становится действительно сложной, то решается как ее разрулить на событиях, нужен ли стор и прочее. А так как всё наше взаимодействие с сервером (и не только) унифицировано, то переход из одного стиля программирования в другой не представляет труда. Переписывание логики страницы со сложной формой с десятками контролов с промисов на события у меня занял день. Сложно представить сколько дополнительного времени ушло бы, если бы я сразу использовал события или редьюсеры со стором. Простые вещи все же лучше писать просто.

Никогда не рассматривал этот инструмент как что-то долгоживущее, но минуло пять лет, а воз и ныне там. За это время появился NgRX, появился MobX и что-то ещё, но чего-то кардинально лучшего, к сожалению не придумали. Или я об этом не знаю (поделитесь в комментариях). Поэтому и написал эту статью.

Эта незамысловатая штука лежит на Гитхабе https://github.com/tamtakoe/oi-angular-resource, можете поиграться. Документация там так себе — извиняйте. Работает надежно, но я не проводил всеобъемлющего тестирования, особенно методов, которыми не пользовался в жизни; да и некоторые штуки типа адаптера для Вебсокетов написаны «чтобы было».


ссылка на оригинал статьи https://habr.com/ru/articles/752398/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *