angular
February 16

Angular Signals

Сигналы - горячая тема для обсуждений среди community. Я попытался разобраться, как с ними стоит работать и вот что получилось. Получился некий свод правил, с которым можно согласиться или нет.

Не используйте сеттеры для входных данных, чтобы превращать их в сигналы

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

@Component({
  selector: 'app-my-component',
  template: `
    <div>
      {{ inputSignal() }}
    </div>
  `
})
export class SomeComponent {
  inputSignal = signal<string>();

  @Input() set input(value: string) {
    this.inputSignal.set(value);
  }

}

В таком случае получается много шаблонного кода. Хорошо это или плохо - каждый решает сам.

Используйте Signal Inputs

Начиная с Angular версии 17.1, появился новый способ объявления входных свойств для компонентов и директив: функция input. Эта функция объявляет вход, но его значение представляет собой сигнал, а не обычное свойство. Упростим наш пример:

@Component({
  selector: 'app-my-component',
  template: `
    <div>
      {{ inputSignal() }}
    </div>
  `
})
export class SomeComponent {
    inputSignal = input<string>('default value');
}

В итоге получается гораздо меньше шаблонного кода. У Signal Inputs есть еще одна возможность - required input.

export class SomeComponent {
  inputSignal = input.required<string>(); // зачение по умолчанию не нужно
}

Так же можно использовать преобразования:

export class SomeComponent {
  booleanSignal = input(true, {transform: booleanAttribute});
}

и псевдонимы:

export class SomeComponent {
  inputSignal = input.required({alias: 'condition'});
}

Предупреждение: сигнальные входы доступны только для чтения, и невозможно установить им другое значение в дочернем компоненте.

Не стоит выполнять HTTP запросы в эффектах

Это распространённая ошибка, которая может возникнуть в любом коде. Предположим, у нас есть входное значение в нашем шаблоне, и мы хотим сделать HTTP-запрос при изменении этого входного значения. Мы можем сделать следующее:

Component({
  selector: 'app-my-component',
  template: `
    <input (input)="query.set($event.target.value)" />
    <ul>
      @for (item of items) {
        <li>{{ item.name }}</li>
      }
    </ul>
  `
}) 
export class SomeComponent {
  query = signal<string>();
  items: Item[] = [];

    constructor(private http: HttpClient) {
        effect(() => {
            this.http.get<Item[]>(`/api/items?q=${this.query()}`).subscribe(items => {
                this.items = items;
            });
        });
    }
}

Такой подход таит в себе несколько проблем. Прежде всего, коллекция items не является сигналом (мы можем сделать её сигналом, но затем нам придётся установить {allowSignalWrites: true} на эффекте, что является ещё более плохой практикой), что затрудняет работу с отслеживанием изменений без зон в будущем. Кроме того, объявление items отдельно от места, где фактически устанавливается его значение, делает его немного сложнее в понимании; и, наконец, активация эффекта отделена от RxJS, что означает, что мы не можем полностью использовать мощь операторов с таймингом (например, debounceTime или switchMap для предотвращения лишних HTTP-запросов), так как каждое изменение сигнала запроса создаст отдельный Observable

Используйте toSignal + toObservable

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

@Component({
  selector: 'app-my-component',
  template: `
    <input (input)="query.set($event.target.value)" />
    <ul>
      @for (item of items()) {
        <li>{{ item.name }}</li>
      }
    </ul>
  `
})
export class SomeComponent {
  http = inject(HttpClient);
  query = signal<string>();
  items = toSignal(
    toObservable(this.query).pipe(
      debounceTime(500),
      switchMap(query => this.http.get<Item[]>(`/api/items?q=${query}`))
    ),
  );
}

В этом случае мы сначала переходим в мир RxJS с помощью toObservable, выполняем асинхронные операции там, а затем возвращаемся к сигналам с помощью toSignal. Таким образом, мы можем использовать всю мощь RxJS и в конечном итоге иметь сигнал, а кроме того, мы решаем проблему с таймингом множественных запросов при быстром вводе пользователем.

Рассмотрим более запутанный пример.

Не забывайте про Untracked

Давайте представим, что у нас есть страница `CompanyDetailsComponent`, на которой отображаются данные о компании, список её сотрудников и позволяется проводить общее редактирование, например, мы можем изменить описание компании. Сотрудников может быть много, поэтому мы хотим иметь возможность искать их по запросу, как в предыдущем примере. Однако мы ищем не среди всех сотрудников, а только среди тех, кто работает в данной компании, что означает, что каждый раз, когда мы ищем сотрудника, нам нужно использовать также и идентификатор компании. Мы можем сделать это следующим образом:

