В предыдущей статье («Angular Components with Extracted Immutable State») я показал, почему изменение полей компонентов без каких-либо ограничений - это не всегда хорошо, а также представил библиотеку, которая позволяет упорядочить изменения состояния компонентов.

С тех пор я немного изменил её концепцию и упростил использование. На этот раз я сосредоточусь на простом (на первый взгляд) примере того, как eё можно использовать в сценариях, где обычно потребовался бы rxJS.

Основная Идея

Есть неизменяемый объект, который представляет все значения полей некоторого компонента:

Каждый раз, когда какое-либо поле (или несколько значений полей) компонента изменяется, то создается новый неизменяемый объект, содержащий комбинацию старых и новых значений:

Сравнивая эти два объекта, можно определить какие именно поля изменились, и если есть логические зависимости от этих полей, то тогда выполнится соответствующее вычисление значений этих зависимостей. После того как это вычисление завершено, будет создан 3-й объект, содержащий как исходные значения 2-го объекта, так и вновь вычисленные:

Теперь у нас есть новый объект, который также можно сравнить с предыдущим, и при необходимости можно будет вычислить новые зависимости. Этот сценарий может быть повторен много раз до тех пор, пока мы не получим полностью согласованный объект, после чего Angular отобразит эти данные:

Простая Форма Приветствия

Давайте создадим простую форму приветствия (исходный код на stackblitz):

simple-greeting-form.component.ts

@Component({
  selector: 'app-simple-greeting-form',
  templateUrl: './simple-greeting-form.component.html'
})
export class SimpleGreetingFormComponent {
  userName: string;
  greeting:  string;
}

simple-greeting-form.component.html

<div class="form-root">  
  <h1>Greeting Form</h1>
  <label for="ni">Name</label><br />
  <input [(ngModel)]="userName" id="ni" />
  <h1>{{greeting}}</h1>
</div>

Очевидно, что поле greeting зависит от поля userName, и существует несколько способов выразить эту зависимость:

  1. Преобразовать greeting в свойство с геттером, но в этом случае его значение будет вычисляться в каждом цикле обнаружения изменений (change detection);

  2. Преобразовать userName в свойство с сеттером, который обновит значение поле greeting;

  3. Создать обработчик событийя ngModelChange, но это избыточно усложнит код;

Эти способы будут работать, но если какое-то другое поле зависит от приветствия (greeting, «greeting counter») или greeting зависит от нескольких полей (например, greeting = f (userName, template)), то ни один из этих методов не поможет, поэтому предлагается другой подход:

@Component(...)
@StateTracking()
export class SimpleGreetingFormComponent {
  userName: string;
  greeting:  string;

  @With("userName")
  public static greet(state: ComponentState<SimpleGreetingFormComponent>)
    : ComponentStateDiff<SimpleGreetingFormComponent>
  {
    const userName = state.userName === "" 
      ? "'Anonymous'" 
      : state.userName;

    return {
      greeting: `Hello, ${userName}!`
    }
  }
}

Для начала компонент должен быть отмечен декоратором @StateTracking или же в конструкторе должна быть вызвана функция initializeStateTracking (декораторы компонентов иногда работают некорректно в некоторых старых версиях Angular):

@Component(...)
export class SimpleGreetingFormComponent {
  userName: string;
  greeting:  string;
  
  constructor(){
    initializeStateTracking(this);
  }
}

Декоратор @StateTracking (или функция initializeStateTracking) находит все те поля компонента, от которых могут зависеть другие поля, и заменяет их свойствами с геттерами и сеттерами, так чтобы библиотека могла отслеживать изменения.

Далее определяем функцию перехода в новое состояние:

  ...
  @With("userName")
  public static greet(state: ComponentState<SimpleGreetingFormComponent>)
    : ComponentStateDiff<SimpleGreetingFormComponent>
  {
      ...
  }
  ...

