За пределами сигналов Angular: Сигналы и пользовательские стратегии рендеринга

RedDeveloper
17.04.2023 08:56
За пределами сигналов Angular: Сигналы и пользовательские стратегии рендеринга

TL;DR: Angular Signals может облегчить отслеживание всех выражений в представлении (Component или EmbeddedView) и планирование пользовательских стратегий рендеринга очень оперативным способом. Таким образом, это позволит добиться некоторых интересных оптимизаций.

Vaikka kirjastot ja kehykset pystyvät yhä paremmin ja paremmin seuraamaan muutoksia hienojakoisesti ja siirtämään ne DOM:iin, saatamme huomata, että joskus suorituskyvyn pullonkaula on DOM-päivityksissä.

Tutkitaanpa, miten Angular Signalsin avulla voisimme voittaa nämä suorituskyvyn pullonkaulat mukautetuilla renderöintistrategioilla.

📜 Tickistä Sigiin

Уже давно команда Angular изучает (гораздо больше, чем мы можем подумать) альтернативные модели реактивности и ищет что-то среднее между крайностями наивного Zone.js (т.е. Zone.js без OnPush) и Zoneless Angular в сочетании со специальными pipes & директивами, подобными тем, которые предоставляет RxAngular .

...затем Pawel Kozlowski присоединился к команде Angular как постоянный член и вместе с Alex Rickabaugh они объединились в Pawælex.

Тем временем, хотя Райан Карниато продолжает настаивать на том, что не он придумал сигналы, он, несомненно, сделал их популярными в экосистеме JavaScript (см. The Evolution of Signals in JavaScript ) и в конечном итоге повлиял на Angular.

Вот как Pawælex & друзья: Andrew , Dylan & Jeremy сделали Angular Signals RFC .

😬 Обновление DOM не так уж и дешево

Фантастическая вещь в Signals - это то, как фреймворки и библиотеки, такие как Angular, SolidJS, Preact или Qwik, "волшебным" образом отслеживают изменения и перерисовывают все, что нужно перерисовать, без особых сложностей по сравнению с более ручными альтернативами.

Но подождите! Если они перерисовывают все, что нужно перерисовать, что произойдет, если узким местом производительности будет само обновление DOM?

Давайте попробуем обновлять 10.000 элементов каждые 100 мс...
@Component({
  ...
  template: `
    <div *ngFor="let _ of lines">{{ count() }}</div>
  `,
})
export class CounterComponent implements OnInit {
  count = signal(0);
  lines = Array(10_000);

  ngOnInit() {
    setInterval(() => this.count.update(value => value + 1), 100);
  }
}
Упс! Мы тратим более 90% времени на рендеринг...
Но подождите! Если они перерисовывают все что нужно перерисовать что произойдет если
...и мы можем заметить, что частота кадров падает где-то до 20 кадров в секунду.
Но подождите! Если они перерисовывают все что нужно перерисовать что произойдет если

🦧 Давайте немного успокоимся.

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

@Component({
  ...
  template: `{{ throttledCount() }}`
})
class MyCmp {
  count = signal(0);
  throttledCount = throttleSignal(this.count, {duration: 1000});
  ...
}

Ср. throttleSignal() .

Но у этого есть пара недостатков:

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

📺 Обновление только области просмотра

А если бы браузер был чувствительным? Он бы обратился к нам и сказал: "Я устал так много работать, и никому нет дела до моих усилий! Отныне я не буду работать, если вы не будете на меня смотреть!".

Возможно, мы согласимся!

В самом деле, зачем нам продолжать обновлять элементы, расположенные ниже уровня разворота? Или, в более общем смысле, зачем нам продолжать обновлять элементы вне области просмотра?

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

lazyCount = applyViewportStrategy(this.count, {element});

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

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

template: `
  <span *lazyViewportSignal="count(); let countValue">{{ countValue }}</span>
  <span> x 2 = </span>
  <span *lazyViewportSignal="double(); let doubleValue">{{ doubleValue }}</span>
`

... что далеко не идеально.

🤔 Как насчет Eventual Consistency для обновлений DOM?

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

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

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

В конце концов, мы могли бы сформулировать следующую теорему (очевидно, вдохновленную CAP Theorem )

