Что новенького в Angular 19
- среда, 5 марта 2025 г. в 00:00:10
Всем привет, с вами Максим Иванов, и сегодня мы поговорим о некоторых улучшениях, которые появились в последней версии Angular 19, вышедшей в ноябре 2024. Публикация довольно запоздалая с этой точки зрения, но мы сегодня постараемся рассмотреть некоторые нововведения более детально. Как и всегда ребята из Google дарят нам что-то новое, что сделает наши приложения еще более быстрыми и эффективными. И в этом нам помогут модерновые реактивные примитивы, инкрементальная гидратация и многое другое. Поехали!
Прежде, чем мы начнем говорить о нововведениях, давайте разберемся с некоторой предысторией о том, что было до нашей эры так сказать, что у нас появилось кардинального в Angular, благодаря чему наши приложения изменились или изменятся очень сильно в будущем.
Представьте, что вы писали свои приложения на Angular и вашей последней версией был Angular 10. Потом вы ушли в долгий отпуск или переквалифицировались в бэкендера на 2+ года и занимались совсем другими делами. За это время вышло несколько версий Angular. И вот вас вновь просят поднять проект, который вы писали когда-то и на этот раз вы решили обновиться на свежую версию. Вы начинаете читать документацию и видите, что будто бы подходы к написанию кода изменились, да и сам фреймворк словно стал другим, ну чуть-чуть.
В принципе, вы могли вполне написать такой код ниже на Angular 10 и справедливости ради он будет работать в Angular 19:
import { ChangeDetectionStrategy, Component, VERSION } from '@angular/core';
@Component({
selector: 'my-app',
template: `
<p>value: {{ value }}</p>
<p>square: {{ square() }}</p>
<button (click)="increment()">Increment</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
value = 0;
square = () => this.value ** 2;
increment = () => this.value++;
}
Не обращаем внимания будто здесь что-то не так, например, вызов функции в шаблоне без мемоизации или использования пайпа, ведь это считается плохой практикой. Плюсом не обращаем внимания на использование стрелочных функций будто мы пишем на React, это сейчас не важно. Мы просто принимаем тот факт, что код работает и выполняет свою функцию. А именно, при нажатии по кнопке Increment в нашем примере angular вызовет рендер шаблона. Далее мы увидим на экране браузера как увеличивается значение счетчика, а рядом и результат его возведения в степень двойки.
С одной стороны, тут все ясно и понятно. У нас код почти на чистом JavaScript. Есть значение и операции над ним. Но есть некоторый нюанс. Когда вы сталкиваетесь с написанием сложных приложений вы редко будете работать с примитивными данными. И скорее всего вы будете писать код на RxJS, ну во всяком случае, раньше так точно было. Во вторых, работа с примитивами порой бывает опасной, так как на практике бывают ситуации, когда какие-то дочерние компоненты пытаются мутировать данные изнутри. А раз такое бывает, то и ситуация может быть непредсказуемой для рендеринга. То есть вы будете замечать странные артефакты отрисовки частей вашего UI. Я хочу сказать, что change detection может плохо подпинываться, такое в Angular когда-то было сплошь и рядом.
И знаете, со временем, а начиная с Angular 2 фреймворку уже исполнится 10 лет в следующем году, он накопил болячки, которые так и не смог быстро побороть. К примеру, Zone.js библиотека. По сути, эта библиотека является менеджером, управляющим в какой момент запускать цикл обнаружения изменений, но лично мне не хочется долго разговаривать об этом, так как подводные камни при работе Angular давно известны. Я еще помню, начиная с Angular 6, эту библиотеку грозились выпилить, однако это длилось долго по объективным причинам. Не было альтернативы. Сейчас же последние несколько лет мы видимо как плавно фреймворк развивается в сторону zoneless.
Кстати, в этом нам как раз помогут реактивные примитивы. Идея в том, что давайте просто будем писать код таким образом, чтобы можно было подсказывать фреймворку, что вот тут данные изменились, а не сам фреймворк догадывался, что обновить, а что нет. И таким образом в дополнение к Angular 16 появилась концепция реактивных примитивов (Angular Signals).
const a = 1; // javascript primitive
//
const b = signal(a); // reactive primitive (обертка над обычным js примитивом)
Вы оборачиваете значение в сигнал (реактивный примитив) и теперь вы можете работать с такими данными в парадигме реактивного программирования.
К примеру, в императивном программировании присваивание a = b + c будет означать, что переменной a будет присвоен результат выполнения операции b + c, используя текущие (на момент вычисления) значения переменных. Позже значения переменных b и c могут быть изменены без какого-либо влияния на значение переменной a.
В реактивном же программировании значение a будет автоматически пересчитано, основываясь на новых значениях.
Дорогой читатель, я понимаю твою озабоченность в том, а зачем тебе все эти концепции, если до этого я вполне хорошо себя чувствовал, когда писал код на таких библиотеках/фреймворках как React, Svelte, Vue, которые не считаются адептами реактивного программирования. Опять же, как считать. В целом, на React вы можете, как и на любом другом инструменте, просто возможно среди коммьюнити и авторов того или иного инструмента приняты иные подходы. В Angular же изначально закладывались идеи такие, которые помогли бы вам писать сложные Enterprise-решения для работы с асинхронностью, взаимодействующие с потоковой обработкой данных.
Да, недостаточно. А если серьезно, RxJS является сторонней и независимой библиотекой. В то время как сигналы это полностью написанная с нуля библиотека командой Angular, тесно завязанная на работу самого фреймворка. С другой стороны, вы тоже можете ее использовать в других фреймворках, если вас не смущает пакет с названием @angular/signals
в проекте на React, к примеру.
И у меня есть смутное подозрение, что в будущем команда Angular постарается написать как можно больше хелперов, что позволит вам не подключать в проект библиотеку rxjs в принципе, в том случае, если вы мало используете API самой библиотеки, а базового функционала из коробки вам хватает.
Теперь в большинстве случаев, а я надеюсь всегда, мы будем писать код с использование реактивных примитивов. Внимательный читатель может заметить, будто мы меняем шило на мыло. Кода в моменте, как в данном примере, даже стало больше, чем его было с обычными JS примитивами:
@Component({
selector: 'my-app',
standalone: true,
template: `
<p>value: {{ value() }}</p>
<p>square: {{ square() }}</p>
<button (click)="increment()">Increment</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
value = signal(0);
square = computed(() => this.value() ** 2);
increment = () => this.value.update((value) => value + 1);
}
Но на деле же, мы получили ряд преимуществ, отталкиваясь от которых можно дальше развивать фреймворк в нужном направлении:
Реактивность
Да у нас уже был RxJS, но теперь это реальная часть фреймворка, а не стороннее решение;
Точечные обновления и улучшение производительности приложений
Пока это не так, но концепция перехода на сигналы, дает фреймворку в будущем, не опираться на все дерево компонентов и его цикл обнаружения изменений, который следует от корневого элемента. В будущем, там где данные поменялись, в том месте и должна произойти перерисовка и пересчет. Сейчас же из-за сложной природы обнаружения изменений, в случае любого изменения вся иерархия компонентов повторно оценивается на предмет, а изменилось ли что-то. Хотя change detection со стратегией OnPush эффективен, для более крупных приложений все равно есть ряд случаев, когда это может создать проблемы с производительностью. Как мы выяснили, сигнал — это реактивный примитив, и после изменения его значения сам сигнал уведомит всех потребителей. А потребитель уже дальше сделают все нужную нам работу. Например, компонент напрямую обновится независимо, без выполнения цикла обнаружения изменений для всей иерархии компонентов.
Сокращение размера приложения
Если нам уже не потребуется тащить zone.js и даже саму rxjs библиотеку в простых приложениях в рантайм, то логично, что мы сэкономим кб трафика нашим пользователям при первоначальной загрузке приложения.
Сигналы в Angular — это функции без аргументов() => T
, вызывая ее, вы получаете текущее значение этого сигнала.
const counter = signal(0); // WritableSignal<number>
console.log( counter() ); // 0
counter.set(2);
console.log( counter() ); // 2
Хотя сигналы и называются реактивными (просто понятие, которое вас отсылает к парадигме реактивного программирования), следует отметить, что:
Signal является синхронным в отличие от большинства действий с потоками данных в RxJS;
Signal не являются observables или как таковой заменой RxJS. Стоит отметить, что в скором времени у нас появится первая нативная реализация в Chrome (Intent to Ship: Observable API), где мы сможем использовать RxJS прямо в браузере без подключения сторонних скриптов.
Выше в примерах вы могли заметить, что мы использовали функцию computed, она хорошо подходит для того, чтобы реактивно получать значение, в случае когда ее зависимые сигналы изменились. Работает достаточно просто:
const counter = signal(0); // WritableSignal<number>
// Автоматически обновляется при изменении `counter`:
const isEven = computed(() => counter() % 2 === 0); // Signal<number>
Стоит отметить, что производные значения тоже являются сигналами, однако уже недоступными на запись, то есть у них нет методов update/set, к примеру.
Что делать, если я хочу иметь асинхронную логику? То есть, написать так:
const a = signal("");
const b = signal(false);
// ...
const items = computed(async () => await query(a() ,b())); // ❌
Ну, во всяком случае, вы получите не совпадение типов, потому что сигналы предназначены для работы в синхронном порядке. Поэтому если вам нужна асинхронная логика, вы все еще можете использовать RxJS. А если вам при этом нужны сигналы, то можно еще и воспользоваться функцией toSignal
.
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: `{{ counter() }}`,
})
export class Ticker {
counter$ = interval(1000);
counter = toSignal(this.counter$, { initialValue: 0 });
}
Как видите мы можем легко работать с асинхронными потоками данных и любыми функциями и операторами высшего порядка из RxJS. Более подробнее можно почитать тут.
Ну что же, мы добрались наконец-то до новой функции, которая появилась в Angular 19, релиз которого оказался многообещающим. Вкратце, linkedSignal
похож на computed
функцию, c той лишь разницей, что теперь это не просто readonly сигнал, а в него можно пробрасывать значения.
Чтобы разобраться, давайте рассмотрим пару простых примеров. Предположим, у нас есть список элементов, и мы хотим вывести количество элементов в этом списке:
const listOfItems = signal([ 'item1', 'item2', 'item3' ]); // WritableSignal<number[]>
const countOfItems = linkedSignal(() => this.listOfItems().length); // WritableSignal<number[]>
// const countOfItems = computed(() => this.listOfItems().length);
/**
* @experimental
*/
export declare function linkedSignal<D>(
computation: () => D,
options?: { equal?: ValueEqualityFn<NoInfer<D>>; }
): WritableSignal<D>;
export declare function linkedSignal<S, D>(options: {
source: () => S;
computation: (source: NoInfer<S>, previous?: {
source: NoInfer<S>;
value: NoInfer<D>;
}) => D;
equal?: ValueEqualityFn<NoInfer<D>>;
}): WritableSignal<D>;
Синтаксис здесь действительно похож на то, как мы работали с computed
, где состояние выводится из исходного сигнала с той лишь разницей, что потом вы можете делать так:
changeTheCountOfItems() {
this.countOfItems.set(0);
}
Кстати, у linkedSignal
есть еще одна форма записи:
const countOfItems = linkedSignal({
source: this.listOfItems,
computation: (items) => items.length,
});
В этой записи linkedSignal
принимает ссылку на сигнал, и когда значение этого сигнала изменится, вызовется computation
функция. Не вдаваясь в отличия, мы ожидаем, что если сигнал listOfitems
имеет 3 элемента, то countOfItems
вернет значение 3. А если дальше в коде, мы добавим еще один элемент в listOfItems
, мы ожидаем, что countOfItems
динамически изменится на 4.
this.listOfItems.update((items) => [...items, 'item4']);
И на первый взгляд кажется, что первая запись всего лишь синтаксический сахар. Что же, давайте разберемся, в каких случаях, что удобнее использовать.
В Angular для передачи данных в компонент используются input
параметры (props
, выражаясь react терминологией). Несколько версий назад до Angular 17 вы традиционно использовали для этого декоратор @Input
@Component({
...
})
export class UserProfileComponent {
@Input() firstName?: string; // string|undefined
@Input({ required: true }) lastName!: string; // string
@Input() age = 0; // number
}
Но теперь с приходом нового подхода к написанию кода, данному декоратору на смену пришел input()
сигнал:
@Component({
...
})
export class UserProfileComponent {
firstName = input<string>(); // Signal<string|undefined>
lastName = input.required<string>(); // Signal<string>
age = input(0); // Signal<number>
}
Как вы понимаете декораторы потихоньку канули в лету и все менее популярны, да и чего греха таить, дизайн-паттерн с декораторами все же имеет сильное сопротивление в JavaScript сообществе среди начинающих фронтенд-разработчиков. Да и в самом Angular при работе с декораторами не все было гладко.
Что стоит отметить, входные input
сигналы не являются WritableSignal
, однако на практике бывают случаи, когда нам нужно обновить их значение. Давайте рассмотрим короткий пример такого компонента как аккордеон, где мы должны переключать его состояние, когда мы нажимаем на него.
@Component({
selector: 'app-accordion',
template: `
<div class="accordion">
<div
(click)="toggle()"
[class.chevron-down]="!isOpen()"
[class.chevron-up]="isOpen()"
>
{{ isOpen() ? 'Close' : 'Open' }} Accordion
</div>
@if (isOpen()) {
<div class="content">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
</div>
}
</div>
`,
styles: `
// Тут наши стили компонента
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccordionComponent {
isOpen = signal(false);
toggle() {
this.isOpen.set(!this.isOpen());
}
}
Тут все просто, у нас есть обычное состояние в виде сигнала isOpen
, которое мы можем менять - открытие/закрытие. Однако, в нашем случае, компонент не является кастомизируемым, а ведь часто бывает так, что пользователи компонента захотят передавать значение по умолчанию извне. Как быть?
Чтобы решить эту проблему мы меняем signal
на input
функцию и теперь потребители этого компонента, легко могут пробрасывать значение isOpen
. Но обратите внимание, что входной парамет теперь не является перезаписываемым и мы не сможем уже изменить состояние из нашего шаблона. Вот по этой причине нам на помощь и приходит linkedSignal
.
export class AccordionComponent {
readonly isOpen = input(false);
state = linkedSignal(() => this.isOpen());
toggle() {
this.state.set(!this.state());
}
}
В HTML шаблоне, мы больше не взаимодействуем с нашим input
параметром напрямую и он не подвергается попыткам изменить его, для этого у нас есть значение state
.
<div class="accordion">
<div
(click)="toggle()"
[class.chevron-down]="!state()"
[class.chevron-up]="state()"
>
{{ state() ? "Close" : "Open" }} Accordion
</div>
@if (state()) {
<div class="content">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
</div>
}
</div>
Давайте рассмотрим еще один пример только уже с раскрывающимся списком, элементы которого могут изменяться по мере получения ответов от http-запросов, ну или любого другого источника данных.
На изображении выше у нас нет выбранных элементов, поэтому Selected
имеет значение null
. Если мы выбираем элемент, мы ожидаем, что у нас будет:
<mat-form-field appearance="fill">
<mat-label>Select an item</mat-label>
<mat-select [(value)]="selectedItem">
<mat-option [value]="null">Select</mat-option>
@for (item of listOfItems(); track $index) {
<mat-option [value]="item">
{{ item.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
Selected: {{ selectedItem() | json }}
В качестве селект-компонента берем готовый из Material. Далее в компоненте:
selectedItem = signal<Item | null>(null);
listOfItems: WritableSignal<Item[]> = signal([
{ id: 1, name: 'item 1' },
{ id: 2, name: 'item 2' },
{ id: 3, name: 'item 3' },
]);
Код действительно прост. Сигнал selectedItem
отвечает за выбранное состояния селекта, а сигналlistOfItems
с массивом представляет наш источник данных.
Предположим, что в listOfItems
динамически прилетают значения от http-запроса. При этом мы хотим очистить состояние selectedItem
, если новые данные не содержат выбранный элемент, а в противном случаем оставляем состояние как есть.
Мы представим два мок-метода, которые будут играть роль словно мы получили данные по HTTP и изменили их в компоненте.
changeTheItemsIncludingTheDefaultOnes() {
this.listOfItems.set([
{ id: 1, name: 'item 1' },
{ id: 2, name: 'item 2' },
{ id: 3, name: 'item 3' },
{ id: 4, name: 'item 4' }, // новый элемент
{ id: 5, name: 'item 5' }, // новый элемент
]);
}
changeTheItemsExcludingTheDefaultOnes() {
this.listOfItems.set([
// только новые элементы
{ id: 4, name: 'item 4' },
{ id: 5, name: 'item 5' },
]);
}
В первом случае, у нас появляются два новых элемента у списка, во втором случае, у нас только два новых элемента.
Пример 1: Выбираем элемент из меню значений по умолчанию, а затем вызываем changeTheItemsIncludingTheDefaultOnes
. В этом случае мы хотим, чтобы item 1
оставался выбранным, поскольку входящий источник включает этот элемент.
Хорошо, у нас есть сигнал selectedItem
, в котором в качестве состояния лежит объект { id: 1, name: 'item 1' }
. Что пошло не так? А дело вот в чем. Так происходит потому, что меню селекта, реализованного в данном компоненте, отслеживает выбранные элементы, сравнивая ссылки на объект. Когда мы запросили новый источник данных через метод changeTheItemsIncludingTheDefaultOnes
, то получилось так, что элемент этого массива, хоть он визуально и такой же { id: 1, name: 'item 1' }
, однако это совершенно другой объект, отличный от того, что лежит вselectedItem
.
Пример 2: Выбираем элемент, а затем вызоваем changeTheItemsExcludingTheDefaultOnes
. В этот раз, поскольку источник данных не будет включать выбранный элемент, мы хотим, чтобы выбранный элемент не только пропал из меню выбора, но также очистил состояние выбранного элемента.
Выбранный элемент отсутствует в меню выбора, но при этом и выбранное состояние все еще присутствует. Возможно у нас могут возникнуть вопросы к выражению [(value)]="selectedItem"
и тому как работает под капотом material компонент, но давайте решим проблему правильным путем. И очевидно, оно заключается в правильной обработке selectedItem
.
// selectedItem = signal<Item | null>(null);
selectedItem = linkedSignal<Item[], Item | null>({
source: this.listOfItems,
computation: (items, previous) => {
return items.find((item) => item.id === previous?.value?.id) || null;
},
});
Вся работа здесь лежит на computation
функции. Каждый раз, когда listOfItems
получает новое значение, происходит вычисление, которое мы с вами контролируем руками. computation
будет вызываться с двумя аргументами. Первый — это данные source
сигнала, а второй — состояние ранее выбранного значения.
Хорошо, вот мы и дошли до еще одной новой фичи, которая появилась в Angular 19. Если выше я писал, что работа с сигналами предполагает только синхронное взаимодействие, то я ошибался. Теперь у нас появился новый сигнал resource()
, предназначенный для управления асинхронными операциями.
fruitId = signal<string>('apple-id-1');
fruitDetails = resource({
request: this.fruitId,
loader: async (params) => {
const fruitId = params.request;
const response = await fetch(`https://api.example.com/fruit/${fruitId}`, {signal: params.abortSignal});
return await response.json() as Fruit;
}
});
Ну что же, сначала мы объявляем так называемый resource
и он же асинхронный источник данных. В нем вы сможете использовать необязательный параметры request
, в нашем примере это fruitId
, который мы будем использовать для query параметров http-запроса. Далее определяем функцию loader
, с помощью которой мы асинхронно загружаем данные, функция должна возвращать тип Promise
. Что на выходе дает нам созданный resource
?
В любой момент мы можем получать значение этого сигнала, а в случае, когда ресурс недоступен вернется undefined
(далее мы поговорим еще о том, что сделать, если мы хотим получать дефолтное значение);
Можем получить определенные состояния у этого сигнала, такие как: idle
, error
, loading
, reloading
, resolve
, local
;
Можем получить доступ к дополнительным сигналам, таким как isLoading
и error
;
Можем выполнять retry
запросы с помощью функцию метода reload
;
Или даже обновлять локальное состояние ресурса через метод update
.
Ниже приведен пример использования таких методов:
// fruitId = signal<string>('apple-id-1');
// fruitDetails = resource({ .... });
isFruitLoading = this.fruitDetails.isLoading;
fruit = this.fruitDetails.value;
error = this.fruitDetails.error;
updateFruit(name: string): void {
this.fruitDetails.update((fruit) => (fruit ? {
...fruit,
name,
} : undefined))
}
reloadFruit(): void {
this.fruitDetails.reload();
}
onFruitIdChange(fruitId: string): void {
this.fruitId.set(fruitId);
}
А что насчет RxJS, нам больше не нужен HttpClient?
Для любителей RxJS мы можем использовать rxResource
. В этом случае loader
функция должна возвращать Observable
, но все остальное по прежнему остается сигналом.
fruitDetails = rxResource({
request: this.fruitId,
loader: (params) => this.httpClient.get<Fruit>(`https://api.example.com/fruit/${params.request}`)
});
В общем и целом, вы можете теперь даже использовать axios
в проектах на Angular, если вам никогда не нравилась работа с HttpClient
. К тому же, если раньше вам приходилось описывать кастомные DI-сервисы с rxjs обертками для isLoading
/error
и прочим, то теперь у вас есть все необходимое из коробки.
Воспользуемся rxResource
для получения данных о «Star Wars».
export function getPerson() {
assertInInjectionContext(getPerson);
const http = inject(HttpClient);
return (id: number) => {
return http.get<Person>(`https://swapi.dev/api/people/${id}`).pipe(
delay(500),
map((p) => ({...p, id } as Person)),
catchError((err) => {
console.error(err);
return throwError(() => err);
}));
}
}
С помощью функции getPerson
делаем запрос для извлечения персонажей. Если запрос выдает ошибку, логируем ошибку и возвращаем ее дальше. При этом мы добавляем тут задержку в 500 миллисекунд для имитации длительной загрузки ресурсов.
const DEFAULT: Person = {
id: -1,
name: 'NA',
height: 'NA',
mass: 'NA',
hair_color: 'NA',
skin_color: 'NA',
eye_color: 'NA',
gender: 'NA',
films: [],
};
export function getStarWarsCharacter(id: Signal<number>, injector: Injector) {
return runInInjectionContext(injector, () => {
const getPersonFn = getPerson();
const starWarsResource = rxResource({
request: id,
loader: ({ request: searchId }) => getPersonFn(searchId),
defaultValue: DEFAULT
}).asReadonly();
return computed(() => ({
value: starWarsResource.value(),
status: starWarsResource.status(),
}));
});
}
Функция getStarWarCharacter
создает ресурс, который запрашивает персонажа по его идентификатору. Как вы могли заметить, теперь мы используем defaultValue
, которое возвращает замоканный объект Person
, в том случае, когда ресурс еще не заиницировался, загружается или имеет ошибку. А сама функция при этом возвращает производный сигнал, состояние которого хранит результат запроса и его статус.
@Component({ ... })
export class CharacterComponent {
searchId = signal(initialId);
injector = inject(Injector);
person = getStarWarsCharacter(this.searchId, this.injector);
}
Используя rxResource
, мы легко можем запросить персонажей по API в компоненте CharacterComponent
.
<h3>Один из 83 персонажей Star War</h3>
<p>Статус: {{ person().status }}</p>
<character-info [info]="person().value" />
<app-character-picker (searchId)="searchId.set($event)" />
Тут же в шаблоне нашего компонента CharacterComponent
отображаем статус ресурса и передаем полученного персонажа дальше дочернему компоненту CharacterInfoComponent
. В свою очередь с помощью компонента CharacterPickerComponent
мы можем перезапрашивать разных персонажей, используя его всплывающее (output) событие, которое оно кидает в родительский компонент.
@Component({
selector: 'character-info',
standalone: true,
template: `
@let person = info();
<p>Id: {{ person.id }} </p>
<p>Имя: {{ person.name }}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CharacterInfoComponent {
info = input.required<Person>();
}
CharacterInfoComponent
— это простой компонент (stateless dumb), который отвечает только за отображение персонажа в шаблоне.
Как вы помните, в функции rxResource
мы использовали параметр defaultValue
для дефолтного состояния, поэтому мы написали типобезопасный код, который гарантирует, что входной параметр компонента character-info
никогда не получит undefined
. Тип входного сигнала — Person
, поэтому дальше в шаблоне нам будет легко работать с ним.
Если же смотреть на логику нашего компонента CharactePickerComponent
, тут мы просто даем возможность пользователю менять идентификатор в двух направления, увеличивая или уменьшая на единицу. Не обращайте внимания, это просто пример, а не реальная бизнес логика.
<div class="container">
<button (click)="delta.set({ value: -1 })">-1</button>
<button (click)="delta.set({ value: 1 })">+1</button>
</div>
@Component({ ... })
export class CharacterPickerComponent {
searchId = output<number>();
delta = signal<{ value: number }>({ value: 0 });
characterId = linkedSignal<{ value: number }, number>({
source: this.delta,
computation: ({ value }, previous) => {
const previousValue = !previous ? initialId : previous.value;
return previousValue + value;
}
});
constructor() {
effect(() => this.searchId.emit(this.characterId()));
}
}
Когда delta
сигнал получает новое значение, сигнал characterId
увеличивает или уменьшает текущий идентификатор для получения нового.
При этом в конструкторе компонента мы слушаем изменения characterId
, передавая его родительскому компоненту посредством output-события. Вы можете спросить, почему мы не выполняем emit
для высплывающего события прямо в computation
функции? Все просто, с точки зрения стайлгайдов Angular, computation
должен быть чистой функцией, поэтому это не лучшее место для выполнения сайд-эффектов.
Когда пользователи нажимают кнопки для +/- для смены идентификатора, то мы отображаем значение по умолчанию, а когда функция rxResource
загрузила данные, то отображаем их. При этом мы с вами увидим в моменте использования интерфейса два статуса Loading
и Resolved
. Разумеется в случае ошибки, мы сможем увидеть и третий статус Error
. Сам пример вы можете найти тут.
Когда Resource API
спецификация впервые появилась, изначально оно возвращало результат Promise(а), а в случае rxResource
только первое значение из Observable. Когда вы хотели сделать так:
rxResource({
request : this.num,
loader: ({ request: n }) => timer(0, 1000).pipe(take(n))
})
вы получали только одно число, а не набор n чисел.
К счастью, в Angular 19.2.0 текущее API было доработано и теперь есть поддержка потоковой передачи, где ресурс может возвращать несколько ответов. Параметры функции rxResource
остаются прежними, но у функции появилась новая опция stream
. Давайте разбираться на примере потоковую передачу строк у таблицы.
Создадим себе кастомный RxJS оператор для нашего примера, он не несет смысловой нагрузки для нас.
function makeRows(numRows: number, numElementsPerRow: number) {
return function (source: Observable<number>) {
return source.pipe(
tap((i) => console.log('timer called', i)),
map((num) => Array(numElementsPerRow).fill(0).map((_, index) => (num * numElementsPerRow + index) + 1)),
take(numRows),
)
}
}
makeRows
создает массив чисел и отменяет подписку в том случае, когда заполнится до указанного количества строк.
Пример через rxResource
<ng-container
[ngTemplateOutlet]="table"
[ngTemplateOutletContext]="{
$implicit: tableDataRxResource,
title: 'Aggregate table rows with rxResource stream'
}"
/>
<ng-template #table let-resource let-title="title">
<p>{{ title }}</p>
@for (row of resource.value(); track $index) {
<div>
@for (data of row; track data; let last=$last) {
@let delimiter = last ? '' : ', ';
<span>{{ `${data}${delimiter}` }}</span>
}
</div>
}
</ng-template>
Далее мы определяем встроенный шаблон ng-template
, который будем использоваться для отображения строк таблицы. А в элементе ng-container
с помощью ngTemplateOutlet
директивы и его контекста указываем данные, которые будут использоваться в нашем будущем встроенном шаблоне.
@Component({
selector: 'app-root',
imports: [NgTemplateOutlet],
templateUrl: 'main.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
rowSub = new Subject<number[]>();
table$ = this.rowSub.pipe(
scan((acc, values) => ([...acc, values]), [] as number[][]),
);
tableDataRxResource = rxResource({
loader: () => this.table$,
});
constructor() {
timer(0, 1000)
.pipe(makeRows(10, 10))
.subscribe({
next: (array) => this.rowSub.next(array)
});
}
}
rowSub
— это Subject
(Observable
высшего порядка, с которым можно работать на запись), он будет источником данных (массива чисел). Когда в rowSub будет кидать новый массив с числами, другой Observable
(table
$) будет просто конкатенировать себе через оператор scan
новые значения.
И вот тут появляется tableDataRxResource
, который в свою очередь будет ресурсом. У ресурс мы используем в качестве loader(a) наш поток с данными, который будет передавать строки с данными в таблицу по мере поступления их в table$
.
Тут просто для примера используем таймер из RxJS, чтобы нагенерировать себе новых строк в таблице каждую секунду десять раз.
Тот же пример, но через resource
<ng-container
[ngTemplateOutlet]="table"
[ngTemplateOutletContext]="{
$implicit: tableDataResource,
title: 'Aggregate table rows with resource stream'
}"
/>
<ng-template #table let-resource let-title="title">
<p>{{ title }}</p>
@for (row of resource.value(); track $index) {
<div>
@for (data of row; track data; let last=$last) {
@let delimiter = last ? '' : ', ';
<span>{{ `${data}${delimiter}` }}</span>
}
</div>
}
</ng-template>
Вместо Subject используем сигнал в качестве источника данных.
table = signal<{ value: number[][] }>({ value: [] });
При использовании resource
определяем вместо loader
уже stream
поле, которое ожидает асинхронную функцию, в ней мы возвращаем сигнал с числовым массивом.
@Component({
...
})
export class App {
table = signal<{ value: number[][] }>({ value: [] });
tableDataResource = resource({
stream: async () => this.table,
});
constructor() {
timer(0, 1500)
.pipe(makeRows(10, 10))
.subscribe({
next: (array) =>
this.table.update(({ value }) => ({ value: [...value, array] }))
});
}
}
Точно также как в первом примере таймер на RxJS подписывается и добавляет новый числовой массив в состоянме сигнала. resource
достигает того же самого результата, что и функция rxResource
, на демо вы сможете воочию увидеть результат работы этих двух функций.
Где-то выше в примерах, мы уже сталкивались с effect
, давайте разберемся для чего она нам. Как вы уже поняли, чаще всего вы будете сталкиваться с ключевыми функциями signal
, computed
и linkedSignal
, но особое внимание хотелось бы уделить функции effect
. В X(Twitter) даже были разговоры о том, что ее вообще следует избегать, а некоторые утверждают, что ее и вовсе не должно существовать.
effect()
нужно использоваться там, где это необходимо.
Асинхронная особенность effect()
означает то, что его не следует использовать для синхронного обновления сигналов. Для синхронных перевычислений подходит в частности computed()
.
По третьему кругу уже не будем разбирать, что такое сигналы, но разрешу себе написать здесь некоторое дополнение, которое может быть важно, ибо не было сказано. Сигналы должны быть иммутабельными, таким образом, когда вы изменяете их значение, новое значение, должно иметь новую ссылку.
const n = signal(2);
console.log(n()); // 2
n.set(3);
console.log(n()); // 3
n.update((value) => value + 1);
console.log(n()); // 4
На примитивных типах вероятнее всего не так очевидно, так как они по умолчанию и так иммутабельные. А вот с объектами начинающие разработчики бывают путаются.
const obj = signal({a: 1});
console.log(obj()); // {a: 1}
obj.update((state) => {
state.b = 2;
return state;
}); // ❌ не правильно
obj.update((state) => {...state, b: 2}); // ✅ правильно
console.log(n()); // {a: 1, b: 2}
//.....
const arr = signal([1]);
console.log(arr()); // [1]
arr.update((state) => {
state.push(2);
return state;
}); // ❌ не правильно
arr.update((state) => [...state, 2]); // ✅ правильно
console.log(n()); // [1, 2]
C computed
нам уже все понятно, он слушает сигнал(ы), который в свою очередь называется «поставщик» (или просто producer), и возвращает производное (вычисленное) значение.
А если мы просто хотим выполнить какой-то код при изменении одного или нескольких сигналов, но при этом не возвращать ничего, в этом случае, нам хорошо подходит effect
.
Важно отметить, функция effect()
выполняется асинхронно, по крайней мере один раз изначально, а затем всякий раз, когда ее producer уведомляет ее. Давайте разберем следующий код:
const n = signal(2);
effect(() => console.log(n()));
setTimeout(() => {
n.set(3);
n.set(4);
});
// В консоле мы получаем следующий результат (асинхронно):
// 2
// 4
Как мы можем заметить, мы не увидели в консоли число 3
. Это случилось потому, что произошло несколько синхронных изменений, а effect()
фиксирует только конечное состояние после завершения синхронного вызовов.
Стоит отметить, что effect
обладает способностью неявно отслеживать вложенные сигналы. Например, если внутри effect()
вызывается метод или функция, внутри которой вызываются сигналы, то они будут автоматически отслеживаться, в каком-то смысле это может повлиять на производительность.
Было
private world = signal('hello');
constructor() {
effect(() => {
const value = someSignalWeWantToTrack();
// внутри метода doSomething есть вызовы сигналов
// ❌ не правильно так оставлять код
this.doSomething(value);
})
}
private doSomething(value) {
this.world().set('world');
this.otherMethod(this.world(), value);
}
Стало
effect(() => {
const value = someSignalWeWantToTrack();
// ✅ правильно
untracked(() => {
this.someService.doSomething(value);
});
})
Код выше не несет смысловой нагрузки, а скорее демонстрирует основной посыл, что может пойти не так. Чтобы избежать гипотетических проблем с производительности следует оборачивать такой код в untracked
. Многие могут словить флешбеки сrunOutsideAngular
при работе сzone.js
. Что же, тут тоже не обошлось без приколюх. Более подробнее про untracked
можно почитать тут.
Когда computed()
, а когда effect()
?
Я бы не сказал, что это дело вкуса. Тут важно разобраться и найти отличия. В официальной документации Angular говорится: «Избегайте использования эффектов для распространения изменений состояния». Что это значит?
При написании кода на RxJS в нас укоренили мысль, что следующий код антипаттерн:
@Component({
// ...
template: `Double: {{ double }}`,
})
class DoubleComponent {
n$ = new BehaviorSubject(2);
double = 0;
constructor() {
this.n$.subscribe((value) => {
this.double = value * 2;
});
}
}
Вычисление double
здесь является побочным эффектом. Лучшая практика — создавать производные потоки Observable
и избегать прямых подписок.
@Component({
// ...
template: `Double: {{ double$ | async }}`,
})
class DoubleComponent {
n$ = new BehaviorSubject(2);
double$ = this.n$.pipe(map((value) => value * 2)); // ✅ правильно
}
Этот код более декларативный. Нам не нужно явно подписываться, за нас это будет делать Angular, вычислять и присваивать значение. Такой подход упрощает чтение и поддержку кода.
Тот же принцип применим к effect()
и computed()
, при этом effect()
здесь будет является императивным, а computed()
— декларативным.
@Component({
// ...
template: `Double: {{ double }}`,
})
class DoubleComponent {
n = signal(2);
double = 0;
constructor() {
effect(() => {
this.double = this.n() * 2;
});
}
}
Зачем нам здесь использовать effect()
, если у нас и так есть computed()
?
@Component({
// ...
template: `Double: {{ double() }}`,
})
class DoubleComponent {
n = signal(2);
double = computed(() => this.n() * 2); // ✅ правильно
}
Большинство из вас даже не рассматривали бы такую возможность использовать effect()
в этом коде. Многие обсуждения в интернете сводятся к тому, что императивный подход порой более читабельный, чем декларативный. Это стилистический спор, некоторые могут даже ответить, что использование effect()
даже в этом случае не вредит вашему приложению. А потому это может привести разработчиков к мысли о том, что безопасно использовать effect()
вообще где попало, ведь так может быть более читабельно. Но настоящая проблема, еще раз подчеркну, заключается в асинхронной особенности, которая как раз таки может вызывать существенные ошибки.
Лозунг «Не используйте effect()
» может сбить с толку. В то время как некоторые разработчики не видея риска в effect()
, другие могут зазря избегать effect()
, даже тогда когда это лучший и наиболее подходящий выбор.
Вот несколько примечательных твитов от уважаемых членов сообщества Angular:
Наиболее распространенные варианты использования effect()
:
Сайд-эффекты: Мы хотим отреагировать на изменение сигнала, но при этом не выполнять какую-то производную логику с вычислениями.
Асинхронные изменения: effect()
обновляет сигнал, но сперва получает данные с сервера.
Пример с сайд-эффектами:
Типичным примером использования effect()
является отслеживание изменения сигнала и синхронизация c localStorage:
@Component({
// ...
})
class DoubleComponent {
n = signal(2);
#logEffect = effect(() => console.log(n()));
#storageSyncEffect = effect(() => localStorage.setItem("n", JSON.stringify({ value: n() })));
}
Вероятнее всего прямая работа DOM через effect()
также является правильным решение, как в нашем примере при работе со сторонней библиотек charts:
export class ChartComponent {
chartData = input.required<number[]>();
chart: Chart | undefined;
updateEffect = effect(() => {
const data = this.chartData();
untracked(() => {
if (this.chart) {
this.chart.data.datasets[0].data = data;
this.chart.update();
}
})
});
// ...
}
Или же пример с синхронизацией форм:
export class CustomerComponent {
customer = input.required<Customer>();
formUpdater = effect(() => {
this.formGroup.setValue(this.customer());
});
formGroup = inject(NonNullableFormBuilder).group({
id: [0],
firstname: ["", [Validators.required]],
name: ["", [Validators.required]],
country: ["", [Validators.required]],
birthdate: ["", [Validators.required]],
});
}
Как вы можете заметить, суть заключается в том, что все эти примеры реагируют на изменения сигнала, но не обновляют другие сигналы. Это похоже на то, как мы использовали Observable
, где у нас были побочные эффекты в subscribe()
или операторе tap()
:
export class ChartComponent {
chartData$ = inject(ChartDataService).getChartData();
chart: Chart | undefined;
constructor() {
this.chartData$
.pipe(
tap((data) => {
if (this.chart) {
this.chart.data.datasets[0].data = data;
this.chart.update();
}
}),
takeUntilDestroyed(),
)
.subscribe();
}
// code for creating the chart
}
Пример с ассинхронными изменениями:
Возможно, еще один наиболее распространенный вариант использования effect()
— это когда изменяется сигнал и вам необходимо асинхронно извлечь данные перед обновлением другого сигнала.
Например, наш сигнал отслеживает ID клиента из query параметров роутера. На основе этого ID вам необходимо извлечь данные с сервера и обновить у какого-то сигнала:
@Component({
// ..
template: `
@if (customer(); as value) {
<app-customer [customer]="value" [showDeleteButton]="true" />
}
`
})
export class EditCustomerComponent {
id = input.required<number>();
customer = signal<Customer | null>(null);
customerService = inject(CustomerService);
loadEffect = effect(() => {
const id = this.id();
untracked(() => {
this.customerService.byId(id).then(
(customer) => this.customer.set(customer)
);
})
});
}
В этом примере loadEffect
прослушивает изменения в сигнале id
, а далее по видимому мы асинхронно загружает данные клиента с сервера, а по результату работы Promise
обновляем сигнал customer
.
Может показаться, что loadEffect
можно было бы переписать на производное значение, но поскольку внутри нашей операции выполняется асинхронная задача, computed()
в этом деле нам не помощник.
По видимому механизм загрузки данных можно было бы обработать в сервисе, но скорее всего мы просто бы переместили бы использование effect()
в другое место, и не факт, что это было бы семантически верным решением.
Если у вас большое приложение, где большая часть по вычитыванию данных зависит от параметров роутера, и при этом вы уже используете effect()
, будьте уверены, что вы делаете все правильно.
В настоящее время единственными вариантами реагирования на изменения сигнала являются computed()
и effect()
. Если computed()
где-то объективно не подходит, смело используйте effect()
.
Стоит отметить, что когда дело доходит до асинхронных задач, нам всегда на помощь приходит RxJS. И хотя RxJS может быть мощным инструментом для управления асинхронными задачами, мне хотелось бы сфокусироваться на роли effect()
.
Если вы хотите использовать Observable
, вы должны сначала преобразовать
сигнал в Observable
. Для этого вы можете использовать toObservable()
, но знаете что? Он использует effect()
внутри.
Давайте просто помнить, что если в нашей задаче/коде/приложении мы сталкиваемся c managing asynchronous race conditions, то проще чем на RxJS написать легкочитаемый и поддерживаемый код - нет ничего.
А теперь давайте рассмотрим, как это было бы на computed()
:
@Component({
selector: "app-edit-customer",
template: `
@if (customer(); as value) {
<app-customer [customer]="value" [showDeleteButton]="true"></app-customer>
}
{{ loadComputed() }}
`,
standalone: true,
imports: [CustomerComponent],
})
export class EditCustomerComponent {
id = input.required({ transform: numberAttribute });
customer = signal<Customer | null>(null);
customerService = inject(CustomerService);
loadComputed = computed(() => {
const id = this.id();
this.customerService.byId(id).then((customer) => this.customer.set(customer));
});
}
В чем разница? Во-первых, у нас теперь есть Signal<void>
, который не особенно полезен, и ваши коллеги по разработке могут на ревью подумать, что тут что-то не так. Во-вторых, это работает только потому, что loadComputed
нам пришлось добавить еще и в шаблон для поддержания жизни сигнала. И в отличие от effect()
, такому сигналу необходимо иметь контекст, например, добавить в шаблон, чтобы он стал реактивным. Мы все можем согласиться, что использование computed()
в данном случае не выглядит удачным, хотя и решает задачу.
Ахиллесова пята effect()
Вот где возникает настоящая проблема с effect()
. В отличие от computed()
, который выполняется синхронно, effect()
обеспечивает асинхронное выполнение, как вы помните. Это может привести к серьезным ошибкам, когда требуются немедленные обновление состояния. Увы, не всем бы хотелось видеть странный баланс на счете своего банковского счета.
Давайте рассмотрим следующий пример:
@Component({
selector: "app-basket",
template: `
<h3>Click on a product to add it to the basket</h3>
<div class="flex gap-4 my-8">
@for (product of products; track product) {
<button mat-raised-button (click)="selectProduct(product.id)">{{ product.name }}</button>
}
</div>
@if (selectedProduct(); as product) {
<p>Selected Product: {{ product.name }}</p>
<p>Want more? Top up the amount</p>
<div class="flex gap-x-4">
<input [(ngModel)]="amount" name="amount" type="number" />
<button mat-raised-button (click)="updateAmount()">Update Amount</button>
</div>
}
`,
standalone: true,
imports: [FormsModule, MatButton, MatInput],
})
export default class BasketComponent {
readonly #httpClient = inject(HttpClient);
protected readonly products = products;
protected readonly selectedProductId = signal(0);
protected readonly selectedProduct = computed(() => products.find((p) => p.id === this.selectedProductId()));
protected readonly amount = signal(0);
#resetEffect = effect(() => {
this.selectedProductId();
untracked(() => this.amount.set(1));
});
selectProduct(id: number) {
this.selectedProductId.set(id);
console.log(this.selectedProduct()?.name + " added to basket");
}
updateAmount() {
this.#httpClient.post("/basket", { id: this.selectedProductId(), amount: this.amount() }).subscribe();
}
}
BasketComponent
выводит какие-то продукты и позволяет пользователю добавить в корзину один из них. После выбора продукта у пользователя должна быть возможность указать количество товаров (по умолчанию 1).
#resetEffect
сбрасывает количество товаров до единицы всякий раз, когда выбирается новый продукт. Мы могли бы поместить эту логику в метод selectProduct
, но привязка ее к сигналу selectedProductId
гарантирует, что любые изменения с selectedProductId
, даже от других обработчиков событий, всегда будут вызывать сброс, а нам этого не надо.
Когда пользователь переключается на другой продукт, мы хотим отправить выбранный продукт вместе с его выбранным количеством на сервер. Для этого мы добавляем следующий запрос в selectProduct
:
class BasketComponent {
// ...
selectProduct(id: number) {
this.selectedProductId.set(id);
console.log(this.selectedProduct()?.name + " added to basket");
this.#httpClient.post("/basket", { id: this.selectedProductId(), amount: this.amount() }).subscribe();
}
}
К примеру, мы выбрали первый продукт, изменили количество этого товара, который мы хотим купить, а затем выбрали второй товар, то мы увидим, что HTTP-запрос по-прежнему отправляет все еще количество товаров для первого продукта. Однако в поле ввода мы увидим сброшенное значение - 1.
Дело не в том, что #resetEffect
не сработал, в противном случае поле ввода не обновилось бы. Проблема заключается в синхронизации.
effect()
работает асинхронно, тогда как клик и событие selectProduct
выполняется синхронно. К моменту отправки HTTP-запроса #resetEffect
даже не начал выполняться, поэтому количество все еще остается старым.
Это фатальная ошибка. Пользователь будет видеть правильное значение, но в базу будут отправляться неправильные. Еще хуже, если пользователь отправляет свою корзину, думая, что сумма верна, он может заплатить больше, чем нужно.
До сих пор мы видели, что computed()
ведет себя по отношению к effect()
, как pipe
ведет себя по отношению к subscribe()
в RxJS. Но здесь сравнения с RxJS дает сбой. В RxJS подписка будет работать синхронно, гарантируя, что все останется синхронизированным.
Хорошо, мы определили проблему — каково же теперь решение?
Reset Pattern
Есть так называемый подход, который разобрали крутые ребята на подкасте TechStackNation, он решает сложные синхронные обновления сигнала с помощью computed()
. В этих случаях, хотя effect()
может показаться проще в написании, Reset Pattern гарантирует, что обновления происходят синхронно.
В данном подходе мы помещает вложенный сигнал внутрь computed()
, инициализируя его значением по умолчанию. Эти сигналы действуют как триггеры, и когда они изменяются, computed()
пересчитывает и обновляет сигнал синхронно. Ох уж этот Angular и его приколюхи.
Вот как #resetEffect
будет выглядеть с помощью computed()
:
class BasketComponent {
protected readonly state = computed(() => {
return {
selectedProduct: this.selectedProduct(),
amount: signal(1),
};
});
}
Как только selectedProductId
изменяется, computed()
уведомляется синхронно и внутренне помечается как dirty. Затем метод selectProduct
считывает значение amount
и возвращает правильное значение.
Для полноты картины вот окончательная версия BasketComponent
:
@Component({
selector: "app-basket",
template: `
<h3>Click on a product to add it to the basket</h3>
<div class="flex gap-4 my-8">
@for (product of products; track product) {
<button mat-raised-button (click)="selectProduct(product.id)">
{{ product.name }}
</button>
}
</div>
@if (state().selectedProduct; as product) {
<p>Selected Product: {{ product.name }}</p>
<p>Want more? Top up the amount</p>
<div class="flex gap-x-4">
<input [(ngModel)]="state().amount" name="amount" type="number" />
<button mat-raised-button (click)="updateAmount()">Update Amount</button>
</div>
}
`,
standalone: true,
imports: [FormsModule, MatButton, MatInput],
})
export default class BasketComponent {
readonly #httpClient = inject(HttpClient);
protected readonly products = products;
protected readonly selectedProductId = signal(0);
readonly #selectedProduct = computed(() => products.find((p) => p.id === this.selectedProductId()));
state = computed(() => {
return {
selectedProduct: this.#selectedProduct(),
amount: signal(1),
};
});
selectProduct(id: number) {
this.selectedProductId.set(id);
console.log(this.#selectedProduct()?.name + " added to basket");
this.#httpClient.post("/basket", { id: this.selectedProductId(), amount: this.state().amount() }).subscribe();
}
updateAmount() {
this.#httpClient.post("/basket", { id: this.selectedProductId(), amount: this.state().amount() }).subscribe();
}
}
На первый взгляд, Reset Pattern выглядит как boilerplate и версия сeffect()
гораздо более интуитивна понятна. Однако, Alex Rickabaugh из команды Angular пояснил, что этот паттерн является наилучшим workaround(ом) для решения проблемы. Выходит, что сама команда Angular знает о проблемах с effect
, и мы можем ожидать, что в будущем нас ждут какие-либо изменения улучшающие и стабилизирующие поведение данной функции.
Angular представил этот синтаксис @let
в версии 18.1 и сделал его стабильным в версии 19.0. Эта новая функция упрощает процесс определения и повторного использования переменных в шаблонах. Это дополнение отвечает на важный запрос сообщества, позволяя разработчикам сохранять результаты выражений без предыдущих обходных путей, которые были менее эргономичными.
Вот как вы можете использовать синтаксис @let
в своих шаблонах Angular:
@let userName = 'Jane Doe';
<h1>Welcome, {{ userName }}</h1>
<input #userInput type="text">
@let greeting = 'Hello, ' + userInput.value;
<p>{{ greeting }}</p>
@let userData = userObservable$ | async;
<div>User details: {{ userData.name }}</div>
@let
позволяет определять переменные непосредственно в шаблоне, которые затем можно повторно использовать. Помните, что переменные, определенные с помощью @let
, доступны только для чтения и ограничены текущим шаблоном, а его потомками их нельзя переназначить. Эта ограничения гарантируют, что шаблоны остаются предсказуемыми.
Выше мы уже видели в примерах использование ngComponentOutlet
. Эта директива NgComponentOutlet существует довольно давно, со временем начала Angular 2, если мне память не изменяет, и используется для создания экземпляра переданного в него класса с последующим рендерингом и вставкой в текущее дерево компонентов.
Грубо говоря, можете делать так:
<!-- dynamic.component.html -->
<ng-container *ngComponentOutlet="displayedComponent" />
<!-- dynamic.component.ts -->
@Component({
// ....
})
export class DynamicComponent implements OnInit {
displayedComponent = EmptyComponent;
showDetail = false;
ngOnInit(): void {
if (this.showDetail) {
this.displayedComponent = ProductDetailComponent;
} else {
this.displayedComponent = EmptyComponent;
}
}
}
В Angular 19.1
появился новый геттер componentInstance
, он позволяет разработчикам получать доступ к экземпляру созданного компонента. Эта очень важное улучшение, поскольку оно облегчает прямое взаимодействие с уже отрендеренным компонентом, теперь вы можете получить доступ и к свойствам и методам. Давайте рассмотрим комплексный пример.
1) Создаем Greeting Service
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class AdminGreetingService {
greeting = signal('');
setGreeting(msg: string) {
this.greeting.set(msg);
}
}
Этот сервис нам понадобится в будущем, он будет использоваться в динамическом компоненте далее.
2) Создаем компонент с формой
import { ChangeDetectionStrategy, Component, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-user-form',
imports: [FormsModule],
template: `
@let choices = ['Admin', 'User', 'Intruder'];
@for (c of choices; track c) {
@let value = c.toLowerCase();
<div>
<input type="radio" [id]="value" [name]="value" [value]="value"
[(ngModel)]="userType" />
<label for="admin">{{ c }}</label>
</div>
}
Name: <input [(ngModel)]="userName" />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserFormComponent {
userType = model.required<string>();
userName = model.required<string>();
}
UserFormComponent
имеет поле ввода, в котором можно ввести имя, и радио-кнопки для выбора типа пользователя. Когда пользователи выбирают Admin
, мы рендерим компонент AdminComponent
, когда пользователи выбирают User
, отображаем компонент UserComponent
.
3) AdminComponent
и UserComponent
компоненты
// app.component.html
<h2>{{ type() }} Component</h2>
<p>Name: {{ name() }}</p>
<h2>Permissions</h2>
<ul>
@for (p of permissions(); track p) {
<li>{{ p }}</li>
} @empty {
<li>No Permission</li>
}
</ul>
@Component({
selector: 'app-admin',
templateUrl: `app.component.html`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminComponent implements Permission {
permissions = input.required<string[]>();
name = input('N/A');
type = input.required<string>();
service = inject(GREETING_TOKEN);
getGreeting(): string {
return `Я ${this.type()} и меня зовут ${this.name()}.`;
}
constructor() {
effect(() => {
this.service.setGreeting(`Привет ${this.name()}`);
});
}
}
@Component({
selector: 'app-user',
templateUrl: `app.component.html`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserComponent implements Permission {
type = input.required<string>();
permissions = input.required<string[]>();
name = input('N/A');
service = inject(GREETING_TOKEN);
getGreeting(): string {
return `Я ${this.type()} и меня зовут ${this.name()}.`;
}
constructor() {
effect(() => {
this.service.setGreeting(`Привет ${this.name()}`);
});
}
}
4) Определяем базовую конфигурацию
export const GREETING_TOKEN =
new InjectionToken<{ setGreeting: (name: string) => void }>('GREETING_TOKEN');
const injector = Injector.create({
providers: [{
provide: GREETING_TOKEN,
useClass: AdminGreetingService
}]}
);
Токен GREETING_TOKEN
— нужен для того, чтобы быть фасадом для сервиса, в том случае, если мы будем динамически подменять реализацию.
export const configs = {
"admin": {
type: AdminComponent,
permissions: ['create', 'edit', 'view', 'delete'],
injector
},
"user": {
type: UserComponent,
permissions: ['view'],
injector
},
}
Также у нас есть конфиг, который отвечает за нужный нам набор прав у пользователя и ссылкой на компонент, который будет отображен динамически.
5) Используем ngComponentOutlet
@Component({
selector: 'app-root',
imports: [NgComponentOutlet, UserFormComponent],
template: `
<app-user-form [(userType)]="userType" [(userName)]="userName" />
@let ct = componentType();
<ng-container [ngComponentOutlet]="ct.type"
[ngComponentOutletInputs]="inputs()"
[ngComponentOutletInjector]="ct.injector"
#instance="ngComponentOutlet"
/>
@let componentInstance = instance?.componentInstance;
<p>Greeting from componentInstance: {{ componentInstance?.getGreeting() }}</p>
<p>Greeting from componentInstance's injector: {{ componentInstance?.service.greeting() }}</p>
<button (click)="concatPermissionsString()">Permission String</button>
hello: {{ permissionsString().numPermissions }}, {{ permissionsString().str }}
`,
})
export class App {
userName = signal('N/A');
userType = signal<"user" | "admin" | "intruder">('user');
componentType = computed(() => configs[this.userType()]);
inputs = computed(() => ({
permissions: this.componentType().permissions,
name: this.userName(),
type: `${this.userType().charAt(0).toLocaleUpperCase()}${this.userType().slice(1)}`
}));
outlet = viewChild.required(NgComponentOutlet);
permissionsString = signal({
numPermissions: 0,
str: '',
});
concatPermissionsString() {
const permissions = this.outlet().componentInstance?.permissions() as string[];
this.permissionsString.set({
numPermissions: permissions.length,
str: permissions.join(',')
});
}
}
componentType = computed(() => configs[this.userType()]);
componentType
— это сигнал, который получает компонент, со всем остальным набором параметров из конфига.
<ng-container [ngComponentOutlet]="ct.type"
[ngComponentOutletInputs]="inputs()"
[ngComponentOutletInjector]="ct.injector"
#instance="ngComponentOutlet"
/>
При этом мы можем легко динамически создать сигнал, который будет являться набором input
-параметров для компонента, что очень удобно.
componentInstance
мы решили продемонстрировать прямо в шаблоне родителя, чтобы отобразить работу методов динамического компонента. Это исключительно пример.
@let componentInstance = instance?.componentInstance;
<p>Greeting from componentInstance: {{ componentInstance?.getGreeting() }}</p>
<p>Greeting from componentInstance's injector: {{ componentInstance?.service.greeting() }}</p>
Также для примера, я продемонстрировал как вы получить доступ к динамическому компоненту в TypeScript коде вашего родительского компонента. Для этого с помощью viewChild вы можете передать туда ссылку на директиву NgComponentOutlet
, далее вы уже сможете оперировать ссылкой на componentInstance.
outlet = viewChild.required(NgComponentOutlet);
permissionsString = signal({
numPermissions: 0,
str: '',
});
concatPermissions() {
const permissions = this.outlet().componentInstance?.permissions() as string[];
this.permissionsString.set({
numPermissions: permissions.length,
str: permissions.join(',')
});
}
У нас есть метод concatPermissions
, который читает permissions
input
-параметр компонента. В шаблоне мы выводим результат работы этого метода:
<button (click)="concatPermissions()">Permission String</button>
hello: {{ permissionsString().numPermissions }}, {{ permissionsString().str }}
Сам пример вы можете найти тут.
Angular 19.2.0 был представляет шаблонный литерал, прекрасно вам известный еще в нативном JS
const name = 'Maksim';
const greeting = `Hellom ${name}`;
Теперь такая возможность появилась и в html шаблонах у angular компонентов.
Одиночная интерполяция
@Component({
selector: 'app-root',
templateUrl: `./main.component.html`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
description = `${VERSION.full} - Untagged Template Literals`;
}
// main.component.html
<h1>{{ `Version ${description}` }}</h1>
Интерполяция html атрибута class
// app-user.component.css
.color-admin {
color: red;
}
.color-user {
color: purple;
}
.color-intruder {
color: blue
}
// app-user.component.html
@let className = 'color-' + type();
<h2 [class]="className">{{ type() | titlecase }} Component</h2>
// app-user.component.html
<h2 class="{{ `color-${type()}` }}">{{ type() | titlecase }} Component</h2>
Интерполяция html атрибута style
export function getHeaderColor(type: string) {
if (type === 'admin') {
return 'red';
} else if (type === 'intruder') {
return 'blue';
} else if (type === 'user') {
return 'purple';
}
return 'black';
}
// app-user.component.ts
color = computed(() => getHeaderColor(this.type()));
// app-user.component.html
<h2 [style.color]="color()">{{ type() | titlecase }} Component</h2>
// app-user.component.html
<h2 style="{{ `color: ${color()}` }}">{{ type() | titlecase }} Component</h2>
Множественная интерполяция
@Component({
selector: 'app-root',
templateUrl: `./main.component.html`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
description = `${VERSION.full} - Untagged Template Literals`;
userName = signal('N/A');
userType = signal<"user" | "admin" | "intruder">('user');
componentType = computed(() => configs[this.userType()]);
inputs = computed(() => ({
permissions: this.componentType().permissions,
name: this.userName(),
type: this.userType(),
}));
}
// main.component.html
@let permissions = componentInstance?.permissions() ?? [];
@let strPermission = permissions.join(', ');
@let numPermissions = permissions.length;
@let permissionUnit = numPermissions > 1 ? 'permissions' : 'permission';
@let delimiter = numPermissions >= 1 ? ', ' : '';
{{ `Multiple Interpolations: ${numPermissions} ${permissionUnit}${delimiter}${strPermission}` }}
А еще в шаблонных литералах можно использовать пайпы
@Component({
selector: 'app-admin',
templateUrl: `app-user.component.html`,
imports: [TitleCasePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminComponent implements Permission {
permissions = input.required<string[]>();
name = input('N/A');
type = input.required<string>();
}
// app-admin.component.html
<h2 class="{{ `color-${type()}` }}">{{ `${type() | titlecase} Component` }}</h2>
для упрощения работы с такими токенами как APP_INITIALIZER
, ENVIRONMENT_INITIALIZER
и PLATFORM_INITIALIZER
, у нас появились аналогичные функции, которые довольно просто и без бойлерплейта мы можем использовать:
export const appConfig: ApplicationConfig = {
providers: [
provideAppInitializer(() => {
console.log('app initialized');
})
]
};
Кроме того, в Angular 19 есть удобные миграции, и вам не придется вручную переписывать ваш код на новые утилиты.
В Angular 17 у нас появились Defferable views, а теперь вы можете включить (правда в экспериментальном режиме) инкрементальную гидратацию:
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withIncrementalHydration()
)
// ...
]
};
Реализация инкрементальной гидратации построена поверх блока defer. Когда мы хотим использовать его, нам нужно добавить к нему определенное условие гидратации.
@defer (hydrate on hover) {
<app-hydrated-cmp />
}
Поддерживаются следующие типы:
idle
interaction
immediate
timer(ms)
hover
viewport
never (компонент останется незагедрированным)
when {{ condition }}
Гидратация улучшает производительность приложения, поскольку позволяет избежать лишней работы по повторному созданию узлов DOM. Вместо этого Angular пытается сопоставить существующие элементы DOM со структурой приложения во время выполнения и повторно использует узлы DOM, когда это возможно.
Известные нам afterRender
и afterNextRender
не отслеживают никаких изменений сигналов и всегда запускаются после каждого цикла рендеринга, а это не всегда удобно. Поэтому команда Angular добавила новую функцию afterRenderEffect
.
counter = signal(0);
constructor() {
afterRenderEffect(() => {
console.log('after render effect', this.counter());
})
afterRender(() => {
console.log('after render', this.counter())
})
}
В данном примере callback в afterRender будет выполняться после каждого цикла рендеринга, а вот callback в afterRenderEffect, будет выполняться после каждого цикла рендеринга, но только в том случае, если значения сигналов изменились.
К сожалению, здесь я не затронул еще целый ряд грандиозных обновлений Angular 19, но из выше сказанного, вы уже можете заметить, что фреймворк развивается и с каждой новой версией команда Angular старается повысить нашу производительность приложений, оптимизировать реактивность и улучшить DX для разработчиков. Кроме того, я рекомендую изучить новый подход к конфигурации роутинга для SSR, ибо данная тема заслуживает отдельной статьи с примерами.
Мне бы хотелось услышать ваши мысли по поводу текущего состояния Angular и того как он развивается, поэтому обязательно оставляйте свои комментарии. Я буду очень рад, если статья оказалась для вас полезной, спасибо огромное, что дочитали до конца!