Каждая функция перехода получает в виде аргумента объект, который представляет текущее состояние компонента, и эта функция должна вернуть объект, который будет содержать только обновленные поля. Это объект будет объединен с копией объекта текущего состояния, и эта новая модифицированная копия станет новым состоянием компонента.

При желании вы можете добавить второй аргумент, который получит объект предыдущего состояния.

Если же вы определите третий параметр, то он получит объект «разницы» между текущим и предыдущим состояниями:

@With("userName")
public static greet(
  state: ComponentState<SimpleGreetingFormComponent>,
  previous: ComponentState<SimpleGreetingFormComponent>,
  diff: ComponentStateDiff<SimpleGreetingFormComponent>
)
: ComponentStateDiff<SimpleGreetingFormComponent>
{
  ...
}

ComponentState и ComponentStateDiff — это прокси типы (Typescript mapped types), которые отфильтровывают методы и источники событий (event emitters). Также ComponentState отмечает все поля как “только для чтения” (ведь состояние неизменяемо (immutable)), а ComponentStateDiff отмечает все поля как необязательные, поскольку функция перехода может возвращать любое подмножество исходного состояния.

Для простоты определим алиасы этих типов:

type State = ComponentState<SimpleGreetingFormComponent>;
type NewState = ComponentStateDiff<SimpleGreetingFormComponent>;
...
  @With("userName")
  public static greet(state: State): NewState
  {
    ...
  }

Декоратор @With получает список имен полей, изменение значений которых вызовет соответствующий декорированный статический (!) метод класса компонента. Typescript проверит, что класс на самом деле содержит объявленные поля и что метод является статическим (функции переходов должны быть «чистыми» (pure)).

Логирование изменений

Теперь форма отображает соответствующее приветствие при любом изменении имени. Посмотрим, как меняется состояние компонента:

@Component(...)
@StateTracking<SimpleGreetingFormComponent>({
  onStateApplied: (c,s,p)=> c.onStateApplied(s,p)
})
export class SimpleGreetingFormComponent {
  userName: string;

  greeting:  string;

  private onStateApplied(current: State, previous: State){
    console.log("Transition:")
    console.log(`${JSON.stringify(previous)} =>`)
    console.log(`${JSON.stringify(current)}`)
  }

  @With("userName")
  public static greet(state: State): NewState
  {
      ...
  }  
}

onStateApplied — это “функция-перехватчик” (hook), которая вызывается каждый раз, когда состояние компонента становится согласованным - это означает, что все функции перехода были вызваны и больше никаких изменений не обнаружено:

Transition:
{} =>
{"userName":"B","greeting":"Hello, B!"}

Transition:
{"userName":"B","greeting":"Hello, B!"} =>
{"userName":"Bo","greeting":"Hello, Bo!"}

Transition:
{"userName":"Bo","greeting":"Hello, Bo!"} =>
{"userName":"Bob","greeting":"Hello, Bob!"}

Как мы видим, компонент переходит в новое состояние каждый раз, когда пользователь вводит следующий символ имени, и поле приветствия при этом немедленно обновляется. Если необходимо предотвратить обновление приветствия при каждом изменении имени, то это можно легко сделать, добавив расширение Debounce к декоратору @With:

@With("userName").Debounce(3000/*ms*/)
public static greet(state: State): NewState
{
    ...
}
...

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

Transition:
{} =>
{"userName":"B"}

Transition:
{"userName":"B"} =>
{"userName":"Bo"}

Transition:
{"userName":"Bo"} =>
{"userName":"Bob"}

Transition:
{"userName":"Bob"} =>
{"userName":"Bob","greeting":"Hello, Bob!"}

Добавим индикацию того, что форма находится в режиме ожидания:

...
export class SimpleGreetingFormComponent {
  userName: string;
  greeting:  string;
  isThinking:  boolean = false;

  ...

  @With("userName")
  public static onNameChanged(state: State): NewState{
    return{
      isThinking: true
    }
  }

