Angular Signals — это захватывающая новая функция, представленная в Angular 16, которая предлагает детальное отслеживание изменений состояния в вашем приложении. Это позволяет платформе эффективно оптимизировать обновления рендеринга, что приводит к повышению производительности. Хотя сигналы в настоящее время находятся на стадии предварительной версии для разработчиков и могут быть изменены, они готовы для изучения и экспериментов. В этой статье мы погрузимся в мир угловых сигналов, поймем, что они из себя представляют, и рассмотрим, как их можно эффективно использовать.

Понимание сигналов

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

Есть два типа сигналов: записываемые сигналы и сигналы только для чтения.

Записываемые сигналы

Сигналы с возможностью записи предоставляют API для прямого обновления их значений. Чтобы создать записываемый сигнал, вы используете функцию signal и указываете начальное значение:

const count = signal(0);

// Accessing the value of a signal using the getter function.
console.log('The count is: ' + count());

Вы можете обновить значение записываемого сигнала двумя способами. Во-первых, вы можете использовать метод set() для прямой установки нового значения:

count.set(3);

В качестве альтернативы вы можете использовать метод update() для вычисления нового значения на основе предыдущего:

// Increment the count by 1.
count.update((value) => value + 1);

В случаях, когда сигнал содержит объект, вы можете захотеть внести внутренние изменения непосредственно в этот объект. Например, если объект представляет собой массив, вы можете захотеть передать новое значение, не заменяя весь массив. В таких случаях вы можете использовать метод mutate:

const todos = signal([{ title: 'Learn signals', done: false }]);

todos.mutate((value) => {
  // Change the first TODO in the array to 'done: true' without replacing it.
  value[0].done = true;
});

Записываемые сигналы имеют тип WritableSignal.

Вычисленные сигналы

Вычисленный сигнал получает свое значение из других сигналов. Вы определяете вычисляемый сигнал с помощью функции computed и указываете функцию вывода:

const count: WritableSignal<number> = signal(0);
const doubleCount: Signal<number> = computed(() => count() * 2);

В этом примере doubleCount зависит от count. Всякий раз, когда обновляется count, Angular распознает, что все, что зависит от count или doubleCount, также нуждается в обновлении.

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

Когда значение count изменяется, это сигнализирует doubleCount, что его кэшированное значение больше недействительно. Новое значение вычисляется и кэшируется, обеспечивая эффективное обновление.

Безопасно выполнять вычисления, требующие значительных вычислительных ресурсов, в вычисляемых сигналах, например фильтровать массивы.

Важно отметить, что вычисленные сигналы не являются записываемыми сигналами. Вы не можете напрямую присвоить значения вычисляемому сигналу, используя метод set():

doubleCount.set(3); // Compilation error: doubleCount is not a WritableSignal.

Вычисленные зависимости сигналов являются динамическими. Отслеживаются только сигналы, фактически считанные во время деривации. Например, рассмотрим следующий вычисленный сигнал, в котором условно считывается сигнал count:

const showCount = signal(false);
const count = signal(0);
const conditionalCount = computed(() => {
  if (showCount()) {
    return `The count is ${count()}.`;
  } else {
    return 'Nothing to see here!';
  }
});

В этом случае, когда читается conditionalCount, а showCount равно false, сообщение "Здесь нечего видеть!" сообщение возвращается без чтения сигнала count. Следовательно, обновления count не вызовут повторного вычисления conditionalCount.

Однако, если позже showCount будет установлено в true, а conditionalCount будет прочитано снова, функция деривации будет выполнена повторно, и будет выбрана ветвь, где showCount будет true. Это вернет сообщение, отображающее значение count, а изменение count сделает недействительным кэшированное значение conditionalCount.

Кроме того, если showCount установить обратно на false, count больше не будет считаться зависимостью conditionalCount, а изменения в count не вызовут повторного вычисления.

Чтение сигналов в компонентах OnPush

Когда компонент OnPush использует значение сигнала в своем шаблоне, Angular автоматически отслеживает сигнал как зависимость этого компонента. Всякий раз, когда сигнал обновляется, Angular помечает компонент, чтобы обеспечить его обновление во время следующего цикла обнаружения изменений. Это поведение особенно актуально для компонентов OnPush, которые могут значительно повысить производительность за счет сокращения ненужного повторного рендеринга. Дополнительные сведения о компонентах OnPush см. в руководстве «Пропуск поддеревьев компонентов».

Эффекты: обработка изменений сигнала

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

effect(() => {
  console.log(`The current count is: ${count()}`);
});

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

Эффекты всегда выполняются асинхронно в процессе обнаружения изменений.

Примеры использования эффектов

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

  1. Ведение журнала: вы можете использовать эффекты для регистрации отображаемых данных и отслеживания их изменения. Это может быть полезно для аналитики или в качестве инструмента отладки.
  2. Синхронизация данных. Эффекты помогают синхронизировать данные с window.localStorage или другими внешними источниками.
  3. Пользовательское поведение DOM: если вам нужно добавить пользовательское поведение DOM, которое нельзя выразить с помощью одного только синтаксиса шаблона, эффекты предоставляют решение.
  4. Пользовательский рендеринг: эффекты позволяют выполнять пользовательский рендеринг в <canvas>, библиотеке диаграмм или любой сторонней библиотеке пользовательского интерфейса.

Когда нельзя использовать эффекты

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

Контекст инъекции

По умолчанию для регистрации нового эффекта с помощью функции effect() требуется «контекст внедрения» с доступом к функции inject. Самый простой способ предоставить этот контекст — вызвать effect() внутри компонента, директивы или конструктора службы:

@Component({...})
export class EffectiveCounterCmp {
  readonly count = signal(0);
  constructor() {
    // Register a new effect.
    effect(() => {
      console.log(`The count is: ${this.count()})`);
    });
  }
}

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

@Component({...})
export class EffectiveCounterCmp {
  readonly count = signal(0);
  
  private loggingEffect = effect(() => {
    console.log(`The count is: ${this.count()})`);
  });
}

Чтобы создать эффект вне конструктора, вы можете передать Injector в effect через его параметры:

@Component({...})
export class EffectiveCounterCmp {
  readonly count = signal(0);
  constructor(private injector: Injector) {}

  initializeLogging(): void {
    effect(() => {
      console.log(`The count is: ${this.count()})`);
    }, {injector: this.injector});
  }
}

Разрушающие эффекты

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

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

Функции равенства сигналов

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

import _ from 'lodash';

const data = signal(['test'], {equal: _.isEqual});

// Even though this is a different array instance, the deep equality
// function considers the values equal, and the signal won't trigger any updates.
data.set(['test']);

Функции равенства могут быть предоставлены как записываемым, так и вычисляемым сигналам. Для записываемых сигналов метод .mutate() не проверяет равенство, поскольку он изменяет текущее значение без создания новой ссылки.

Чтение без отслеживания зависимостей

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

effect(() => {
  console.log(`User set to ${currentUser()} and the counter is ${untracked(counter)}`);
});

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

effect(() => {
  const user = currentUser();
  untracked(() => {
    // If the `loggingService` reads signals, they won't be considered dependencies of this effect.
    this.loggingService.log(`User set to ${user}`);
  });
});

Функции очистки эффектов

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

effect((onCleanup) => {
  const user = currentUser();

  const timer = setTimeout(() => {
    console.log(`Hello, ${user}!`);
  }, 1000);

  onCleanup(() => {
    clearTimeout(timer);
    console.log(`Effect for ${user} cleaned up!`);
  });
});

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

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

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