Процесс синхронизации состояния и представления не может гарантировать ни согласованность, ни доступность.

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

Это может выглядеть примерно так:

@Component({
  ...
  template: `
    <div *viewportStrategy>
      <span>{{ count() }}</span>
      <span> x 2 = </span>
      <span>{{ double() }} </span>
    </div>
  `,
})
export class CounterComponent implements OnInit {
  count = Signal(0);
  double = computed(() => count());
}

👨🏻‍🍳 Пробираясь между сигналами и обнаружением изменений.

Очевидно, что первым делом я спросил у команды Angular (точнее, у моего дорогого друга Алекса, который теперь работает в Pawælex, как уже упоминалось), планируется ли предоставить API для переопределения того, как сигналы запускают обнаружение изменений.

Алекс сказал: нет.

Я услышал: пока нет.

Тогда я сказал: спасибо.

И мы одновременно сказали: пока.

Тогда я надел свой фартук кодера и начал пробовать некоторые наивные вещи.

Моя первая попытка была ничем иным, как чем-то вроде этого:

/**
 * This doesn't work as expected!
 */
const viewRef = vcr.createEmbeddedView(templateRef);
viewRef.detach();
effect(() => {
  console.info('Yeay! we are in!'); // if called more than once
  viewRef.detectChanges();
});

... но это не сработало.

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

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

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

(Да! Я знаю... Я люблю сначала попробовать случайные вещи 😬)

🔬 Реактивный график

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

В настоящее время существует четыре типа реактивных узлов:

  • Записываемые сигналы: signal()
  • Вычисляемые сигналы: computed()
  • Наблюдатели: effect()
  • Reactive Logical View Consumer: специальный, который нам нужен 😉 (введение компонентов на основе сигналов, вероятно, добавит больше типов узлов, как входы компонентов)

Каждый ReactiveNode знает всех своих потребителей и производителей (которые все являются ReactiveNode). Это необходимо для того, чтобы добиться безглючной реализации push/pull в Angular Signals.