  @With("userName").Debounce(3000/*ms*/)
  public static greet(state: State): NewState
  {
    const userName = state.userName === "" 
      ? "'Anonymous'" 
      : state.userName;

    return {
      greeting: `Hello, ${userName}!`,
      isThinking: false
    }
  }
}
...
<h1 *ngIf="!isThinking">{{greeting}}</h1>
<h1 *ngIf="isThinking">Thinking...</h1>
...

Кажется, что это работает, но есть проблема - если пользователь начал печатать, а затем решил вернуть исходное имя в течение заданных 3 секунд, то с точки зрения библиотеки поле greeting не изменилось, и функция перехода не будет вызвана, а форма будет показывать “Thinking…” до тех пор, пока вы не набираете другое имя. Это можно решить, добавив декоратор @Emitter() для поля userName:

@Emitter()
userName: string;

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

Однако есть и другое решение - когда форма перестает "думать", она может установить для userName значение null, и тогда пользователю придется начать вводить новое имя:

...
@With("userName")
public static onNameChanged(state: State): NewState{
  if(state.userName == null){
    return null;
  }

  return{
    isThinking: true
  }
}

@With("userName").Debounce(3000/*ms*/)
public static greet(state: State): NewState
{
  if(state.userName == null){
    return null;
  }
  
  const userName = state.userName === "" 
    ? "'Anonymous'" 
    : state.userName;

  return {
    greeting: `Hello, ${userName}!`,
    isThinking: false,
    userName: null
  }
}
...

А теперь давайте подумаем о ситуации, когда пользователь нетерпелив и хочет сразу получить результат. Что ж, позволим ему нажать [Enter] ((keydown.enter) = "onEnter ()"), чтобы немедленно получить приветствие:

...
userName: string | null;
immediateUserName: string | null;

onEnter(){
  this.immediateUserName = this.userName;
}
...
@With("userName")
public static onNameChanged(state: State): NewState{
  ...
}

@With("userName").Debounce(3000/*ms*/)
public static greet(state: State): NewState {
  ...
}

@With("immediateUserName")
public static onImmediateUserName(state: State): NewState{
  if(state.immediateUserName == null){
    return null;
  }

  const userName = state.immediateUserName === "" 
    ? "'Anonymous'" 
    : state.immediateUserName;

  return {
    greeting: `Hello, ${userName}!!!`,
    isThinking: false,
    userName: null,
    immediateUserName: null
  }
}
...

Ещё было бы неплохо узнать, сколько времени ждать, если пользователь не нажимает [Enter] - какой-нибудь счетчик обратного отсчёта был бы очень полезен:

<h1 *ngIf="isThinking">Thinking ({{countdown}} sec)...</h1>
...
countdown: number = 0;
...
@With("userName")
public static onNameChanged(state: State): NewState{
  if(state.userName == null){
    return null;
  }

  return{
    isThinking: true,
    countdown: 3
  }
}
...
@With("countdown").Debounce(1000/*ms*/)
public static countdownTick(state: State): NewState{
  if(state.countdown <= 0) {
    return null
  }

  return {countdown: state.countdown-1};
}

и вот как это выглядит:

Обратный отсчет также следует сбрасывать каждый раз, когда готово новое приветствие. Это предотвращает ситуацию, когда пользователь сразу нажимает [Enter], а обратный отсчет в этот момент остается 3 - после этого он перестает работать, так как его значение больше никогда не изменится. Для простоты сбросим все поля, зависящие от флага isThinking:

...
@With("isThinking")
static reset(state: State): NewState{
  if(!state.isThinking){
    return{
      userName: null,
      immediateUserName: null,
      countdown: 0
    };
  }
  return null;
}
...

Обнаружение Изменений (Change Detection)

Очевидно, что обратный отсчет работает асинхронно, и этот факт не вызывает никаких проблем с обнаружением изменений в Angular, пока стратегия обнаружения изменений - Default. Однако, если стратегия - OnPush, то ничто не может сообщить компоненту, что его состояние меняется во время обратного отсчета.

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

