Около 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% случаев хватает за глаза.
Примечание для тех, кто считает, что такой подход больше не Angular-way
Несколько лет назад на одной из конференций общался по этому поводу с Игорем Минаром — ключевым разработчиком Ангуляра. Он поддерживает такой подход, но задача не приоритетная и у команды просто нет ресурсов на написание подобной библиотеки. К сожалению, он так и не прочитал мое сообщение с ссылкой на этот проект.
Теперь посмотрим как выглядит 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(messagesSelector)
this.isLoading = this.chatResource.state.pipe(isLoadingSelector)
this.isError = this.chatResource.state.pipe(isErrorSelector)
но мне такой подход не нравится. Во-первых, в шаблоне появляется логика не относящаяся к отображению, что нарушает парадигму MCV, во-вторых, люди с React/Vue бэкграундом или бэкендщики, которым придется читать этот код, вам точно спасибо не скажут (привет ребятам из Тинькова и другим, кто так делает). Мемоизация, которую мы получаем в этом случае, зачастую не дает ощутимого выигрыша в скорости, да и реализовать ее можно в другом месте. Тем более вы сами выбрали Ангуляр своим фреймворком, так что не жалуйтесь.
Итоги
В общем, это и вся демонстрация. На первый взгляд такой подход к проектированию приложений кажется чересчур гибким и приносящим хаос в проект. На деле всё наоборот. Разработчики в большинстве случаев просто пишут на промисах, как показано в начале статьи и в ус не дуют. Лишь когда задача становится действительно сложной, то решается как ее разрулить на событиях, нужен ли стор и прочее. А так как всё наше взаимодействие с сервером (и не только) унифицировано, то переход из одного стиля программирования в другой не представляет труда. Переписывание логики страницы со сложной формой с десятками контролов с промисов на события у меня занял день. Сложно представить сколько дополнительного времени ушло бы, если бы я сразу использовал события или редьюсеры со стором. Простые вещи все же лучше писать просто.
Никогда не рассматривал этот инструмент как что-то долгоживущее, но минуло пять лет, а воз и ныне там. За это время появился NgRX, появился MobX и что-то ещё, но чего-то кардинально лучшего, к сожалению не придумали. Или я об этом не знаю (поделитесь в комментариях). Поэтому и написал эту статью.
Эта незамысловатая штука лежит на Гитхабе, можете поиграться. Документация там так себе — извиняйте. Работает надежно, но я не проводил всеобъемлющего тестирования, особенно методов, которыми не пользовался в жизни; да и некоторые штуки типа адаптера для Вебсокетов написаны «чтобы было».