Как стать автором
Обновить
525.55
OTUS
Цифровые навыки от ведущих экспертов

Angular Signals, реактивный контекст и динамическое отслеживание зависимостей

Время на прочтение11 мин
Количество просмотров2K
Автор оригинала: OZ

Чтобы эффективно использовать сигналы Angular Signals, вам нужно понимать концепцию «реактивного контекста» и то, как работает отслеживание зависимостей. В этой статье я объясню обе эти вещи и покажу, как избежать некоторых связанных с ними ошибок.

Отслеживание зависимостей

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

  • Граф зависимостей (Dependency Graph): граф, где каждый узел реализует интерфейс ReactiveNode;

  • Производители (Producers): узлы, содержащие значения и уведомляющие о новых значениях (они «производят реактивность»);

  • Потребители (Consumers): узлы, которые считывают произведенные значения (они «потребляют реактивность»);

Сигналы являются производителями, функция computed() — производитель и потребитель одновременно, функция effect() — потребитель, шаблоны — потребители.

Рекомендую вам прочитать более подробную статью о графике зависимостей в Angular Signals (с анимированным примером графика).

Как работает автоматическое отслеживание зависимостей: есть глобальная для всех реактивных узлов переменная activeConsumer, и каждый раз, когда computed() запускает свою функцию реактивных вычислений, каждый раз, когда effect() запускает свою функцию выполнения сайд‑эффектов, или когда шаблон проверяется на изменения, они:

  1. Считывают значение переменной activeConsumer (чтобы запомнить предыдущего потребителя);

  2. Регистрируют себя в качестве activeConsumer;

  3. Запускают функцию или выполняют шаблон (на этом этапе могут быть считаны некоторые сигналы);

  4. Регистрируют предыдущего потребителя (из шага 1) как activeConsumer.

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

Давайте пошагово рассмотрим, что происходит в этом примере:

@Component({
  template: `
   Items count: {{ $items().length }}
   Active items count: {{ $activeItemsCount() }}
`   
})
class ExampleComponent {
  protected readonly $items = signal([{id: 1, $isActive: signal(true) }]);

  protected readonly $activeItemsCount = computed(() => {
    return this.getActiveItems().length;
  });

  private getActiveItems() {
    return this.$items().filter(i => i.$isActive());
  }
}
  1. Шаблон считывает значение activeConsumer и сохраняет его в переменной prevConsumer (эта переменная является локальной для шаблона);

  2. Шаблон устанавливает себя в качестве activeConsumer;

  3. Он вызывает сигнал $items(), чтобы получить значение;

  4. Сигнал $items извлекает значение activeConsumer;

  5. Полученное значение не пустое (в нем содержится ссылка на шаблон), поэтому сигнал $items помещает это значение (ссылку на наш шаблон) в список потребителей. После этого каждый раз, когда $items будет обновляться, шаблон будет получать уведомление — в графе зависимостей появилась новая ссылка;

  6. $items возвращает значение в шаблон;

  7. Шаблон считывает значение сигнала $activeItemsCount. Чтобы вернуть значение, $activeItemsCount должен запустить свою вычислительную функцию (функцию, которую мы передаем в нашем коде в computed());

  8. Перед запуском вычислительной функции $activeItemsCount считывает значение activeConsumer и сохраняет его в своей локальной переменной prevConsumer. Поскольку $activeItemsCount также является потребителем, он помещает ссылку на себя в переменную activeConsumer;

  9. Вычислительная функция вызывает функцию getActiveItems();

  10. Внутри этой функции мы считываем значение $items — шаги с 3 по 6 повторяются, но поскольку наш шаблон уже зависит от $items, шаг 5 не будет добавлять нового потребителя в список;

  11. Когда возвращается значение (массив элементов), getActiveItems() считывает каждый элемент этого массива и считывает значение $isActive();

  12. $isActive — это сигнал. Поэтому, прежде чем вернуть значение, он повторяет шаги 3–6. На шаге 4 $isActive извлекает значение activeConsumer. В данный момент activeConsumer содержит ссылку на $activeItemsCount, поэтому на шаге 5 $isActive (каждый из массива) добавит $activeItemsCount в список зависимых потребителей. Каждый раз, когда $isActive обновляется, $activeItemsCount получает уведомление, $activeItemsCount уведомляет наш шаблон о том, что его значение устарело и должно быть пересчитано. После этого наш шаблон в конечном итоге (не сразу после уведомления) спросит $activeItemsCount, каково новое значение, и шаги с 7 по 14 будут повторены;

  13. getActiveItems() возвращает значение. $activeItemsCount использует это значение для вычислений и перед тем, как вернуть его, помещает значение своей локальной переменной prevConsumer в переменную activeConsumer;

  14. $activeItemsCount возвращает значение;

  15. Шаблон помещает ранее сохраненное значение prevConsumer в activeConsumer.