@Component({
  selector: 'app-company-details',
  template: `
    <div>
      <h2>{{ company().title }}</h2>
      <input placeholder="Company description" 
      [ngModel]="company().description" 
      (ngModelChange)="updateCompanyDescription($event)" />
    </div>
    <input (input)="query.set($event.target.value)" />
    <ul>
      @for (employee of companyEmployees()) {
        <li>{{ item.name }}</li>
      }
    </ul>
  `
})
export class CompanyDetailsComponent {
  http = inject(HttpClient);
  query = signal<string>();
  company = input.required<Company>();
  employees = input.required<Employee>();
  companyEmployees = computed(() => {
    return this.employees().filter(employee => employee.companyId === this.company().id && employee.name.includes(this.query()));
  });
  @Output() companyUpdated = new EventEmitter<Company>();

  updateCompanyDescription(description: string) {
    this.companyUpdated.emit({
      ...this.company(),
      description
    });
  }
}

Что здесь происходит:

  1. Компонент получает список всех сотрудников и данные о компании.
  2. Мы используем Computed Signal, чтобы получить список сотрудников, работающих в данной компании, и сопоставить его с запросом.
  3. Мы можем обновить описание компании и вызвать событие, когда это происходит, чтобы родительский компонент выполнил необходимые HTTP-запросы.
  4. Мы используем вычисляемый сигнал в шаблоне.

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

companyEmployees = computed(() => {
  return this.employees().filter(employee => employee.companyId === untracked(this.company).id && employee.name.includes(this.query()));
});

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

Не используйте toSignal в сервисах

Многие приложения Angular не используют специализированные библиотеки управления состоянием, такие как `NgRx` или `Akita`, а вместо этого используют сервисы для управления состоянием приложения. Это вполне допустимый подход. Большинство таких сервисов используют RxJS Observables (в частности, Subject's или BehaviorSubject's) для передачи изменений состояния по всему приложению. Конечно же, может возникнуть желание использовать toSignal для представления Observable в виде сигнала, чтобы остальное приложение могло потреблять его напрямую. Это может привести к ряду проблем.

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

@Injectable({
  providedIn: 'root'
})
export class MessagesStore {
  private messagesService = inject(MessagesService);
  private messages$ = this.messagesService.getMessages();
  messages = toSignal(this.messages$);

  unreadMessages = computed(() => {
    return this.messages().filter(message => !message.read);
  });

  addMessage(message: Message) {
    this.messagesService.addMessage(message);
  }
}

В этом сценарии мы считываем сообщения в реальном времени из внешнего сервиса (FireBase, WebSocket, не имеет значения), и источник подключается к сервису, когда появляется первый подписчик, и отключается, когда последний подписчик исчезает. Это идеальный случай использования Observable, но не для сигнала. Хотя мы получили некоторые преимущества, такие как использование вычисляемых значений, мы также убедились, что соединение начинается сразу после создания сервиса и фактически никогда не прекращается, даже когда уничтожаются компоненты, которые потребляют эти сигналы, поскольку это MessagesStore, который является подписчиком, и он не будет уничтожен до закрытия приложения.

Используйте toSignal в компонентах

Одним простым способом решения может быть просто не выставлять сигналы из служб вовсе и вызывать toSignal только в компонентах, которые потребляют состояние. Таким образом, мы можем быть уверены, что подключение будет установлено только при создании компонента и будет уничтожено, когда все компоненты-клиенты будут уничтожены. Это нормальный подход для большинства приложений, однако это может быть немного утомительным, так как мы можем закончить копировать и вставлять вычисленные сигналы туда-сюда (например, unreadMessages в предыдущем примере).

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

@Injectable({
  providedIn: 'root'
})
export class MessagesStore {
  private messagesService = inject(MessagesService);
  private messages$ = this.messagesService.getMessages();

  addMessage(message: Message) {
    this.messagesService.addMessage(message);
  }

  messages() {
    return toSignal(this.messages$);
  }

  unreadMessages(messages: Signal<Message[]>) {
    return computed(() => {
      return messages().filter(message => !message.read);
    });
  }
}

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

@Component({
  selector: 'app-messages',
  template: `
    <ul>
      @for (message of unreadMessages(messages())) {
        <li>{{ message.text }}</li>
      }
    </ul>
  `
})
export class MessagesComponent {
  private readonly messages = inject(MessagesStore);
  messages = this.messagesStore.messages();
  unreadMessages = this.messagesStore.unreadMessages(this.messages);
}

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



Много интересного по сигналам в документации https://angular.dev/guide/signals#signal-equality-functions