...
constructor(readonly changeDetector: ChangeDetectorRef){
}
...
private onStateApplied(current: State, previous: State){
  this.changeDetector.detectChanges();
  ...

Теперь он работает должным образом даже с OnPush стратегией обнаружения (Change Detection Strategy).

Исходящие Параметры (Output Properties)

Библиотека обнаруживает все источники событий (Event emitters) компонентов и вызывает их, когда поменялись значения привязанных к этим источникам полей. По умолчанию привязка выполняется с использованием суффикса Change в названиях источников событий:

greeting:  string;

@Output()
greetingChange = new EventEmitter<string>();

Распределенное Состояние

Обычно, когда компонент уничтожается (например, скрывается с помощью *ngIf), все ещё не завершенные асинхронные операции, инициированные этим компонентом, заканчиваются без каких либо значимых изменений. Однако библиотека позволяет выделить состояние компонента со всеми его переходами в отдельный объект, который может существовать независимо от компонента. Более того, такой объект может использоваться несколькими компонентами одновременно!

Давайте превратим компонент формы приветствия в сервис:

greeting-service.ts

@StateTracking({includeAllPredefinedFields:true})
export class GreetingService implements IGreetingServiceForm {
  userName: string | null = null;
  immediateUserName: string | null = null;
  greeting:  string = null;
  isThinking:  boolean = false;
  countdown: number = 0;

  @With("userName")
  static onNameChanged(state: State): NewState{
    ...
  }
  @With("userName").Debounce(3000/*ms*/)
  static greet(state: State): NewState
  {
    ...
  }
  @With("immediateUserName")
  static onImmediateUserName(state: State): NewState{
    ...
  }
  @With("countdown").Debounce(1000/*ms*/)
  static countdownTick(state: State): NewState{
    ...
  }
  @With("isThinking")
  static reset(state: State): NewState{
    ...
  }
}

и добавим его в провайдеры главного модуля.

includeAllPredefinedFields означает, что все поля с некоторым начальным значением (даже если оно null) будут автоматически включены в объекты состояния.

Чтобы использовать подобный сервис внутри компонента, нужно выполнить следующие действия:

  1. Внедрить экземпляр службы в компонент через dependency injection;

  2. Передать экземпляр сервиса в инициализатор трекера состояния;

  3. Отметить поля компонента, которые будут привязаны к соответствующим полям сервиса;

  4. Подписаться на изменения состояния сервиса - это необходимо, когда требуется явное обнаружение изменений или стратегия обнаружения изменений - OnPush.

После выполнения этих шагов компонент будет выглядеть так:

@Component({...
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ComplexGreetingFormComponent 
  implements OnDestroy, IGreetingServiceForm {

  private _subscription: ISharedStateChangeSubscription;

  @BindToShared()
  userName: string | null;

  @BindToShared()
  immediateUserName: string | null;

  @BindToShared()
  greeting:  string;

  @BindToShared()
  isThinking:  boolean = false;

  @BindToShared()
  countdown: number = 0;

  constructor(greetingService: GreetingService, cd: ChangeDetectorRef) {
    const handler = initializeStateTracking<ComplexGreetingFormComponent>(this,{
      sharedStateTracker: greetingService,
      onStateApplied: ()=>cd.detectChanges()
    });
    this._subscription = handler.subscribeSharedStateChange();
  }

  ngOnDestroy(){
    this._subscription.unsubscribe();
  }

  public onEnter(){
    this.immediateUserName = this.userName;
  }
}

Экземпляр службы передается в функцию initializeStateTracking (также возможно использование декоратора @StateTracking(), но это потребует больше усилий), которая возвращает объект с помощью методов которого можно управлять работой библиотеки в указанном компоненте.

Подписка (_subscription: ISharedStateChangeSubscription) требуется для вызова функции обратного вызова onStateApplied при изменении состояния сервиса или для вызова локальной функции перехода, которая зависит от поля (полей) сервиса. Если компонент использует стратегию обнаружения изменений Default и отсутствуют локальные функции перехода, то подписка не требуется.

Не забудьте отказаться от подписки при уничтожении компонента, дабы избежать утечек памяти. В качестве альтернативы вы можете вызвать функции handler.release() или releaseStateTracking(this), чтобы отказаться от подписок на компоненты, но эти методы также отменяют все незавершенные асинхронные операции, что не всегда желательно.

Составное Распределенное Состояние

Библиотека позволяет использовать общие состояния не только в компонентах, но и в других сервисах.

Давайте создадим сервис, который будет регистрировать все приветствия и имитировать их отправку на сервер:

export type LogItem = {
  id: number | null
  greeting: string,
  status: LogItemState,
}

@Injectable()
export class GreetingLogService implements IGreetingServiceLog, IGreetingServiceOutput {

  @BindToShared()
  greeting:  string;

  log: LogItem[] = [];

  logVersion: number = 0;

  identity: number = 0;

  pendingCount: number = 0;

  savingCount: number = 0;

  ...

  constructor(greetingService: GreetingService){
    const handler = initializeStateTracking(this,{
      sharedStateTracker: greetingService, 
      includeAllPredefinedFields: true});
      
    handler.subscribeSharedStateChange();    
  }

  ...
}

При каждом изменении значения поля greeting, это значение будет добавлено в массив журнала log. logVersion будет увеличиваться каждый раз при изменении массива, чтобы обеспечить обнаружение изменений, не делая при этом массив журнала неизменяемым:

...
@With("greeting")
static onNewGreeting(state: State): NewState{
    state.log.push({id: null, greeting: state.greeting, status: "pending"});

    return {logVersion: state.logVersion+1};
}
...

Сервис не сразу отправляет новые приветствия "на сервер", он будет ждать некоторое время, чтобы накопить сразу несколько изменений:

@With("logVersion")
static checkStatus(state: State): NewState{

  let pendingCount = state.pendingCount;

  for(const item of state.log){
    if(item.status === "pending"){
      pendingCount++;
    }
    else if(item.status === "saving"){
      savingCount++;
    }
  }

  return {pendingCount, savingCount};
}

@With("pendingCount").Debounce(2000/*ms*/)
static initSave(state: State): NewState{

  if(state.pendingCount< 1){
    return null;
  }

  for(const item of state.log){
    if(item.status === "pending"){
      item.status = "saving";
    }
  }

  return {logVersion: state.logVersion+1};
}

И наконец, собственно, сама “отправка на сервер”:

...
  @WithAsync("savingCount").OnConcurrentLaunchPutAfter()
  static async save(getState: ()=>State): Promise<NewState>{
      const initialState = getState();

      if(initialState.savingCount < 1){
        return null;
      }

      const savingBatch = initialState.log.filter(i=>i.status === "saving");

      await delayMs(2000);//Simulates sending data to server 

      const stateAfterSave = getState();

      let identity = stateAfterSave.identity;

      savingBatch.forEach(l=>{
        l.status = "saved",
        l.id = ++identity
      });

      return {
        logVersion: stateAfterSave.logVersion+1,
        identity: identity
      };      
  }
...

Эта функция перехода отличается от предыдущих тем, что она асинхронная:

  1. Она отмечена декоратором WithAsync вместо With;

  2. Декоратор имеет спецификацию поведения параллельного запуска (в данном случае OnConcurrentLaunchPutAfter);

  3. Вместо объекта текущего состояния он получает функцию, которая возвращает текущее состояние в момент вызова.

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


Мы только что рассмотрели пример пользовательского интерфейса с относительно сложным асинхронным поведением. Однако оказывается, что реализовать такое поведение не так уж и сложно, используя концепцию серии неизменяемых состояний. По крайней мере, это можно рассмотреть как альтернативу RxJs.


  1. Код статьи на stackblitz

  2. Ссылка на предыдущую статью: Angular Components with Extracted Immutable State

  3. Ссылка не исходный код ng-set-state