Это не маленький список, но, пожалуйста, прочитайте его внимательно.

Самое важное здесь то, что потребителям (computed(), effect(), шаблонам) не нужно беспокоиться о добавлении сигналов, которые они считывают, в список зависимостей. Сигналы будут делать это сами, используя переменную activeConsumer. Эта переменная доступна любому реактивному узлу, поэтому неважно, как глубоко в цепочке функций будет прочитан сигнал — любой сигнал получит значение activeConsumer и добавит его в список потребителей.

Помните: если вы вызываете в шаблоне функцию, computed() или effect() (любого потребителя), а эта функция читает другую функцию, а та читает другую функцию... и, наконец, на каком‑то уровне функция читает сигнал, то этот сигнал добавляет потребителя в свой список и уведомляет его об обновлениях.

Все это чтение (почти как отладка), может быть утомительным, поэтому позвольте мне развлечь вас этим небольшим приложением:

Пожалуйста, выполните следующие действия в этом приложении:

  1. Нажмите кнопку «2», чтобы сделать ее активной, а затем нажмите ее еще раз. Обратите внимание, что текст «Active items» над кнопками отражает это изменение;

  2. Нажмите кнопку «Add Item»;

  3. Нажмите кнопку «4». Обратите внимание, что надпись «Active items» не отражает изменения;

  4. Нажмите кнопку «2»;

  5. Теперь несколько раз нажмите кнопку «4» и обратите внимание, что текст «Active items» отражает состояние кнопки, как и ожидалось.

Но почему это происходит? Давайте посмотрим на код:

export type Item = {
  id: number;
  $isActive: WritableSignal<boolean>;
};

@Component({
  selector: 'my-app',
  template: `
    <div>Active items: {{ $activeItems() }}</div>
    <div>
      <span>Click to to toggle:</span>
      @for(item of items; track item.id) {
        <button (click)="item.$isActive.set(!item.$isActive())" 
                [class.active]="item.$isActive()">
          {{ item.id }}
       </button>
      }
    </div>
    <div>
      <button (click)="addItem()">Add Item</button>
    </div>
  `,
})
export class App {
  protected readonly items: Item[] = [
    { id: 1, $isActive: signal(true) },
    { id: 2, $isActive: signal(false) },
    { id: 3, $isActive: signal(true) },
  ];

  protected readonly $activeItems = computed(() => {
    const ids = [];
    for (const item of this.items) {
      if (item.$isActive()) {
        ids.push(item.id);
      }
    }
    return ids.join(', ');
  });

  protected addItem() {
    this.items.push({
      id: this.items.length + 1,
      $isActive: signal(false),
    });
  }
}

Теперь давайте разберемся, почему строка «Active items» обновляется некорректно.

Привязка в нашем шаблоне:

<div>Active items: {{ $activeItems() }}</div>

$activeItems это сигнал, предоставленный функцией computed():

protected readonly $activeItems = computed(() => {
  const ids = [];
  for (const item of this.items) {
    if (item.$isActive()) {
      ids.push(item.id);
    }
  }
  return ids.join(', ');
});

Функция, которую мы передаем в computed(), будет выполняться заново каждый раз, когда любой из сигналов, которые она считывает, будет обновлен. Какие сигналы мы считываем?

