От переводчика

Всем привет, меня зовут Максим Иванов. В основном я пишу обзоры и русифицирую статьи для начинающих разработчиков. Я очень люблю Angular и иногда рассказывать что-то о нем. Если вы только начинаете свой путь в изучении этого фреймворка, надеюсь эта статья будет вам полезной. Cегодня мы с вами поговорим о том, что такое пайпы (pipes), как они устроены и что не так с одним из самых популярных и доступных из коробки пайпов, таких как async. Желаю приятного прочтения и хорошего настроения. Поехали!

Содержание

  1. Что такое pipe

  2. Мемоизация функции

  3. Пишем свой первый pipe

  4. Базовый набор готовых pipe(ов)

  5. Как устроена компиляция pipe(ов)?

  6. Impure pipe(ы)

  7. Работа с RxJS в шаблонах

  8. AsyncPipe под капотом

  9. markForCheck vs detectChanges

  10. Какие есть проблемы у AsyncPipe?

  11. Сигналы

  12. Заключение


1. Что такое pipe

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

Пайп — это специальный оператор, применяемый в html шаблонах на Angular, который позволяет декларативно преобразовывать данные.

Синтаксис (прямая черточка - | ) был взят не случайно, далеко ходить не нужно, чаще всего вы могли встретиться с подобным при работе с терминалом в Unix-системах. Таким образом в наших шаблонах мы просто пишем:

{{ value | one | two | three }}

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

{{ three(two(one(value))) }}

Ведь, по правде говоря, человеку только что пришедшему с курсов по JavaScript и React, не обращая внимания на синтаксический сахар, последняя запись будет более понятной. Так вот, дело тут не только в красивой записи, но и в оптимизации, а именно, то как Angular работает с вычислениями в шаблоне.

Давайте рассмотрим самый простой пример:

@Component({
  selector: 'app',
  imports: [FormsModule],
  template: `
     Имя: 
      <input [(ngModel)]="name" />
      <br />
      Фамилия: 
      <input [(ngModel)]="lastname" />
      <br />
      <br />
      {{ toUpperCase(name) }}
      {{ toUpperCase(lastname) }}
  `,
})
export class App {
  name = '';
  lastname = '';

  toUpperCase(value: string): string {
    console.log('value', value);

    return value.toUpperCase();
  }
}

У нас есть два текстовых поля ввода. Задача состоит в том, чтобы вывести в шаблоне значения этих полей в верхнем регистре.

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

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

Значение не изменилось ведь, зачем мне снова к ней применять toUpperCase?

Разумеется, данная операция никак не скажется на сложности вычислений и практически не заметна для современных процессоров.


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

Кстати, с примером на React, в консоли, мы с вами увидим тоже самое.
export default function App() {
  const [name, setName] = useState('');
  const [lastname, setLastname] = useState('');

  function toUpperCase(value) {
    console.log('value', value);

    return value.toUpperCase();
  }

  return (
    <>
      Имя{': '}
      <input 
           value={name} 
           onChange={(e) => setName(e.target.value)} />
      <br />
      Фамилия{': '}
      <input 
           value={lastname} 
           onChange={(e) => setLastname(e.target.value)} />
      <br />
      <br />
      {toUpperCase(name)} {toUpperCase(lastname)}
    </>
  );
}

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

2. Мемоизация функции

Мемоизация — сохранение результатов выполнения функций для предотвращения повторных вычислений. Это один из способов оптимизации, применяемый для увеличения скорости выполнения компьютерных программ. Проще говоря, мемоизация — это запоминание, сохранение чего-либо в памяти. Функции, в которых используется мемоизация, обычно работают быстрее, так как при их повторных вызовах с одними и теми же параметрами, они, вместо выполнения неких вычислений, просто считывают результаты из кеша и возвращают их [1].

Самая простая реализация memoize функции выглядит следующим образом:

const memo = function (fn) {
    const cache = {};

    return function (x) {
        if (x in cache) return cache[x];
        return cache[x] = fn(x);
    };
};

На собеседованиях на Junior(a) вас могут попросить реализовать мемоизацию для факториала . Сама функция выглядит довольно просто:

Формула факториала
function factorial(n) {
    if (n < 2) return 1;

    return n * factorial(n - 1);
}

Функция вычисления факториала — это пример ресурсоемкой функции, которая, практически гарантированно, в ходе нескольких вызовов, выполняет некоторую часть одинаковых вычислений по много раз. Это открывает возможности по оптимизации через кеширование[1].

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

const factorial = memo(function (n) {
    if (n < 2) return 1;

    return n * factorial(n - 1);
});

Когда мы первый раз выполним factorial(5), то это будет холодный старт, для значений от 1 до 5 мы впервые закешируем результаты. И в том случае, когда нам нужно будет выполнить в дальнейшем factorial(6), то умножение будет выполняться быстрее, по той логике, что часть операций уже были запомнены (замемоизированы).

Хорошо, вспомнив как написать простую memoize функции, мы можем применить наши знания к коду выше и получим следующий пример:

const memo = function (fn: Function) {
  const cache: Record<string, string> = {};

  return function (x: string) {
    if (x in cache) return cache[x];
    return (cache[x] = fn(x));
  };
};

@Component({
  selector: 'app',
  imports: [FormsModule],
  template: `
      Имя: 
      <input [(ngModel)]="name" />
      <br />
      Фамилия: 
      <input [(ngModel)]="lastname" />
      <br />
      <br />
      {{ toUpperCase(name) }}
      {{ toUpperCase(lastname) }}
  `,
})
export class App {
  name = '';
  lastname = '';

  toUpperCase = memo((value: string): string => {
    console.log('value', value);

    return value.toUpperCase();
  });
}

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

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