Каждый ReactiveNode знает всех своих потребителей и производителей (которые все являются

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

Наконец, каждый раз, когда реактивный узел может измениться, он уведомляет своих потребителей , вызывая их метод onConsumerDependencyMayHaveChanged().

🎯 Реактивный потребитель логических представлений

Пока я копался в земле и испортил свой фартук, я наткнулся на удивительный тип реактивных узлов, который живет в исходном коде рендера IVy, ReactiveLViewConsumer .

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

Как и любой другой реактивный узел, этот реализует метод onConsumerDependencyMayHaveChanged(), но не как любой другой реактивный узел, этот узел привязан к представлению, поэтому он может контролировать обнаружение изменений... и он это делает! помечая представление как грязное при получении уведомления от производителя:

onConsumerDependencyMayHaveChanged() {
  ...
  markViewDirty(this._lView);
}

🐘 Пробираясь (как слон) между сигналами и обнаружением изменений

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

...но, к счастью, на мне есть фартук для кодирования, так что я не боюсь испачкаться.

1. Создание встроенного представления

Сначала создадим типичную структурную директиву, чтобы мы могли создать & управление встроенным представлением.

@Directive({
  standalone: true,
  selector: '[viewportStrategy]',
})
class ViewportStrategyDirective {
  private _templateRef = inject(TemplateRef);
  private _vcr = inject(ViewContainerRef);

  ngOnInit() {
    const viewRef = this._vcr.createEmbeddedView(this._templateRef);
  }
}

2. Однократное срабатывание обнаружения изменений

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

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

viewRef.detectChanges();
viewRef.detach();

Ага! Пока писала это, наткнулась на этот комментарий здесь ... значит я была права! Наконец-то раз! Да!

3. Возьмите ReactiveLViewConsumer

🙈

const reactiveViewConsumer = viewRef['_lView'][REACTIVE_TEMPLATE_CONSUMER /* 23 */];

4. Переопределите обработчик уведомления Signal, как обезьяна

Теперь, когда у нас есть экземпляр ReactiveLViewConsumer, мы можем позволить хакеру в нас переопределить метод onConsumerDependencyMayHaveChanged() и запустить/пропустить/запланировать обнаружение изменений с помощью выбранной нами стратегии, например, наивного дросселя:

let timeout;

reactiveViewConsumer.onConsumerDependencyMayHaveChanged = () => {
  if (timeout != null) {
    return;
  }

  timeout = setTimeout(() => {
    viewRef.detectChanges();
    timeout = null;
  }, 1000);
};

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

Ср. ThrottleStrategyDirective & ViewportStrategyDirective

🚀 и это работает!

Давайте попробуем!

Давайте попробуем!

Это кажется по крайней мере в 5 раз быстрее... (хотя отслеживание появления элемента во вьюпорте является относительно дорогой задачей)

Это кажется по крайней мере в 5 раз быстрее (хотя отслеживание появления элемента во

И частота кадров довольно приличная:

и частота кадров довольно приличная

... но обратите внимание:

Это может сломаться в любой будущей версии (мажорной или минорной) Angular. Возможно, вам не стоит делать это на работе.

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

🔮 Что дальше?

🚦 RxAngular + сигналы

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

Вместо того чтобы углубляться в это, можно было бы объединить это с RxAngular Render Strategies ... подмигивание, подмигивание, подмигивание! 😉 моим друзьям из RxAngular.

🅰️ Возможно, нам понадобится больше низкоуровневых API Angular.

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

Если бы Angular мог предоставить некоторые дополнительные API, такие как:

interface ViewRef {
  /* This doesn't exist. */
  setCustomSignalChangeHandler(callback: () => void);
}

... или что-то менее многословное 😅, мы могли бы объединить это с ViewRef.detach() и легко пробраться между Сигналами и обнаружением изменений.

Компоненты на основе сигналов

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

⚛ Пользовательские стратегии рендеринга в некоторых других библиотеках и фреймворках

А как насчет других библиотек и фреймворков?

Я не мог удержаться от вопроса, поэтому я сделал это и получил интересные отзывы от представителя SolidJS Ryan Carniato & Preact's Jason Miller :

SolidJS

Я не мог удержаться от вопроса поэтому я сделал это и получил интересные отзывы от

Preact

Я не мог удержаться от вопроса поэтому я сделал это и получил интересные отзывы от

React

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

const CounterWithViewportStrategy = withViewportStrategy(() => <div>{count}</div>);

export function App() {
  ...
  return <>
    {items.map(() => <CounterWithViewportStrategy count = {count}/>}
  </>
}

Ср. React Custom Render Strategies Demo

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

Или, возможно, использование пользовательского хука на основе useSyncExternalStore() .

Vue.js

Используя JSX, мы могли бы обернуть render() так же, как это делает withMemo():

defineComponent({
  setup() {
    const count = ref(0);
    return viewportStrategy(({ rootEl }) => (
      <div ref = {rootEl}>{ count }</div>
    ));
  },
})

Ср. пример дросселя на Stackblitz

... но мне все еще интересно, как это может работать в SFC без необходимости добавлять преобразование узла компилятора для преобразования чего-то вроде v-viewport-strategy в обертку. 🤔

Qwik

Этот вопрос требует более тщательного изучения 😅, и я не уверен, что переопределение стратегии рендеринга по умолчанию в настоящее время возможно.

Однако, мое первое предположение заключается в том, что это может быть "qwikly" добавлено во фреймворк.

Например, может быть API, позволяющий нам переключать флаг "DETACHED" компонента, что пропустит планирование рендеринга компонента в notifyRender() .

👨🏻‍🏫 Заключительные замечания

☢️ Пожалуйста, не делайте этого на работе!

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

Так зачем об этом писать? Моя цель - показать некоторые новые возможности, которые могут быть реализованы благодаря Signals, одновременно улучшая опыт разработчиков.

Хотите попробовать пользовательские стратегии рендеринга до перехода на сигналы?

Посмотрите шаблон RxAngular

Заключение

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

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

🔗 Ссылки и предстоящие семинары

👨🏻‍🏫 Семинары

📰 Подписаться на рассылку

💻 Репозиторий исходного кода

💬 Обсудить это на github

Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?

20.08.2023 18:21

Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в 2023-2024 годах? Или это полная лажа?".

Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией

20.08.2023 17:46

В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.

Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox

19.08.2023 18:39

Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в частности, магию поплавков и гибкость flexbox.

Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest

19.08.2023 17:22

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

Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️

18.08.2023 20:33

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

Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL

14.08.2023 14:49

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