Управление состоянием в Angular 2 Apps с помощью ngrx / store было рецензировано Себастьяном Зейтцем , Марком Брауном и Вильданом Софтиком . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!
Компоненты, которые мы создаем для наших веб-приложений, часто содержат состояние. Соединение компонентов может привести к совместному использованию изменяемого состояния: это сложно управлять и приводит к несогласованности. Что если у нас есть одно место, где мы изменяем состояние и позволяем сообщениям делать все остальное? ngrx / store — это реализация Redux для Angular, использующая RxJS, которая привносит этот мощный паттерн в мир Angular.
В этой статье я расскажу о проблеме общего изменяемого состояния и покажу, как вы можете решить эту проблему с помощью библиотеки ngrx / store, чтобы привнести одностороннюю архитектуру потока данных в ваши приложения Angular 2. Попутно мы создадим пример приложения, которое позволит пользователю искать видео с помощью API YouTube.
Примечание. Вы можете найти код, сопровождающий эту статью, в этом репозитории GitHub .
Проблема с параллелизмом
Создание компонентов, которые взаимодействуют друг с другом, является типичной задачей с участием государства. Нам часто приходится идти в ногу со временем, когда разные компоненты Angular взаимодействуют с одним и тем же состоянием: когда несколько компонентов обращаются к этому состоянию и изменяют его, мы называем его разделяемым изменяемым состоянием .
Чтобы понять, почему общее изменяемое состояние представляет проблему, подумайте о компьютере, который используется двумя разными пользователями. Однажды первый пользователь обновит операционную систему до последней версии. Второй пользователь включает компьютер один день спустя и озадачен, поскольку интерфейс пользователя изменился без видимой причины. Это произошло потому, что два пользователя могли изменять один и тот же объект (в данном случае компьютер), не разговаривая друг с другом.
Общее изменяемое состояние на практике
Типичным примером общего состояния является набор свойств выполняемого нами действия. Если мы выполняем поиск в базе данных, мы называем этот набор функций текущим поиском . Отныне я буду называть такой набор поисковым объектом .
Представьте себе страницу, которая позволяет вам искать что-то по имени, а также предлагает возможность ограничить поиск по географическому положению. На этой странице будет как минимум два разных компонента, которые могут изменять текущие свойства поиска. Скорее всего, будет служба, отвечающая за фактический поиск.
Правила будут:
- если поле имени пустое, очистить результаты поиска
- если определено только имя, выполните поиск по имени
- если указаны имя и местоположение, выполните поиск по имени и местоположению
- для поиска по местоположению необходимо указать координаты (широта / долгота) и радиус
Доступные подходы
Одним из способов решения проблемы общего изменяемого состояния может быть переадресация объекта поиска назад и вперед между компонентами и службой, позволяя каждому изменять его.
Это повлечет за собой более подробное и сложное тестирование, которое отнимает много времени и подвержено ошибкам: для каждого теста вам нужно будет смоделировать объект, изменив только некоторые свойства, чтобы протестировать только определенное поведение. Все эти тесты и макеты также должны быть сохранены.
Кроме того, каждый компонент, взаимодействующий с состоянием, должен будет разместить логику для этого. Это ухудшает возможность повторного использования компонентов и нарушает принцип СУХОГО .
Альтернативный подход заключается в инкапсуляции объекта поиска в сервис и предоставлении базового API для изменения значений поиска. Тем не менее, служба будет отвечать за три разные вещи:
- выполняя поиск
- сохраняя состояние последовательным
- применение правил параметров
Довольно далеко от принципа единой ответственности , сервис теперь стал самим приложением и не может быть легко использован повторно.
Даже разделение этого сервиса на более мелкие сервисы все равно приведет к тому, что у нас будут разные сервисы или компоненты, модифицирующие одни и те же данные.
Кроме того, компоненты потребляют услугу, поэтому они не могут использоваться без услуги.
Другой и часто используемый шаблон состоит в том, чтобы поместить всю логику на прикладной уровень, но мы все равно получили бы большое количество кода, отвечающего за обеспечение согласованности состояний.
Мое мнение таково, что прикладной уровень, который является настоящей отличительной чертой, должен применять только правила. Другие задачи, а именно передача сообщений, хранение и события, могут выполняться инфраструктурой.
Подход Redux
Этот подход основан на модели архитектуры приложений Flux, разработанной Facebook в последние годы, и на архитектуре Elm .
Этот шаблон также доступен разработчикам AngularJS в нескольких реализациях. В этом руководстве мы будем использовать ngrx / store, поскольку он является частью пакета ngrx
, который является официальной оболочкой Angular 2 для Reactive Extensions . Кроме того, он реализует шаблон Redux с Observables , таким образом, оставаясь совместимым с архитектурой Angular 2.
Как это работает?
- компоненты испускают действия
- действия отправляются в государственный магазин
- функции редуктора выводят новое состояние на основе этих действий
- подписчики уведомляются о новом состоянии
Таким образом, мы можем разделить обязанности, поскольку ngrx / store заботится о согласованности состояний, а RxJS передает шину сообщений.
- Наши компоненты не будут знать об услугах или логике приложения: они просто излучают действия.
- Наш сервис не имеет состояния: он просто выполняет поиск по поисковому объекту, поступающему извне.
- Наш прикладной компонент просто слушает изменения состояния и решает, что делать.
- Новая запись, редуктор, будет фактически реагировать на действия, изменяя состояние при необходимости.
- Одна точка входа для мутаций.
Пример: компонент поиска YouTube
Мы напишем небольшое приложение для поиска видео с помощью API YouTube. Вы можете увидеть финальную демо-версию ниже:
Клонирование стартового репо
Клонируйте стартовый релиз репозитория. В папке app/
мы найдем фактические файлы приложения, где мы будем работать:
project ├── app │ ├── app.module.ts │ ├── app.component.ts │ └── main.ts ├── index.html ├── LICENSE ├── package.json ├── README.md ├── systemjs.config.js ├── tsconfig.json └── typings.json
Теперь в папке app
мы создаем две папки с именами models
и components
. Первое, что нам нужно определить, это модели, которые будут использоваться.
Определение моделей
Учитывая, что требуется поисковый запрос, нам нужно решить, как его представить. Это позволит искать по имени и местоположению .
/** app/models/search-query.model.ts **/ export interface CurrentSearch { name: string; location?: { latitude: number, longitude: number }, radius: number }
Поскольку местоположение будет опцией, оно определяется как необязательное свойство объекта поиска.
Представление результатов поиска также потребуется. Это будет включать в себя идентификатор видео, заголовок и миниатюру, так как именно это будет показано в пользовательском интерфейсе.
/** app/models/search-result.model.ts*/ export interface SearchResult { id: string; title: string; thumbnailUrl: string; }
Компонент окна поиска
Первый параметр поиска — «по имени», поэтому необходимо создать компонент, который будет:
- показать текстовый ввод
- отправлять действие каждый раз, когда текст изменяется
Давайте создадим новый файл в app/components
с определением компонента:
/** app/components/search-box.component.ts **/ @Component({ selector: 'search-box', template: ` <input type="text" class="form-control" placeholder="Search" autofocus> ` })
Компонент также должен отменить действие на полсекунды, чтобы избежать одновременного запуска нескольких действий:
export class SearchBox implements OnInit { static StoreEvents = { text: 'SearchBox:TEXT_CHANGED' }; @Input() store: Store<any>; constructor(private el: ElementRef) {} ngOnInit(): void { Observable.fromEvent(this.el.nativeElement, 'keyup') .map((e: any) => e.target.value) .debounceTime(500) .subscribe((text: string) => this.store.dispatch({ type: SearchBox.StoreEvents.text, payload: { text: text } }) ); } }
Это можно разбить следующим образом: чтобы получить Observable
из события DOM, вспомогательная функция Observable.fromEvent(HTMLNode, string)
используется для преобразования набора в поток строк, который затем обрабатывается с помощью инструментария RxJS.
Обратите внимание на определение store
в качестве входных данных. Он представляет наш диспетчер для доставки действия. Компонент не будет знать о потребителе, процессе поиска или услуге; он просто обрабатывает входную строку и отправляет ее.
Обратите внимание на то, как используется диспетчер: его подпись — dispatch(action: Action): void
где Action
— это объект с обязательным полем type
(строка) и необязательной payload
. Поскольку типом действия является string
, я предпочитаю определять их как константы внутри компонента с надлежащим пространством имен, чтобы любой потребитель этого действия просто импортировал и сопоставлял их.
Компонент Proximity Selector
Второй тип управления поиском — «по географическому положению», в котором указываются координаты широты и долготы. Поэтому нам нужен компонент, который будет:
- показать флажок, чтобы включить локализацию
- отправлять действие каждый раз при изменении локализации
- показать диапазон ввода для радиуса
- отправлять действие каждый раз при изменении радиуса
Логика все та же: показать вход, запустить действие.
/** app/components/proximity-selector.component.ts **/ @Component({ selector: 'proximity-selector', template: ` <div class="input-group"> <label for="useLocation">Use current location</label> <input type="checkbox" [disabled]="disabled" (change)="onLocation($event)"> </div> <div class="input-group"> <label for="locationRadius">Radius</label> <input type="range" min="1" max="100" value="50" [disabled]="!active" (change)="onRadius($event)"> </div> ` })
Это очень похоже на предыдущий компонент окна поиска. Тем не менее, шаблон отличается, так как теперь должны отображаться два разных входа. Кроме того, мы хотим, чтобы радиус был отключен, если местоположение отключено.
Вот реализация:
/** app/components/proximity-selector.component.ts **/ export class ProximitySelector { static StoreEvents = { position: 'ProximitySelector:POSITION', radius: 'ProximitySelector:RADIUS', off: 'ProximitySelector:OFF' }; @Input() store: Store<any>; active = false; // put here the event handlers }
Теперь два обработчика событий требуют реализации. Сначала флажок будет обработан:
/** app/components/proximity-selector.component.ts **/ export class ProximitySelector { // ... onLocation($event: any) { this.active = $event.target.checked; if (this.active) { navigator.geolocation.getCurrentPosition((position: any) => { this.store.dispatch({ type: ProximitySelector.StoreEvents.position, payload: { position: { latitude: position.coords.latitude, longitude: position.coords.longitude } } }); }); } else { this.store.dispatch({ type: ProximitySelector.StoreEvents.off, payload: {} }); } } }
Первый необходимый шаг — определить, включена ли локализация:
- если он включен, текущая позиция будет отправлена
- если он выключен, соответствующее сообщение будет отправлено
На этот раз используется обратный вызов, поскольку данные представляют собой не поток чисел, а отдельное событие.
Наконец, добавляется обработчик для радиуса, просто отправляя новое значение независимо от статуса местоположения, поскольку у нас работает атрибут disabled
.
/** app/components/proximity-selector.component.ts **/ export class ProximitySelector { // ... onRadius($event: any) { const radius = parseInt($event.target.value, 10); this.store.dispatch({ type: ProximitySelector.StoreEvents.radius, payload: { radius: radius } }); } }
Редуктор
Это вместе с диспетчером является ядром новой системы. Редуктор — это функция, которая обрабатывает действие и текущее состояние для создания нового состояния.
Важным свойством редукторов является то, что они компонуются, что позволяет нам разделять логику между различными функциями, сохраняя при этом состояние атомарным. Из-за этого они должны быть чистыми функциями : другими словами, они не имеют побочных эффектов.
Это дает нам еще одно важное следствие: тестирование чистой функции тривиально, поскольку при одинаковом вводе будет получен одинаковый результат.
Необходимый нам редуктор будет обрабатывать действия, определенные в компонентах, возвращая новое состояние для приложения. Вот графическое объяснение:
Редуктор должен быть создан в новом файле, в app/reducers/
:
/** app/components/search.reducer.ts **/ export const SearchReducer: ActionReducer<CurrentSearch> = (state: CurrentSearch, action: Action) => { switch (action.type) { // put here the next case statements // first define the default behavior default: return state; } };
Первое действие, которое мы должны выполнить, это бездействие: если действие не влияет на состояние, редуктор вернет его без изменений. Это очень важно, чтобы не сломать модель.
Далее мы обрабатываем действие по изменению текста:
/** app/components/search.reducer.ts **/ switch (action.type) { case SearchBox.StoreEvents.text: return Object.assign({}, state, { name: action.payload.text }); // ... }
Если действие является действием, предоставляемым компонентом SearchBox
, мы знаем, что полезная нагрузка содержит новый текст. Поэтому нам нужно изменить только text
поле объекта state
.
Согласно лучшим практикам , мы не изменяем состояние, а создаем новое и возвращаем его.
Наконец, выполняются действия, связанные с локализацией:
- для
ProximitySelector.StoreEvents.position
нам нужно обновить значения позиции - для
ProximitySelector.StoreEvents.radius
нам нужно обновить только значение радиуса - если сообщение
ProximitySelector.StoreEvents.off
мы просто устанавливаем и позицию, и радиус наnull
/** app/components/search.reducer.ts **/ switch (action.type) { case ProximitySelector.StoreEvents.position: return Object.assign({}, state, { location: { latitude: action.payload.position.latitude, longitude: action.payload.position.longitude } }); case ProximitySelector.StoreEvents.radius: return Object.assign({}, state, { radius: action.payload.radius }); case ProximitySelector.StoreEvents.off: return Object.assign({}, state, { location: null }); // ... }
Проводить все это вместе
На данный момент, у нас есть два компонента диспетчерских действий и редуктор для обработки сообщений. Следующий шаг — подключить все элементы и проверить их.
Сначала давайте импортируем новые компоненты в модуль app/app.module.ts
:
/** app/app.module.ts **/ import {ProximitySelector} from "./components/proximity-selector.component"; import {SearchBox} from "./components/search-box.component"; import {SearchReducer} from "./reducers/search.reducer"; // the rest of app component
Затем мы модифицируем метаданные модуля, чтобы включить SearchBox
и ProximitySelector
качестве директив:
/** app/app.module.ts **/ @NgModule({ // ... other dependencies declarations: [ AppComponent, SearchBox, ProximitySelector ], // ... })
Затем нам нужно предоставить магазин, который позаботится о диспетчерских действиях и запустит редукторы против состояния и действий. Это можно создать с provideStore
функции StoreModule
модуля StoreModule
. Мы передаем объект с названием магазина и редуктором, обрабатывающим его.
/** app/app.module.ts **/ // before the @Component definition const storeManager = provideStore({ currentSearch: SearchReducer });
Теперь мы помещаем менеджера магазина в список поставщиков:
/** app/app.module.ts **/ @NgModule({ imports: [ BrowserModule, HttpModule, StoreModule, storeManager ], // ... })
Наконец, но очень важно, нам нужно поместить компоненты в наш шаблон, передав их store
в качестве входных данных:
/** app/app.component.ts **/ @Component({ // ...same as before template: ` <h1>{{title}}</h1> <div class="row"> <search-box [store]="store"></search-box> <proximity-selector [store]="store"></proximity-selector> </div> <p>{{ state | json }}</p> ` })
Класс должен быть обновлен, чтобы соответствовать новому шаблону:
/** app/app.component.ts **/ export class AppComponent implements OnInit { private state: CurrentSearch; private currentSearch: Observable<CurrentSearch>; constructor( private store: Store<CurrentSearch> ) { this.currentSearch = this.store.select<CurrentSearch>('currentSearch'); } ngOnInit() { this.currentSearch.subscribe((state: CurrentSearch) => { this.state = state; }); } }
Здесь мы определили частное свойство, которое представляет состояние для предоставления (для пользовательского интерфейса). Служба хранилища вставляется в наш конструктор и используется для получения экземпляра currentSearch
. Интерфейс OnInit
используется, чтобы получить хук для фазы инициализации, позволяя компоненту подписаться на обновления состояния, используя экземпляр магазина.
Что дальше?
Теперь можно реализовать простой сервис, который принимает CurrentSearch
и вызывает внутренний API (например, YouTube ), как в живом примере . Можно изменить сервис, не меняя ни одной строки компонентов или реализации приложения.
Кроме того, ngrx
не ограничивается хранилищем: доступно несколько инструментов, таких как effects
и selectors
, чтобы справиться с более сложными сценариями, такими как обработка асинхронных HTTP-запросов.
Вывод
В этом уроке мы увидели, как реализовать Redux-подобный поток в Angular 2, используя ngrx / store и RxJs .
Суть в том, что поскольку мутации являются корнем многих проблем, их размещение в одном контролируемом месте поможет нам написать более понятный код. Наши компоненты не связаны с логикой, а детали их поведения не известны приложению.
Стоит отметить, что мы использовали шаблон, отличный от того, который показан в официальной документации ngrx , поскольку компоненты отправляют действия напрямую, без использования событий и дополнительного уровня интеллектуальных компонентов . Дискуссия о лучших практиках все еще развивается.
Вы уже попробовали ngrx или предпочитаете Redux? Я хотел бы услышать ваши мысли!