Это сигнал $isActive для каждого элемента в массиве this.items.

Обратите внимание, как знак $ в имени переменной помогает быстро найти источники реактивности. Этот принцип аналогично применим к функции effect() и шаблонам компонентов. Именно поэтому я использую его, но на деле это просто вопрос личных предпочтений.

Почему же $activeItems не обновляется после шагов 2 и 3?

Вычислительная функция будет повторно выполнена только тогда, когда обновится один из сигналов, от которых она зависит.

Когда мы нажимаем кнопку «Add Item», мы изменяем this.items и создаем новый сигнал внутри нового элемента. Но до этого момента наша функция computed() никогда не читала этот сигнал, поэтому у нее нет его в списке зависимостей.

До и после нажатия кнопки «Add Item» список сигналов, от которых зависит $activeItems, остается неизменным: три сигнала $isActive от трех элементов в this.items.

Поскольку ни один из этих сигналов не изменяется, когда мы нажимаем на кнопку «Add Item», computed() не будет уведомлен, и вычислительная функция не будет выполнена повторно.

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

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

Для этого нам нужно изменить одну из существующих зависимостей: именно для этого мы нажимаем кнопку «2» в шаге 4.

Этот пример создан для того, чтобы напомнить вам, что функции, которые мы передаем в computed() и effect(), будут повторно выполняться только тогда, когда один из производителей, которых они считали, будет обновлен.

Поэтому всегда полезно перепроверять, какие зависимости есть у вашей computed() и какие из них должны вызвать повторное вычисление. Если некоторые из них не должны — используйте untracked().

Некоторые функции, которые мы передаем в computed() или effect(), могут считывать сигналы (или функции, которые они вызывают, могут считывать сигналы).

this.$petWalkingIsAllowed = computed(() => {
  return this.$isFreeTime() && this.isItGoodWeatherOutside();
});

isItGoodWeatherOutside() {
  return $isSunny() && $isWarm() && !$isStormy();
}

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

  • Если мы не хотим, чтобы наша computed() вычисляла новый результат, когда эта функция (isItGoodWeatherOutside()) возвращает новое значение, оберните ее в untracked():

this.$petWalkingIsAllowed = computed(() => {
  return this.$isFreeTime() && untracked(() => this.isItGoodWeatherOutside());
});

isItGoodWeatherOutside() {
  return $isSunny() && $isWarm() && !$isStormy();
}
  • Если при каждом новом значении из этой функции мы хотим повторно выполнить вычисления, не оборачивайте ее в функцию untracked().

Как видите, untracked() помогает нам контролировать, какие зависимости мы хотим отслеживать. Она также помогает управлять еще одним важным аспектом:

Реактивный контекст

Выше, в разделе про автоматическое отслеживание зависимостей, я упоминал переменную activeConsumer.

Если activeConsumer не равен null, прочитанные сигналы добавят этот activeConsumer в список потребителей, чтобы впоследствии уведомлять членов этого списка об изменении сигнала. Если реактивный узел будет прочитан, когда activeConsumer пуст, он не создаст новой связи в графе зависимостей реактивных узлов.

Другими словами, пока activeConsumer установлен, мы считываем сигналы внутри реактивного контекста.

В большинстве случаев реактивный контекст будет обрабатываться автоматически, и будут создаваться и удаляться только нужные связи и зависимости.

Но иногда мы непреднамеренно создаем утечку реактивного контекста.

Давайте рассмотрим это приложение:

https://stackblitz‑starters‑hgrfbo.stackblitz.io

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

  • Нажатие на кнопку «Add Item» приводит к полному сбросу всех статусов;

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

Сможете ли вы быстро обнаружить ошибку?

@Component({
  template: `
    <div>Active items: {{ $activeItems() }}</div>
    <div class="flex-row">
      <span>Click to to toggle:</span>
      @for(item of $items(); track item.id) {
      <button (click)="item.$isActive.set(!item.$isActive())" [class.active]="item.$isActive()" [style.transform]="'scale('+item.$scale()+')'">
        {{ item.id }}
      </button>
      }
    </div>
    <div>
      <button (click)="addItem()">Add Item</button>
    </div>
  `,
})
export class App {
  private readonly $itemsCount = signal(3);

