Обнаружение локальных изменений в Angular 17
Как использовать сигналы Angular и стратегию обнаружения изменений OnPush для улучшения производительности с локальным обнаружением изменений.
Сообщество Angular наблюдает много улучшений и новых функций, которые добавляются в их любимый фреймворк. В выпуске Angular v17, в котором было представлено множество качественных улучшений и функций, и signals API (кроме effect()) покидают стадию предварительной разработки. Поскольку производительность всегда является обсуждаемой темой в сообществе относительно обнаружения изменений, стабильные signals API кажутся делающими фреймворк более мощным и умным в обнаружении изменений.
Некоторое время назад команда Angular начала работу над внедрением осведомленности о локальности в фреймворк (при обнаружении изменений) в том смысле, чтобы определить, какие изменения состояния влияют на приложение, не завися от Zone.js.
Для этой цели, в Angular v17, следуя текущему подходу верхний-вниз к обнаружению изменений, основанному на Zonejs, сигналы были настроены, обеспечивая отправную точку к осведомленности о локальности, таким образом внедряя некое подобие того, что сообщество Angular называет локальное обнаружение изменений.
Это отличная возможность, которая была добавлена в фреймворк через сигналы и ожидается, что принесет улучшение производительности, но это то, чего долго ждало сообщество? Это сократит количество проверок, необходимых во время цикла обнаружения изменений? И связано ли это как-то с стратегиями обнаружения изменений?
В этой статье мы рассмотрим, как достигается осведомленность о локальности, условия для эффективного и правильного использования и покажем некоторые рабочие примеры.
Режимы обнаружения изменений
До Angular v17, каждый раз, когда происходит событие, которое Zone.js патчит и которое может привести к изменению состояния, Zone.js обнаруживает это событие и уведомляет Angular о том, что какое-то состояние изменилось (не конкретно где) и что нужно запустить обнаружение изменений. Поскольку Angular не знает, откуда именно произошло изменение или где оно произошло, он начинает обходить дерево компонентов и проверять все компоненты на изменения. Этот подход к обнаружению изменений известен как глобальный режим.
С использованием сигналов, данный подход будет утончен в том смысле, что больше не нужно будет проверять на изменения все компоненты. Сигналы могут отслеживать места, где они используются. Для сигналов, привязанных к шаблону компонента, сам шаблон является потребителем, который каждый раз, когда меняется значение сигнала, должен извлечь это новое значение (отсюда и "живой" потребитель). Таким образом, когда меняется значение сигнала, достаточно пометить его потребителей как "грязных", но не нужно делать то же самое для родительских компонентов (как это было до версии v17). Для этой цели в последней версии было внедрено новое улучшение, согласно которому сигналы достаточно "умны", чтобы помечать как "грязные" только конкретный компонент, на котором они используются (живой потребитель), и помечать родительские компоненты для проверки (добавляя флаг HasChildViewsToRefresh).
Для достижения этого была введена новая функция markAncestorsForTraversal, заменяющая markViewDirty (которая используется для пометки как Dirty также всех родительских компонентов). Давайте посмотрим на базовый код:
export function consumerMarkDirty(node: ReactiveNode): void {
node.dirty = true;
producerNotifyConsumers(node);
node.consumerMarkedDirty?.(node);
}
consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
markAncestorsForTraversal(node.lView!);
},
export function markAncestorsForTraversal(lView: LView) {
let parent = lView[PARENT];
while (parent !== null) {
// We stop adding markers to the ancestors once we reach one that already has the marker. This
// is to avoid needlessly traversing all the way to the root when the marker already exists.
if ((isLContainer(parent) && (parent[FLAGS] & LContainerFlags.HasChildViewsToRefresh) ||
(isLView(parent) && parent[FLAGS] & LViewFlags.HasChildViewsToRefresh))) {
break;
}
if (isLContainer(parent)) {
parent[FLAGS] |= LContainerFlags.HasChildViewsToRefresh;
} else {
parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh;
if (!viewAttachedToChangeDetector(parent)) {
break;
}
}
parent = parent[PARENT];
}
}
Во время обнаружения изменений (запущенного Zone.js), компоненты, помеченные для обхода, когда их посещают, позволяют Angular понять, что их не нужно проверять на изменения, но у них есть Dirty дочерние компоненты. Таким образом, они остаются доступными во время процесса обнаружения изменений, даже когда они не являются Dirty, но пропущены по пути к Dirty дочерним компонентам, которые обновляются. В результате новая возможность сигналов локализовать, где в дереве компонентов произошли изменения, обеспечивает локальное обнаружение изменений, о котором мы говорили ранее. Улучшенный подход, применяемый сигналами, известен как целевой режим обнаружения изменений. В этом режиме Angular все равно запускает процесс проверки сверху вниз (вспомните, активируемый Zone.js), но теперь он проходит через компоненты, помеченные для обхода, и направляется на обновление только Dirty потребителей.
Этот подход действительно кажется приносящим выигрыш в производительности всего приложения, но предоставляется ли он из коробки или это возможность выбора?
Стратегии обнаружения изменений
В общем, наилучший способ борьбы с проблемами производительности — это сделать меньше работы, что означает выполнение меньшего количества кода, а в Angular это означает уменьшение числа циклов обнаружения изменений и количества компонентов, проверяемых на изменения во время цикла. Для достижения этого Angular нуждается в способе знать, какие компоненты должны быть проверены на изменения, а какие нет.
Поскольку Angular не способен точно определить, какой компонент изменился, и обнаружение изменений является глобальным процессом сверху вниз, предполагается, что все компоненты в дереве Dirty и требуют проверки на изменения в каждом цикле. Это означает, что компоненты, независимо от их состояния, будут проверяться на изменения. Такое поведение и соответствие компонентов в отношении процесса обнаружения изменений называется стратегией обнаружения изменений. Поскольку это поведение по умолчанию для компонентов, его называют Стратегией обнаружения изменений по умолчанию.
Для улучшения этого поведения и сокращения объема работы Angular-команда представила новую стратегию, которая уменьшает количество компонентов, подлежащих проверке на изменения. Эта новая стратегия известна как стратегия обнаружения изменений OnPush, которая позволяет пропускать поддеревья компонентов, которые не Dirty.
Теперь, основываясь на этой информации, мы можем предположить, что стратегия обнаружения изменений OnPush, кажется, позволяет нам использовать преимущества локального обнаружения изменений Angular v17. Давайте выясним, действительно ли это так.
Гибридное обнаружение изменений
Для демонстрационных целей ниже приведено небольшое приложение, поддерживающее наш случай:
@Component({
...
selector: 'app-child-y',
templateUrl: `
<div class="container">
<h3>Child Y<br /> value: {{ count() }} runs: {{getChecked()}}</h3>
<app-grandchild-y />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class ChildYComponent {...}
@Component({
...
selector: 'app-child-x',
templateUrl: `
<div class="container">
<h3>Child X <br /> value: {{ count() }} runs: {{getChecked()}}</h3>
<app-grandchild-x />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class ChildXComponent {...}
@Component({
...
selector: 'app-parent',
templateUrl: `
<div class="container">
<h2>Parent <br /> value: {{count()}} runs: {{getChecked()}}</h2>
<div class="children">
<app-child-x />
<app-child-y />
</div>
</div>
`,
...
})
export class ParentComponent {...}
Приложение визуализирует дерево компонентов, при этом у родительского компонента используется обнаружение изменений по умолчанию, а у его двух дочерних компонентов, ChildX и ChildY, установлено обнаружение изменений OnPush. Кроме того, каждый из дочерних компонентов, GrandChildX и GrandChildY соответственно, имеет свой дочерний компонент.
@Component({
...
selector: 'app-grandchild-x',
templateUrl: `
<div class="container">
<h4>(GrandChild X <br /> value: {{ count() }} runs: {{getChecked()}}</h4>
<button (click)="updateValue()">Increment Count</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class GrandchildXComponent {
...
updateValue() {
this.count.update((v) => v + 1);
}
}
@Component({
...
selector: 'app-grandchild-y',
templateUrl: `
<div class="container" appColor>
<h4>(GrandChild Y <br /> value: {{ count() }} runs: {{getChecked()}}</h4>
<button #incCount>Increment Count</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class GrandchildYComponent implements AfterViewInit {
...
@ViewChild('incCount') incButton!: ElementRef<HTMLButtonElement>;
ngZone = inject(NgZone);
injector = inject(Injector);
app = inject(ApplicationRef);
ngAfterViewInit(): void {
runInInjectionContext(this.injector, () => {
this.ngZone.runOutsideAngular(() => {
fromEvent(this.incButton.nativeElement, 'click')
.pipe(throttleTime(1000), takeUntilDestroyed())
.subscribe(() => {
this.count.update((v) => v + 1);
this.app.tick();
});
});
});
}
}
Единственное отличие между дочерними компонентами заключается в том, как обрабатывается обработчик события щелчка. В следующем разделе мы разберем причину принятия этого решения.
Теперь, когда на обеих сторонах дерева компонентов все компоненты используют стратегию OnPush, давайте проверим, что происходит при увеличении счетчика в компоненте GrandChildX:
То, что мы наблюдаем здесь, не отличается от использования глобальной стратегии. Несмотря на то, что компоненты используют стратегию OnPush, они все равно проверяются на изменения. Почему это происходит?
Angular внутренне оборачивает обработчики событий в функцию, которая отмечает компонент и его предков для проверки при возникновении события. Кроме того, Zone.js вносит изменения в событие и уведомляет Angular о его запуске, чтобы Angular мог начать обнаружение изменений. Таким образом, в нашем случае, когда мы нажимаем кнопку, текущий и предыдущие компоненты отмечаются как измененные. И поскольку обнаружение изменений начинается в режиме Global, вся структура обновляется. Так работало обнаружение изменений до v17 и осталось неизменным в целях обратной совместимости.
Исходя из этого, мы можем заключить, что для получения преимуществ местного обнаружения изменений нам нужно: обновить сигнал таким образом, чтобы не отмечать для проверки все предыдущие компоненты.
Хм... хорошо. Мы позаботились о кнопке увеличения счетчика в компоненте GrandChildY, чтобы соответствовать этим требованиям, так что давайте посмотрим, что у нас получилось сейчас:
Ура! Теперь у нас есть локальное обнаружение изменений в нашем приложении. Для большей ясности, когда нажимается кнопка, значение сигнала обновляется, что затем отмечает текущий компонент (потребителя) как Dirty и предков для обхода. После этого срабатывает Zone.js (помните манкипатчинг), который уведомляет Angular, затем Angular начинает обнаруживать изменения в режиме Global и обновляет родительский компонент, потому что он использует стратегию обнаружения изменений по умолчанию, переключается в целевой режим при посещении компонентов, отмеченных для обхода (с флагом HasChildViewsToRefresh) и использует стратегию обнаружения изменений OnPush, компонент ChildY в этом случае, обходя их, но не обновляя, затем наконец доходит до Dirty компонента GrandChildY (потребителя), переключается обратно в глобальный режим обнаружения изменений и обновляет представление. Это последовательное переключение, которое Angular выполняет между режимами обнаружения изменений, Global и Targeted, известно как гибридное обнаружение изменений.
Теперь, учитывая все вышеперечисленное, кто-то может сказать, что кажется, что мест, где мы получим преимущества локального обнаружения изменений, не так уж и много. Действительно, мы не получим преимущества повсюду, но и обработка щелчка выше не является обычной вещью, которую мы часто делаем. Однако есть другие случаи использования, где мы можем получить это преимущество, см. ниже:
Здесь вы можете увидеть более типичный случай, когда у вас есть общее состояние в службе, типичным случаем является библиотеки управления состоянием (NgRx, Akita, ...), и это состояние используется во многих компонентах по всему дереву компонентов. Также ситуация, когда на родительском компоненте есть некоторое состояние, которое может быть использовано из дочерних компонентов. Когда это состояние (помните значение сигнала) изменяется, помечаются только компоненты, которые потребляют (потребители) состояние, и с установленной стратегией CD OnPush, вы получите преимущества локального обнаружения изменений.
Заключение
Улучшение обнаружения изменений всегда было одним из приоритетов команды Angular и сообщества. Такой уровень локальности, который у нас есть сейчас в v17, не является полным, но представляет собой хороший старт и первый шаг к тому, что предполагается быть реализованным в будущих версиях. Несмотря на то, что это не предоставляется из коробки, разработчики могут воспользоваться преимуществами локального обнаружения изменений в своих приложениях с помощью нескольких настроек: стратегии обнаружения изменений OnPush и Сигналов.
Весь код проекта доступен по ссылке: LocalChangeDetection-ng17