Итог: накапливаются бесполезные данные в памяти
const memo = function (fn: Function) {
  const cache: Record<string, string> = {};

  return function (x: string) {
    console.log(cache); // Убедимся в этом посмотрев в логах
  
    // ....
    // ....

Кеширование результатов на основе последних аргументов

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

interface Cache {
  prevArg: string;
  prevResult: string;
}

const memoizeOne = function (fn: Function) {
    let cache: Cache | null = null;

    return function (newArg: string): string {
        if (cache?.prevArg === newArg) {
          return cache.prevResult;
        }

        cache = {
          prevArg: newArg,
          prevResult: fn(newArg)
        };
      
        return cache.prevResult;
    };
};

Звучит хорошо, но кажется, что-то пошло не так:

const memoizeOne = /* ... */;

@Component({
  selector: 'app',
  imports: [FormsModule],
  template: `
      Имя: 
      <input [(ngModel)]="name" />
      <br />
      Фамилия: 
      <input [(ngModel)]="lastname" />
      <br />
      <br />
      {{ toUpperCase(name) }}
      {{ toUpperCase(lastname) }}
  `,
})
export class App {
  name = '';
  lastname = '';

  toUpperCase = memoizeOne((value: string): string => {
    console.log('value', value);

    return value.toUpperCase();
  });
}
Дебаг

Результат оказался не тем, что мы ожидали. Если посмотреть, то мы видим, что каждый раз вычисляется перевод в верхний регистр снова и снова, будто мы и не применяли мемоизацию. А дело все в том, что мы допустили просчет в логике. Давайте разбираться.

Поле с фамилией пустая строка. Начинаем вводить букву м имени. Вызывается метод-функция, идущая первой в шаблоне с аргументом name , то есть toUpperCase('м'), результатом под капотом мы сохраняем кеш со значением 'М'.

Затем следующей строчкой срабатывает toUpperCase(''), с аргументом lastname. И тут-то все и ломается. Аргумент с точки зрения замемоизированной-функции поменялся, а значит и кеш не актуален уже. По сути, мы вошли в бесконечную перезапись кеша и нивелировали тщетные попытки оптимизировать наш код. Как же быть?

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

const memoizeOne = /* ... */;

const toUpperCase = (value: string): string => {
  console.log('value', value);

  return value.toUpperCase();
};

@Component({
  selector: 'app',
  imports: [FormsModule],
  template: `
      Имя: 
      <input [(ngModel)]="name" />
      <br />
      Фамилия: 
      <input [(ngModel)]="lastname" />
      <br />
      <br />
      {{ getName(name) }}
      {{ getLast(lastname) }}
  `,
})
export class App {
  name = '';
  lastname = '';

  getName = memoizeOne(toUpperCase);
  getLast = memoizeOne(toUpperCase);
}
Кстати, на React все тоже самое можно добиться с использованием useMemo хука
export default function App() {
  const [name, setName] = useState('');
  const [lastname, setLastname] = useState('');

  const toUpperCase = (value) =>
    useMemo(() => {
      console.log('value', value);

      return value.toUpperCase();
    }, [value]);

  return (
    <>
      Имя{': '}
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <br />
      Фамилия{': '}
      <input value={lastname} onChange={(e) => setLastname(e.target.value)} />
      <br />
      <br />
      {toUpperCase(name)} {toUpperCase(lastname)}
    </>
  );
}

3. Пишем свой первый pipe

Хорошо, давайте заканчивать с извращениями, которые мы проделывали выше и посмотрим, что предлагает делать в таких случаях наш фреймворк. Конечно, же использовать пайпы!

@Pipe({
  name: 'myToUpperCase',
})
export class MyToUpperCase implements PipeTransform {
  transform(value: string): string {
    console.log('value', value);

    return value.toUpperCase();
  }
}

@Component({
  selector: 'app',
  standalone: true,
  imports: [FormsModule, MyToUpperCase],
  template: `
     Имя: 
      <input [(ngModel)]="name" />
      <br />
      Фамилия: 
      <input [(ngModel)]="lastname" />
      <br />
      <br />
      {{ name | myToUpperCase }}
      {{ lastname | myToUpperCase }}
  `,
})
export class App {
  name = '';
  lastname = '';
}

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

4. Базовый набор готовых pipe(ов)

Разумеется, чтобы не писать свои собственные тривиальные решения, как мы это сделали в примере с myToUpperCase, команда Angular заранее подумала о нас и предоставила из коробки уже готовый джентльменский набор пайпов:

Как мы видим наши танцы с бубном быстро решились, если знать где что лежит
import { UpperCasePipe } from '@angular/common';
// ...

@Component({
  selector: 'app',
  standalone: true,
  imports: [FormsModule, UpperCasePipe],
  template: `
     Имя: 
      <input [(ngModel)]="name" />
      <br />
      Фамилия: 
      <input [(ngModel)]="lastname" />
      <br />
      <br />
      {{ name | uppercase }}
      {{ lastname | uppercase }}
  `,
})
export class App {
  name = '';
  lastname = '';
}
<p>{{ 'some string' | uppercase }}</p>
<!-- на выходе получим "SOME STRING" -->
<p>{{ 'SOME STRING' | lowercase }}</p>
<!-- на выходе получим "some string" -->
<p>{{ 'some string' | titlecase }}</p>
<!-- на выходе получим "Some String" -->
<p>{{ 0.259 | currency }}</p>
<!-- на выходе получим "$0.26" -->

<p>{{ 0.259 | currency: 'EUR' }}</p>
<!-- на выходе получим "€0.26" -->
@Component({
  // ...
})
class AppComponent {
   dateObj = Date.now();
}
<p>{{ dateObj | date }}</p>
<!-- на выходе получим "Jun 15, 2015" -->
<p>{{ ['a', 'b', 'c', 'd'] | slice : 0 : 2}}</p>
<!-- на выходе получим "['a', 'b']" -->
@Component({
  // ...
})
class AppComponent {
   obj = { 2: 'foo', 1: 'bar' };
}
<p>{{ obj | keyvalue }}</p>
<!-- на выходе получим '[ { "key": "1", "value": "bar" }, { "key": "2", "value": "foo" } ]' -->
  • DecimalPipe, (исходники)

    PS: Честно признаюсь, за 10 лет использования Angular, я так и не воспользовался ни разу этим пайпом. В ней мне казалось, странным все, начиная с того, что название класса отличается от названия самого пайпа, заканчивая тем, что вторым аргументом вы передаете нетипизированную строку формата: {minIntegerDigits}.{minFractionDigits}-{maxFractionDigits}, что лично для меня кажется не самым удобным.

<p>{{ 103.1234 | number: '3.2-2' }}</p>
<!-- на выходе получим "103.12" -->
<pre>{{ myObj | json }}</pre>
<!-- на выходе мы получим JSON.stringify объекта в шаблоне "{ ... }" -->
  • PercentPipe, (исходники)

    PS: И тут тоже, я крайне редко встречал этот пайп в реальных проектах, поделитесь вашим опытом, был ли он вам полезен?

<p>{{ 0.259 | percent }}</p>
<!-- на выходе получим "26%" -->

5. Как устроена компиляция pipe(ов)?

Давайте рассмотрим, что же из себя представляет код на Angular после компиляции в обычном JavaScript представлении. Возьмем тот же пример, который у нас был в начале и соберем его в dev-режиме, чтобы код не особо подвергся обфускации и можно было слегка посмотреть, что там:

$ ng build -c development

Для начала, рассмотрим пример, который использует memoizeOne.

После сборки, как мы видим все наше приложение со всеми его html, typescript кодом скомбайнилось в один файл main.js.

Чтобы было проще читать код, я заменил \u0275 на ɵ.

Как мы видим, вся магия с декоратором и его мета-данными превращается в статичное поле ɵcmp, в котором мы можем заметить поле template. Выходит, наш html-шаблон превращается в динамически-управляемый JavaScript код.

Было (html):

{{ getName(name) }}
{{ getLast(lastname) }}

Стало (javascript):

ɵɵtextInterpolate2(
  " ", 
  ctx.getName(ctx.name), 
  " ", 
  ctx.getLast(ctx.lastname), 
  " "
);

Здесь ctx скорее всего this-экземпляр нашего класса App. Окей, все логично, наши методы мы мемоизировали, они выполняются точно также в рантайме.

А теперь рассмотрим пример, в котором мы работали с нашим кастомным myToUpperCase пайпом:

Тут уже видим наш класс, на который был повешен декоратор Pipe, скомпилировался в JavaScript класс со статичным полем ɵpipe. Из интересного мы можем наблюдать здесь поле pure в вызове ɵɵdefinePipe. О нем мы еще поговорим чуть позже.

Было (html):

{{ name | myToUpperCase }}
{{ lastname | myToUpperCase }}

Стало (javascript):

ɵɵtextInterpolate2(
  " ", 
  ɵɵpipeBind1(8, 4, ctx.name), 
  " ", 
  ɵɵpipeBind1(9, 6, ctx.lastname), 
  " "
);

Если смотреть, что из себя представляет функция ɵɵpipeBind1, то мы можем заметить, что под капотом у нас просто вызывается тернарный оператор. Как мы помним, по умолчанию в ɵɵdefinePipe указано поле pure как true , значит в нашем случае, всегда будет вызываться pureFunction1Internal, который по-видимому будет кешировать результат вызова метода transform у нашего класса, который мы с вами определили ранее для трансформации. Ну и логично, если же поле pure было бы false, то тут всегда возвращался бы результат вызова метода transform.

Если же смотреть, как устроена функция pureFunction1Internal, то мы видим, если входные параметры у нашего метода transform изменились, то мы заново обновим кеш, в противном случае, всегда будет возвращать последнее закешированное значение из getPureFunctionReturnValue.

В функции bindingUpdated, нам важно здесь то, что наши аргументы мы сравниваем с помощью метода Object.is(oldValue, value)

Object.is(1, 1) // true
Object.is(1, 2) // false
  
Object.is("a", "a") // true
Object.is("a", "b") // false

const a = []
Object.is(a, a); // true
Object.is(a, []); // false

Что же, логика теперь понятна! Конечно, не точно также как это было с memoizeOne, но подход все тот же - мы используем кеш.

Кстати, очень часто мы видимо в скомпилированном коде lView, что это? Так вот lView это сокращение от Logical View Data. LView организован таким образом, что представляет из себя линейную структуру (одномерный массив), в котором содержаться все данные на момент рендеринга вашего шаблона.

Теперь понятно, для чего в скомпилированном коде генерируются индексы, в нашему случае 8 и 9:

ɵɵpipeBind1(8, 4, ctx.name);
ɵɵpipeBind1(9, 6, ctx.lastname);

Если смотреть псевдокод, то это выглядит так:

const lView = [];

lView[4] = tView.data[8].transform("Максим");
lView[6] = tView.data[9].transform("Иванов");

// lView
[empty × 8, 'МАКСИМ', 'ИВАНОВ']

Соответственно, когда аргументы поменяются, то lView[4] и lView[6] будут перезатираться и иметь новые значения, а так, мы обычно всегда возвращаем по индексу закешированные в этих ячейках результаты.

Что касается, tView (Template View Data), то это уже более сложная структура, которая на псевдокоде будет выглядеть так:

const tView = { 
  data: [],
  pipeRegistry: []
};

class MyPipe {
  static name = 'myToUpperCase';

  transform(exp) {
      return exp.toUpperCase();
  }
}

tView.pipeRegistry.push(new MyPipe());

tView.data[8] = getPipeDef('myToUpperCase');
tView.data[9] = getPipeDef('myToUpperCase');

function getPipeDef(name) {
  return tView.pipeRegistry.filter((pipe) => pipe.name === name);
}

Она нужна, чтобы хранить связи между различными зависимостями вашего шаблона, DI или какие-либо низкоуровневые экземпляры для работы с ними в шаблонах.

6. Impure pipe(ы)

Как вы могли заметить из скомпилированного кода выше, пайпы бывают двух видов:

  • pure (не допускающий изменения / сайд-эффекты);

  • impure (допускающий изменения / сайд-эффекты).

@Pipe({
  name: 'myToUpperCase',
  pure: false // тут определяем поведение для вызова метода transform
})
export class MyToUpperCase implements PipeTransform {
  transform(exp) {
      return exp.toUpperCase();
  }
}

В приведенном выше коде, если наш пайп myToUpperCase становится impure, то его метод transform будет выполняться на каждом цикле обнаружения изменений, независимо оттого, поменялись ли аргументы или нет.

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

Увы, к сожалению, в Angular есть подводные камни, когда сам фреймворк вот никак не может понять нужно ли обновлять данные в шаблоне или нет, и чтобы ему помочь, приходиться делать для таких пайпов исключения. В случае с myToUpperCase это бессмысленно, его не нужно делать impure, потому что вы передаете на вход иммутабельную строку. Так или иначе на все сто процентов наш пайп должет быть pure по умолчанию.

А вот для ситуации, к примеру, когда вы передаете объект или массив данных на вход вашего пайпа, который где-то в рантайме приложения может мутироваться, без изменения ссылка на сам объект, то увы Angular не будет в состоянии узнать поменялись ли что-то в вашем коде.

@Pipe({
  name: 'myStringify',
})
export class MyStringifyObject implements PipeTransform {
  transform(obj: any) {
    return JSON.stringify(obj);
  }
}

@Component({
  selector: 'app',
  imports: [MyStringifyObject],
  template: `
    {{ arr | myStringify }}
  `,
})
export class App {
  arr = [1, 2];

  ngOnInit() {
    setInterval(() => {
      const n = Math.floor(Math.random() * 10) + 1;

      this.arr.push(n);
    }, 1000);
  }
}

Тут каждую секунду, мы добавляем новое значение в массив, однако, на экране мы не увидим никаких изменений кроме как [1, 2]. Чтобы все заработало как надо, нам необходимо сделать пайп impure:

@Pipe({
  name: 'myStringify',
  pure: false
})
// ...

Однако, правильнее поступить иначе, не делая наш пайп impure, нам достаточно изменять ссылку на массив данных, и все будет работать как надо:

const n = Math.floor(Math.random() * 10) + 1;

this.arr = [...this.arr, n];

К сожалению, бывают случаи, когда такой возможности может и не быть, если это, к примеру, работа со сложными RxJS потоками. В Angular вы сразу столкнетесь с такими, особенно часто у вас будет потребность работать с потоками данных прямо в шаблоне и для работы с ними фреймворк предоставляет вам async пайп, который является impure по умолчанию. А как так вышло и почему давайте разбираться.

7. Работа с RxJS в шаблонах

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

Пример на чистом JavaScript с использованием setInterval

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

@Component({
  selector: 'counter',
  template: `{{ count }}`
})
export class Counter {
  count = 0;

  constructor() {
    setInterval(() => {
      this.count++;
    }, 1000);
  }
}

Окей, вроде бы ясно и понятно. Но, чтобы быть честным, нужно помнить, о том, что команда Angular рекомендует всегда использовать использовать OnPush стратегию обнаружения изменений по умолчанию на компонентах[2], а потому наш пример может выглядеть так:

@Component({
  selector: 'counter',
  template: `{{ count }}`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Counter {
  cd = inject(ChangeDetectorRef);
  count = 0;

  constructor() {
    setInterval(() => {
      this.count++;
      this.cd.detectChanges();
    }, 1000);
  }
}

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

@Component({
  selector: 'counter',
  template: `{{ count }}`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Counter implements OnDestroy {
  cd = inject(ChangeDetectorRef);
  count = 0;
  destroyId = 0;

  constructor() {
    this.destroyId = setInterval(() => {
      this.count++;
      this.cd.detectChanges();
    }, 1000);
  }

  ngOnDestroy() {
    clearInterval(this.destroyId);
  }
}

Надеюсь это все? Не тут то было, если вы хотите добиться максимальной производительности в Angular, вам нужно помнить о так называемом zone pollution. Любые изменения в приложении отслеживаются с помощью библиотеки zone.js, к сожалению, у нее есть ряд ограничений и определенные подводные камни, с которыми приходится сталкиваться. К счастью, спустя 10 лет разработки команда из гугла практически поборола свои legacy проблемы и уже продвигает новый zoneless подход в массы, начиная с Angular 20. Более подробно мы еще поговорим об этом всем. Тем не менее, финальный код будет выглядеть так:

@Component({
  selector: 'app',
  template: `{{ count }}`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App implements OnDestroy {
  zone = inject(NgZone);
  cd = inject(ChangeDetectorRef);
  destroyId = 0;
  count = 0;

  constructor() {
    this.zone.runOutsideAngular(() => {
      this.destroyId = setInterval(() => {
        this.count++;
        this.cd.detectChanges();
      }, 1000);
    });
  }

  ngOnDestroy() {
    clearInterval(this.destroyId);
  }
}

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

Пример с RxJS

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

@Component({
  selector: 'counter',
  template: `{{ count }}`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Counter {
  zone = inject(NgZone);
  cd = inject(ChangeDetectorRef);
  subscription?: Subscription;
  count = 0;

  constructor() {
    this.zone.runOutsideAngular(() => {
      this.sub = interval(1000).subscribe(() => {
        this.count++;
        this.cd.detectChanges();
      });
    });
  }

  ngOnDestroy() {
    this.subscription?.unsubscribe();
  }
}

Теперь мы с вами заиспользовали оператор interval, который под капотом точно также использует setInterval, но согласитесь, кода меньше не стало. Да, конечно, вы можете переписать подписку иным способом, c использованием более современного подхода по автоматической отписке от потоков по завершению жизни компонента:

@Component({
  selector: 'counter',
  template: `{{ count }}`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Counter {
  zone = inject(NgZone);
  cd = inject(ChangeDetectorRef);
  destroyRef = inject(DestroyRef);
  count = 0;

  constructor() {
    this.zone.runOutsideAngular(() => {
      interval(1000)
        .pipe(takeUntilDestroyed(this.destroyRef))
        .subscribe(() => {
          this.count++;
          this.cd.detectChanges();
        });
    });
  }
}

Однако, все еще кажется, что у нас много избыточного кода.

Пример с RxJS + async pipe

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

import { AsyncPipe } from '@angular/common';
// ...

@Component({
  selector: 'counter',
  template: `{{ count | async }}`,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [AsyncPipe],
})
export class Counter {
  zone = inject(NgZone);
  count = interval(1000);
}

Согласитесь, код стал чище. Правда стоит отметить, что счетчик под капотом использует все тот же setInterval, а значит, он будет триггерить жизненный цикл компонентов за счет отслеживания событий через zone.js, и если вы хотите добиться уж совсем максимальной производительности, вам пригодится хелпер:

// данная утилита может быть переиспользована где угодно
export function runOutsideAngular<T>(ngZone = inject(NgZone)) {
  return (source$: Observable<T>) =>
    new Observable<T>((observer) => {
      return ngZone.runOutsideAngular(() => {
        return source$.subscribe({
          next: (value) => observer.next(value),
          error: (err) => observer.error(err),
          complete: () => observer.complete(),
        });
      });
    });
}

// counter.ts
@Component({
  selector: 'counter',
  template: `{{ count | async }}`,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [AsyncPipe],
})
export class Counter {
  zone = inject(NgZone);
  count = interval(1000).pipe(runOutsideAngular());
}

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

8. Async pipe под капотом

Чтобы лучше понять его работу, создадим свой собственный с нуля. Давайте назовем его SubscribePipe, чтобы не путаться далее в названиях.

Кроме того, мы хотим, чтобы наш пайп соблюдал следующее:

  • Memory Leak Prevention (отсутствие утечек памяти);

  • Automatic Subscription Management (у пользователя не должно быть нужды ручной подписки на потоки данных);

  • Value Unwrapping (мы должны уметь работать с Observable, в данном разборе опустим работу с входными Promises типами данных);

  • Change Detection Triggering (мы должны уметь помечать компоненты для обнаружения изменений, гарантируя эффективное обновление нашего представления во время рендеринга).

Пример:

@Component({
  selector: 'my-app',
  template: `
    @if (show) {
      Value: {{ obs$ | subscribe }}
    }
  `,
  standalone: true,
  imports: [CommonModule, SubscribePipe],

  // как только мы научимся работать с этим, раскомментируем
  // changeDetection: ChangeDetectionStrategy.OnPush 
})
export class AppComponent {
  show = true;

  obs$ = interval(1000).pipe(
    tap((x) => {
      if (x === 5) {
        this.show = false;
      }
    })
  );
}

Создаем наш пайп:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'subscribe',
  standalone: true // в новых версиях Angular атрибут уже по умолчанию
})
export class SubscribePipe implements PipeTransform {
  transform() {}
}

Итак, мы хотим, чтобы наш пайп умел принимать на вход данные типа Observable , а это значит, что нам нужно уметь обрабатывать как оригинальные Observables потоки, так и потоки высшего порядка, такие как Subjects, BehaviorSubjects и ReplaySubjects.

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

type Subscribable<T> = Observable<T> | Subject<T> | BehaviorSubject<T> | ReplaySubject<T>;

Теперь, когда мы знаем, что принимать, реализуем это в нашем коде! А еще сделаем наш код типобезопасным и будем также принимать undefined и null :

export class SubscribePipe<T> implements PipeTransform {
  transform(obs: Subscribable<T> | null | undefined) {}
}

Обработка подписки:

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

transform(obs: Subscribable<T> | null): T | null {
  if (!obs) {
    return null;
  }
}

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

latestValue: T | null = null;

transform(obs: Subscribable<T> | null): T | null {
  if (!obs) {
    return null;
  }

  obs.subscribe(value => {
    this.latestValue = value;
  });

  return this.latestValue;
}

И ой, это не будет работать как бы нам не хотелось. Почему?

Вспоминаем, то, что мы видели ранее. Наш переданный observable, это по сути ссылка на объект. А если ссылка не меняется, то и метод не будет вновь вызван, что логично. Это также означает, что первый вызов просто вернул самое первое latestValue значение, которое просто было проинициализировано как null.

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

@Pipe({
  name: 'subscribe',
  standalone: true,
  pure: false // <-- Как мы помним, по умолчанию true
})

Правда, когда мы запустим код, то увидим что-то странное:

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

Для начала разберемся с учеткой памяти, выполнив простую проверку на равенство. Мы сохраним текущую ссылку на observable, и каждый раз, когда будет срабатывать наш transform метод, мы будем проверять, равен ли он текущему, если да, достаточно вернуть уже закешированную ссылку.

private currentObs: Subscribable<T> | null = null;

transform(obs: Subscribable<T> | null): T | null {
  if (!obs) {
    return null;
  }

  if (obs === this.currentObs) { // <-- простое сравнение по ссылке
    return this.latestValue;
  } else {
    this.currentObs = obs; // <-- сохраняем ссылку на Observable

    obs.subscribe((value) => {
      this.latestValue = value;
    });
  }

  return this.latestValue;
}

Если мы сейчас проверим наше приложение, то увидим, что оно работает как надо!

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

Обработка отписки:

Так же, как мы сохранили latestValue и currentObs, создадим еще одно свойство в нашем классе, которое будет являться ссылкой на текущую подписку.

// ...
private sub: Subscription | null = null;

transform(obs: Subscribable<T> | null): T | null {
  // ...
  this.sub = obs.subscribe((value) => {
    this.latestValue = value;
  });
  // ...
}

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

export class SubscribePipe<T> implements PipeTransform, OnDestroy {
  // ...
  private sub: Subscription | null = null;

  transform(obs: Subscribable<T> | null): T | null {
      // ...
      this.sub = obs.subscribe((value) => {
        this.latestValue = value;
      });
      // ...
  }
  
  ngOnDestroy() {
    this.sub?.unsubscribe();
  }

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

@Component({
  selector: 'my-app',
  template: `
    <div *ngIf="show">{{ obs$ | subscribe }}</div>
  `,
  standalone: true,
  imports: [CommonModule, SubscribePipe],
})
export class AppComponent {
  show = true;

  ngOnInit() {
    setTimeout(() => {
      this.obs$ = of(20000);
    }, 2000);

    setTimeout(() => {
      this.obs$ = null;
    }, 4000);
  }

  obs$ = interval(1000).pipe(
    tap((x) => {
      if (x === 5) {
        this.show = false;
      }
    })
  );
}

Итак, как же нам нужно избавиться от подписок в этих случаях? Все просто. Давайте создадим метод dispose(), который будет иметь логику отписки, и будем вызывать его в нужные нам моменты времени.

private dispose() {
  if (this.sub) {
    this.sub.unsubscribe();
    this.sub = null;
  }
}

Итак, мы добавим его сюда, как это было изначально:

ngOnDestroy() {
  this.dispose();
}

И слегка модифицируем метод transform:

transform(obs: Subscribable<T> | null): T | null {
  if (!obs) {
    // Если уже была подписка, но в obs прилетел null
    this.dispose(); 
    return null;
  }

  if (obs === this.currentObs) {
    return this.latestValue;
  } else {
    // Перед подпиской на новый observable, 
    // обязательно отписываемся от предыдущего
    this.dispose();  

    this.currentObs = obs;

    this.sub = obs.subscribe((value) => {
      this.latestValue = value;
    });
  }

  return this.latestValue;
}

Если мы снова откроем наше приложение, увидим, что оно работает как часы! Да еще и без утечек памяти. Мы закончили? К сожалению, нет.

@Component({
  // ...
  changeDetection: ChangeDetectionStrategy.OnPush,
})

Если мы с вами переключим стратегию обнаружения на OnPush. Мы заметим, что код и вовсе не работает. Мы не увидим отображение отчета секунд в нашем примере.

Как нам быть теперь? Ну, мы можем попробовать с вами сделать так, как мы это делали в самых первых примерах, когда работали с нативным setInterval или тем же interval в примерах на RxJS и OnPush на компоненте:

private cd = inject(ChangeDetectorRef);

transform(obs: Subscribable<T> | null): T | null {
  // ...
  this.sub = obs.subscribe((value) => {
    this.latestValue = value;
    this.cd.detectChanges();
  });
  // ...
}

И когда мы зайдем на наш пример, то увидим, что все снова работает как надо:

Кстати, если зайти в исходники существующего async, то мы увидим, что там используется markForCheck, вместо detectChanges. Почему?

9. markForCheck vs detectChanges

Полагаю уже тысячи статей за 10 лет существования Angular было написано на данную тему, и тем не менее, давайте рассмотрим важные особенности на каком-нибудь примитивном примере. Допустим у нас с вами есть дерево компонентов с одной ветвью, состоящая из трех компонентов друг друга вложенных:

Далее в компонентах A и C, мы решили выводить значение счетчика из компонента B, который находится по середине, для одного он родительский (С), а для другого дочерний компонент (А). Сам же компонент (B) внутри себя будет генерировать значение и обновлять этот самый счетчик каждую секунду.

Default:

Рассмотрим ситуацию, при которой все наши компоненты имеют базовую стратегию обнаружения изменений.

@Component({
  selector: 'C',
  template: `<h1>C: {{ B.value }}</h1>`
})
export class C {
  B = inject(B);
}

@Component({
  selector: 'B',
  template: `
    <h1>B: {{ value }}</h1>
    <C></C>
  `,
  imports: [C]
})
export class B {
  value = 0;

  ngOnInit() {
    setInterval(() => {
      this.value++;
    }, 1000);
  }
}

@Component({
  selector: 'A',
  template: `
    <h1>A: {{ B.value }}</h1>
    <B></B>
  `,
  imports: [B]
})
export class A {
  @ViewChild(B, { static: true })
  B!: B;
}

// main.ts
bootstrapApplication(A);
ChangeDetectionStrategy.Default
ChangeDetectionStrategy.Default

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

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

Помните, мы выше уже говорили про zone.js библиотеку? Это та самая библиотека, которая занимается манкейпатчингом (monkey patch) всего браузерного API. На псевдокоде это выглядит так:

const originalSetInterval = window.setInterval;

window.setInterval = (callback, timer) => {
  const wrappedCallback = () => {
    currentZone.run(() => callback());
    // По завершению работы колбэка, 
    // тот, кто был подписан на отслеживаемые события (то есть Angular),
    // будет оповещен о завершении работы этого самого события
  };
  
  return Zone.current.scheduleMacroTask(wrappedCallback, timer, /* ... */);
};

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

Так вот у Angular, есть так называемый глобальный сервис ApplicationRef, в котором есть подписка на любые события от zone.js, когда такие события прилетают, он запускает волну обновлений, на псевдокоде это будет выглядеть так:

@Injectable({ 
  providedIn: 'root' 
})
export class ApplicationRef {
  // ...
  
  constructor(private readonly zone: NgZone) {
    this.zone.onStable.subscribe(() => this.tick());
  }

  tick() {
    // Выполнение Change Detection на дереве компонентов, начиная от корня
    rootComponent.detectChanges(); // в нашем случае (A).detecthChanges();
  }
}
ApplicationRef подписан на события для запуска волн обновления дерева компонентов
ApplicationRef подписан на события для запуска волн обновления дерева компонентов

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

Последовательное каскадное обновление
Последовательное каскадное обновление

Кстати, чтобы убедиться, что за нас делает всю работу zone.js, достаточно его отключить на уровне приложения:

// main.ts
bootstrapApplication(A, {
  providers: [provideZonelessChangeDetection()],
});

И вы не увидите никаких обновлений в UI, так как никто не вызывает detectChanges. Что же, пока вернем на место все как было и обратно включим zone.js , давайте посмотрим, что поменяется теперь у нас в довесок с использованием OnPush стратегии.

OnPush:

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

Поэтому команда из Google изначально дала возможность разработчикам контролировать, когда именно Angular должен проверять компонент на изменения, тем самым уменьшая количество лишних проверок и ре-рендера.

Теперь в компонентах A, B, C, давайте пропишем в декораторе следующее свойство changeDetection: ChangeDetectionStrategy.OnPush:

Стоп, почему опять ничего не происходит, будто мы выключили zone.js?

На самом деле, zone.js включен и ApplicationRef, как ему и положено, вызывает detectChanges на рутовом компоненте, однако, тут надо залезать в документацию и читать, что происходит в момент вызова detectChanges на компонентах.

OnPush оптимизирует ваш компонент таким образом, что проверяет/рендерит его только в определенных случаях:

  • Если входные параметры компонента изменились:

    • Если это @Input декоратор, то ссылки переданные на вход вашего компонента должны быть иммутабельными, чтобы Angular смог отследить изменение;

    • Если это сигнальный input(), достаточно изменить его состояние.

  • Если компонент помечен как dirty (грязный), то есть нуждающийся в перерисовке;

  • Если произошли какие-то всплывающие события (Event binding) из дочерних компонентов.

Хорошо, как вы помните в нашем рутовом компоненте мы не имеем никаких инпут-параметров, а на компонент B мы даже не подписаны на какое-нибудь (event) событие:

@Component({
  selector: 'A',
  template: `
    <h1>A: {{ B.value }}</h1>
    <B></B>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
  // ..
})
export class A {
 // ..
}

Отсюда логично, что detectChanges просто напросто не вызывается и не рендерится ничего, потому что условия выше не выполняются. Мы можем поступить хитро с вами, а именно в этом компоненте поменять стратегию обнаружения на ChangeDetectionStrategy.Default и увидим следующую картину:

Цепочка действий по итогу такая:

setInterval event
-> zone.js event
-> ApplicationRef
-> A.detectChanges()

Вопрос, почему в компоненте B не перерисовывается ничего? Снова вспоминаем, так как он у нас OnPush, и на нем не выполняются условия выше для старта проверок, то значит и рендеринг не будет запущен.

detectChanges:

Хорошо, мы как разработчики компонента B, очень хотим, чтобы он у нас отрисовывался как надо. Давайте воспользуемся волшебным ручным запуском обнаружения изменений:

@Component({
  selector: 'B',
  template: `
    <h1>B: {{ value }}</h1>
    <C></C>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  // ...
})
export class B {
  cd = inject(ChangeDetectorRef);

  value = 0;

  ngOnInit() {
    setInterval(() => {
      this.value++;
      this.cd.detectChanges(); // Делаем точечную проверку компонента
    }, 1000);
  }
}

@Component({
  selector: 'A',
  template: `
    <h1>A: {{ B.value }}</h1>
    <B></B>
  `,
  changeDetection: ChangeDetectionStrategy.Default
  // ..
})
export class A {
  @ViewChild(B, { static: true })
  B!: B;
}

Цепочка действий по итогу такая:

setInterval event
-> B.detectChanges()
-> zone.js event
-> ApplicationRef
-> A.detectChanges()

Как вы видите, теперь цепочка не совсем однонаправленная, но рабочая, ведь так наш рендеринг уже будет работать чуть-чуть так как нам надо. Сначала будет принудительно вычислен шаблон компонента B, а затем пересчитается шаблон компонента А, потому что он у нас Default стратегии.

Если мы снова поменяем у компонента А, стратегию на OnPush, цепочка действий останется такой же, но результат будет следующим:

Как вы видите, после React(a) с его прозрачным однонаправленным рендерингом виртуального DOM дерева вам может показаться, что многие вещи не очень очевидны в Angular. Но с другой стороны, вы имеете полный контроль над точечным рендерингом в определенной области вашего приложения, просто нужно немного привыкнуть к механизму работы.

markForCheck:

Как вы могли заметить, результаты выше не совсем то, чего мы хотим. Давайте теперь воспользуемся нашим уже упомянутым markForCheck, который мы видели в исходниках async пайпа.

@Component({
  // ...
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class C {
  // ...
}

@Component({
  // ..
  changeDetection: ChangeDetectionStrategy.OnPush,
  // ...
})
export class B {
  cd = inject(ChangeDetectorRef);

  value = 0;

  ngOnInit() {
    setInterval(() => {
      this.value++;
      this.cd.markForCheck(); // Помечаем компоненты A, B "грязными"
    }, 1000);
  }
}


@Component({
  // ...
  changeDetection: ChangeDetectionStrategy.OnPush
  // ..
})
export class A {
 // ...
}

Теперь цепочка действий такая:

setInterval event
-> B.markForCheck()
-> B (marked as dirty)
-> A (marked as dirty)
-> zone.js event
-> ApplicationRef
-> A.detectChanges()
-> B.detectChanges()

Итак, markForCheck это своего рода палочка-выручалочка, ведь нам уже не важно, какие родительские компоненты на уровне выше, были ли они Default или OnPush, они все равно будут принудительно проверены. Да, так это и работает.

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

Zoneless + OnPush:

А теперь перепишем наш код совсем по-другому, не используя вышеупомянутое API для проверок обнаружения изменений:

@Component({
  selector: 'C',
  template: `<h1>C: {{ value }}</h1>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class C {
  @Input() value?: number;
}

@Component({
  selector: 'B',
  template: `
    <h1>B: {{ value }}</h1>
    <C [value]="value"></C>
  `,
  imports: [C],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class B {
  @Input() value = 0;
  @Output() valueChange = new EventEmitter<number>();

  ngOnInit() {
    setInterval(() => {
      this.valueChange.emit(this.value + 1);
    }, 1000);
  }
}

@Component({
  selector: 'A',
  template: `
    <h1>A: {{ value }}</h1>
    <B [value]="value" (valueChange)="value = $event"></B>
  `,
  imports: [B],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class A {
  value = 0;
}

// main.ts
bootstrapApplication(A, {
  providers: [provideZonelessChangeDetection()],
});

Вот теперь все работает действительно как надо, все наши компоненты являются OnPush и мы больше не полагаемся на zone.js.

Вспоминаем, что в традиционном Angular с зонами setInterval автоматически запускает ChangeDetection посредством ApplicationRef.tick(). Однако, в Zoneless-режиме этот сервис больше не вызывается напрямую, так как теперь Angular использует более точечный механизм обновлений на основе событий и реактивных источников, например, ObservableSignal, в нашем случае EventEmitter.

Цепочка действий:

setInterval event
-> B (valueChange.emit)
-> B (marked as dirty
-> A (marked as dirty)
-> ChangeDetectionScheduler
-> A.detectChanges()
-> B.detectChanges()

В финальном примере, если раньше у нас была связка Zone.js + ApplicationRef. То теперь за обновление отвечает один ChangeDetectionScheduler (сервис-планировщик), который вызывает detectChanges по окончанию микротасок на стеке задач.

Более подробно можно изучить исходники тут.

Zoneless + OnPush + signals/effects:

Не пугайтесь, я продемонстрирую еще один вариант, но уже с новыми модными реактивными примитивами - сигналами. Этот подход считается более предпочтительным в написании приложений на Angular в 2025:

@Component({
  selector: 'C',
  template: `<h1>C: {{ value() }}</h1>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class C {
  value = input.required<number>();
}

@Component({
  selector: 'B',
  template: `
    <h1>B: {{ value() }}</h1>
    <C [value]="value()"></C>
  `,
  imports: [C],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class B {
  value = input.required<number>();
  valueChange = output<number>();

  ngOnInit() {
    setInterval(() => this.valueChange.emit(this.value() + 1), 1000);
  }
}

@Component({
  selector: 'A',
  template: `
    <h1>A: {{ value() }}</h1>
    <B [(value)]="value"></B>
  `,
  imports: [B],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class A {
  value = signal(0);
}

И, кстати, вот вам домашнее задание, какой вариант лучше всего для Change Detection? Есть ли разница или нет? Какие могут быть тут подводные камни, преимущества?

Вариант 1:

value = input.required<number>();
valueChange = output<number>();

ngOnInit() {
  setInterval(() => this.valueChange.emit(this.value() + 1), 1000);
}

Вариант 2:

value = input.required<number>();
valueChange = output<number>();

newValue = linkedSignal(() => this.value());
newValueEffect = effect(() => this.valueChange.emit(this.newValue()));

ngOnInit() {
  setInterval(() => this.newValue.update((x) => ++x), 1000);
}

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

10. Какие есть проблемы у AsyncPipe?

Как мы теперь знаем, AsyncPipe довольно удобный инструмент для работы с Observable и Promise в шаблонах, но поговорим чуть больше о его недостатках.

1. Автоматическая отписка (может быть неожиданной)

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

@Component({
  selector: 'news',
  template: `
    <button (click)="show = !show">Toggle</button>

    @if (show) {
      {{ news$ | async }}
    } 
  `
})
export class News {
  show = true;
  
  news$ = webSocket('wss://example.com/news').pipe(
    tap(message => console.log(`Сообщение: ${message}`))
  );
}

Вероятнее всего пример слишком надуманный, но хорошо показывает, когда AsyncPipe вредит, а не помогает. Подписавшись на поток, мы получаем сообщения, стоит только скрыть нам наш блок по клику (show = false), как async пайп отпишется от WebSocket(автоматически закрывается). И при следующем показе блока (show = true), наши сообщения из news$ больше не возобновятся. Итог: потеря соединения и данных.

2. Множественные подписки могут создать проблемы с производительностью

Если AsyncPipe используется несколько раз для одного и того же Observable в шаблоне:

<button
    type="button"
    [attr.title]="(i18n$ | async)?.text"
>
    {{ (i18n$ | async)?.text }}
</button>

Проблема в том, что каждая конструкция | async создает новую подписку, что может привести к утечкам памяти и лишним вычислениям. В качестве решения, рекомендуется кешировать значение:

@let i18n = i18n$ | async;

<button type="button" [attr.title]="i18n?.text">
    {{ i18n?.text }}
</button>

Но самая классическая ошибка, это использование async пайпа в старой конструкции ngFor без использования trackBy:

@Component({
  template: `
    <ul>
      <li *ngFor="let id of ids">
        {{ getUserById(id) | async }}
      </li>
    </ul>
  `
})
export class Users {
  http = inject(HttpClient);
  ids = [1, 2, 3, 4, 5];
  
  getUserById(id: number): Observable<string> {
    return this.http.get<string>(`/api/user/${id}`);
  }
}

Здесь для каждого элемента создается своя подписка. При любом изменении списка ids Angular будет перерисовывать все DOM узлы с нуля, создавая новые подписки, не успев отписаться вовремя от старых (только если ваш сервер медленный). Итог: потенциальная утечка памяти, сетевые перегрузки, дергание UI.

3. Нет обработки ошибок из коробки, сложность отладки

Не самая критичная, но для кого-то может стоить нескольких часов дебага. Это то, что AsyncPipe молча проглатывает ошибки из Observable / Promise. Если стрим упадет с ошибкой, в шаблоне просто ничего не обновится и вы ничего не заметите. В качестве решения, следует обрабатывать ваши стримы с использованием  catchError или tap.

4. Задержка обновления UI или перегрузка ChangeDetection(а)

Как мы уже выяснили AsyncPipe вызывает markForCheck() под капотом, даже уже поняли, насколько форсированно это может влиять на все дерево компонентов. Делаем вывод, что при каждом новом значении в стриме это приводит к лишним проверкам изменений всего нашего приложения, в том случае, если подписок слишком много, они все в разных местах, а новые значения в этих потоках слишком часто появляются. Даже в сочетании с OnPush  это может все равно приводить к "мерцанию" интерфейса. В качестве решения, часто можно заметить, как в приложениях чаще всего вешают debounceTime, share, distinctUntilChanged операторы на такие подписки.

5. Ограниченная гибкость и проблемы с null, undefined

Сигнатура async пайпа в Angular, возвращающая null или undefined имеет ряд неудобств, особенно когда мы хотим сделать наш код строго-типизированным, чистым и предсказуемым в шаблоне.

Мы вынуждены везде делать проверку на null:

@let user = user$ | async;

@if (user) {
  <h1>Hello, {{user.name}}</h1>
}

Или

<h1>Hello, {{ (user$ | async)?.name }}</h1>

Итог: мусорный код, особенно при глубокой вложенности.

Еще с async мы имеем непредсказуемость, так как null может означать:

  • Данные еще не пришли данные;

  • Произошла ошибка;

  • Observable просто завершился;

  • Компонент был уничтожен;

  • Нельзя отличить "нет значения" от "пришел null":

    of(null)asyncnull
    never()asyncnull

  • Чаще всего нам нужно писать safe navigation. Возникает вопрос, это проблема тех кто передает данные, или тех кто пишет компонент и должен уметь обрабатывать входные параметры на null/undefined?

<user [name]="(user$ | async)?.name ?? ''" />

6. У пайпов нет возможности писать выражения в HostBinding

Иногда при разработке компонентов необходимо работать с состоянием через host-атрибуты, к сожалению, такой возможности нет в Angular при использовании | пайпов:

@Component({
  // ...
  host: {
    '[class.loaded]': '!(data$ | async)'
  },
})
export class User {
  // ...
}

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

11. Сигналы

1. Использование реактивного способа работы с HTTP

Чаще всего AsyncPipe нужен был для работы с HTTP-запросами. Однако на смену старой парадигме в Angular 17 появились сигналы, которые привнесли новую жизнь в написание более чистого, предсказуемого и удобного кода.

@Component({
  template: `
   @switch (user().state) {
      @case ('pending') {
        <p>Загрузка...</p>
      }
      @case ('error') {
        <p>Ошибка</p>
      }
      @case ('success') {
        <p>{{ user().data.name }}</p>
      }
   }
  `
})
export class User {
  http = inject(HttpClient);
  readonly user = httpResource(() => this.http.get<User>('/api/user'));
}

К тому же, с сигналами удобно работать еще и с host атрибутами компонента:

@Component({
  // ...
  host: {
    '[class.active]': 'user().state === "success"',
    '[class.loading]': 'user().state === "pending"',
  }
})
export class User {
  http = inject(HttpClient);
  readonly user = httpResource(() => this.http.get<User>('/api/user'));
}

Альтернативой вы можете еще использовать computed сигналы:

import { computed } from '@angular/core';

// В классе компонента:
isActive = computed(() => this.user().state === 'success');

// в декораторе @Component:
host: {
  '[class.active]': 'isActive()',
}

2. toSignal - это современная альтернатива AsyncPipe

В Angular 17, у нас появилась возможность переписать все наши Observable потоки на сигналы используя функцию-конвертер:

import { toSignal } from '@angular/core/rxjs-interop';

// в классе компонента:
readonly data = toSignal(this.data$, { initialValue: null });

По такому принципу мы можем использовать везде наши сигналы, без async пайпа и не переживать о дополнительных подписках в компонентах:

// в классе компонента:
readonly i18n = toSignal(this.i18n$, { initialValue: null });

// в шаблоне компонента:
<button type="button" [attr.title]="i18n()?.text">
  {{ i18n()?.text }}
</button>

Типобезопасность без null / undefined

Что удобно, мы сами можем решать, будет ли null значением по умолчанию, или это будет явно описанный fallback, что улучшает нашу типизацию кода на выходе:

// user: User
readonly user = toSignal(data$, { initialValue: { name: 'Максим', age: 31 } });

Нет автоматической отписки / пересозданий

Если async pipe привязан к рантайму View нашего компонента, то в момент срабатывания @if, @switch или других условных конструкций, мы можем потерять данные. А вот Signal живет все время в компоненте и не зависит от его DOM. Это бывает важно если мы не хотим терять данные от WebSocket, Subject и других горячих потоков.

Производительность (CD не триггерится зазря)

Signals работают с fine-grained reactivity (во всяком случае, так заявлено). Как это устроено сейчас я вам ответить не могу точно, но сигналы точно не триггерят change detection на глаз, как это async и его markForCheck на все случаи жизни.

Однако, у toSignal есть подводные камни!

Не отписывается автоматически при destroy компонента

Как мы знаем async пайп мог отписываться от потока автоматически, когда часть view-отображения этого самого компонента уничтожалось посредством удаления DOM узлов из-за срабатывания событий конструкции @if или router-outlet. А вот toSignal() живет, пока жив компонент, и не отписывается сам — особенно если мы создаем сигналы вне inject() контекста. Сложно, правда?

@Injectable({
  providedIn: 'root'
})
сlass MyService {
  // ❌ Плохо! Подписка будет жить вечно, 
  // так как сигнал не привязан к жизненному циклу компонента
  user = toSignal(this.http.get('/api/user'));
}

Angular Signals используют Dependency Injection через inject() для автоматического управления жизненным циклом, особенно при уничтожении. В случае с компонентами, у каждого компонента есть свой DestroyRef сервис, на который подписываются сигналы для отписки от потоков, а вот с другим сущностями могут быть проблемы. Забыть отписаться, это всегда плохо. Ведь это может приводить к утечке памяти (если вы используете таймер / бесконечный interval) или перегрузить сеть (например, вы создали соединение по WebSocket, которое вечно висит).

Подписка начинается сразу

Наверное, это самое непривычное отличие после использования async пайпа. toSignal() немедленно подписывается, в момент создания, даже если мы еще ничего не вызывали и не описывали в шаблоне или коде компонента. В этом случае, async пайп имеет преимущество, так как имеет lazy-подписку. Так как, в случае с сигналом это может приводить к раннему HTTP-запросу, даже если данные нам пока не нужны.

Как обходной путь, это использовать computed сигнал:

// В классе компонента:
readonly id = signal<number | null>(null);

readonly user = computed(() => {
  const id = this.id();
  
  if (id === null) {
    return null;
  }

  const user$ = this.http.get<User>(`/api/user/${id}`);
  
  return toSignal(user$, { initialValue: null });
});

// Потом в шаблоне:
@let user = user();

@if (user) {
 {{ user.name }}
}

В данном примере выше лучше использовать для работы с сетью httpResource, так как за нас упростили работу с ненужными мгновенными подписками. Кстати, домашнее задание, написать свою функцию lazyToSignal.

3. Отказ в пользу computed сигнал от пайпов

За многие годы, мы привыкли к Angular-классике, с его RxJS и пайп подходам, однако, на смену нам пришла Signal-First архитектура. Возникает вопрос, можем ли мы отказаться от использования пайпов и всегда использовать только сигналы в шаблонах?

В целом, да, мы можем почти полностью отказаться от пайпов | async, | date, | number и т.д. в пользу сигналов с обычными функциями, но это не всегда разумно.

readonly user = toSignal(this.http.get<User>('/api/user'), {
  initialValue: null,
});

// в шаблоне, итог | async больше не нужен.
{{ user()?.name }}
readonly formatted = computed(() => {
  const date = this.date();
  return date ? new Intl.DateTimeFormat('ru-RU').format(date) : '';
});

// в шаблоне, итог | date больше не нужен.
{{ formatted() }}
// или сразу
{{ new Intl.DateTimeFormat('ru-RU').format(user()?.birthDate) }}
readonly message = computed(() => {
  const count = this.items();
  return count === 1 ? 'Один товар' : `${count} товаров`;
});

// в шаблоне, итог | i18nPlural больше не нужен.
{{ message() }}

С одном стороны, с сигналами у нас появилось больше гибкости, но теряется вся "магия" от использования пайпов, местами даже немного громоздко и плохо читается в шаблоне. А keyvalue пайп так вообще непонятно нужно ли заменять (да и как?). Но есть вероятность, что в будущем в Angular мы можем увидеть ряд хелперов, которые будут напоминать нам React-way с его use-хуками, в виде useDateSignal, useCurrencySignal, usePluralSignal и тд.

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

@Component({
  selector: 'app',
  standalone: true,
  imports: [FormsModule],
  template: `
     Имя: 
      <input [(ngModel)]="name" />
      <br />
      Фамилия: 
      <input [(ngModel)]="lastname" />
      <br />
      <br />
      {{ nameToUppercase() }}
      {{ lastnameToUppercase() }}
  `,
})
export class App {
  name = model('');
  lastname = model('');

  nameToUppercase = computed(() => this.name().toUpperCase());
  lastnameToUppercase = computed(() => this.lastname().toUpperCase());
}

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

12. Заключение

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

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

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

Список дополнительных материалов

1) Мемоизация в JS и ускорение функций
2) OnPush — ваш новый Default
3) Angular Performance Checklist
4) New possibilities with Angular’s push pipe

Комментарии (0)