  protected readonly $items: Signal<Item[]> = computed(() => {
    console.warn('Generating items!');
   
    const items: Item[] = [];
    for (let id = 0; id < this.$itemsCount(); id++) {
      const $isActive = signal(Math.random() > 0.5);
      const $scale = signal($isActive() ? 1.2 : 1);
      items.push({ id, $isActive, $scale });
    }
    return items;
  });

  protected readonly $activeItems = computed(() => {
    const ids = [];
    for (const item of this.$items()) {
      if (item.$isActive()) {
        ids.push(item.id);
      }
    }
    return ids.join(', ');
  });

  protected addItem() {
    this.$itemsCount.update(c => c + 1);
  }
}

Что мы можем здесь увидеть:

  • Мы выводим список элементов из $items, который был вычислен в computed();

  • $items генерирует новый массив элементов, а их количество контролируется сигналом $itemsCount. Каждый раз, когда мы изменяем $itemsCount, элементы генерируются заново;

  • addItem() просто увеличивает $itemsCount, вызывая повторное вычисление $items.

Теперь мы видим, почему «Add Item» работает именно так. Давайте попробуем выяснить, почему себя странно ведет переключение статуса.

Если мы откроем консоль, то заметим, что каждый раз, когда мы нажимаем на кнопку, в логах появляется предупреждение «Generating items!». Но почему? Мы не изменяем $itemsCount, так почему же $items вычисляется заново?

Возможно, вы уже заметили, что функция вычисления $items считывает еще один источник реактивности: сигнал $isActive:

const $scale = signal($isActive() ? 1.2 : 1);

Этот сигнал ($isActive) считывается в реактивном контексте: activeConsumer содержит $items, поэтому $isActive будет уведомлять $items о каждом изменении. Поэтому, когда мы изменяем $isActive в попытке переключить этот статус, мы запускаем повторное вычисление $items.

Существует множество способов исправить эту ошибку, но данный подход предотвращает утечку реактивного контекста:

const $scale = signal(untracked($isActive) ? 1.2 : 1);

Что делает функция untracked()?

/** 
 * https://github.com/angular/angular/blob/75a186e321cb417685b2f13e9961906fc0aed36c/packages/core/src/render3/reactivity/untracked.ts#L15
 *
 * packages/core/src/render3/reactivity/untracked.ts
 *
 **/
export function untracked<T>(nonReactiveReadsFn: () => T): T {
  const prevConsumer = setActiveConsumer(null);
  try {
    return nonReactiveReadsFn();
  } finally {
    setActiveConsumer(prevConsumer);
  }
}
  • устанавливает activeConsumer в null и сохраняет возвращаемое значение в локальной переменной prevConsumer;

  • запускает заданную функцию;

  • восстанавливает activeConsumer из prevConsumer.

Она временно отключает реактивный контекст, выполняет нашу функцию, а затем восстанавливает его.

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

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

Существуют довольно интересные и неожиданные действия, приводящие к утечке реактивного контекста:

  • Создание экземпляра класса, который считывает некоторые сигналы;

  • Вызов функции, которая вызывает другую функцию, считывающую сигнал;

  • Создание компонента внутри effect();

  • Передача нового значения observable.

Когда вы используете computed() и effect(),

  • Читайте другие сигналы с осторожностью — при каждом изменении, вызванном любой другой функцией, они будут заново выполнять наши computed() и effect().

  • Делайте эти функции легко читаемыми и понятными;

  • Дважды проверяйте каждый источник реактивности, который потребляет ваша функция.

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


Больше про языки программирования эксперты OTUS рассказывают в рамках практических онлайн-курсов. С полным каталогом курсов можно ознакомиться по ссылке.

Теги:
Хабы:
Всего голосов 2: ↑1 и ↓10
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS