Эксперимент: удаление Zone из Angular с минимальными усилиями для повышения производительности во время выполнения.

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

В этой статье я хочу изучить некоторые способы, с помощью которых новый компилятор Angular Ivy (выпущенный в версии 9) сможет сделать приложения, работающие без Zone, намного проще, чем это было в прошлом.

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

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

Совет. Используйте Bit (Github), чтобы легко и постепенно создавать библиотеки компонентов Angular. Сотрудничайте над компонентами многократного использования в проектах, чтобы ускорить разработку, поддерживать согласованный пользовательский интерфейс и писать более масштабируемый код.

Случай использования Angular без зоны

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

Если вашему приложению требуется особая цель производительности, отключение Zone может помочь повысить производительность вашего приложения: пример сценария случая, когда производительность действительно может изменить правила игры, - это высокочастотные обновления, которые проблема, с которой я столкнулся при работе над торговым приложением в реальном времени, когда WebSocket постоянно отправлял сообщения клиенту.

Удаление зоны из Angular

Запускать Angular без зоны довольно просто. Первый шаг - закомментировать или удалить оператор импорта в файле polyfills.ts:

Второй шаг - загрузить корневой модуль со следующими параметрами:

platformBrowserDynamic()
  .bootstrapModule(AppModule, {
    ngZone: 'noop'
  })
  .catch(err => console.error(err));

Angular Ivy: обнаружение изменений вручную с ɵdetectChanges and ɵmarkDirty

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

Теперь мы можем использовать еще две функции, экспортированные из @angular/core: ɵdetectChanges и ɵmarkDirty. Эти две функции все еще должны использоваться в частном порядке и нестабильны, поэтому они начинаются с символа ɵ.

Давайте посмотрим на примере, как их можно использовать.

ɵmarkГрязный

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

import { ɵmarkDirty as markDirty } from '@angular/core';
@Component({...})
class MyComponent {
  setTitle(title: string) {
    this.title = title;
    markDirty(this);
  }
}

ɵdetectChanges

Из соображений эффективности внутренняя документация не рекомендует использовать ɵdetectChanges и рекомендует использовать вместо него ɵmarkDirty. Эта функция синхронно запускает обнаружение изменений в компонентах и ​​подкомпонентах.

import { ɵdetectChanges as detectChanges } from '@angular/core';
@Component({...})
class MyComponent {
  setTitle(title: string) {
    this.title = title;
    detectChanges(this);
  }
}

Автоматическое обнаружение изменений с помощью декоратора машинописного текста

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

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

Представляем декоратор @observed

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

  • к синхронным методам
  • к наблюдаемому
  • к объекту

Давайте посмотрим на два быстрых примера. На изображении ниже мы применяем декоратор @observed к объекту state и к методу changeName.

  • чтобы проверить изменения в объекте state, мы используем прокси внизу, чтобы перехватить изменения в объекте и инициировать обнаружение изменений
  • мы заменяем метод changeTitle его функцией, которая сначала вызывает метод, а затем запускает обнаружение изменений

Ниже у нас есть пример с BehaviorSubject:

Для Observables все становится немного сложнее: нам нужно подписаться на наблюдаемый и пометить компонент как грязный в подписке, но нам также нужно его очистить. Для этого мы переопределяем ngOnInit и ngOnDestroy на подписку, а затем очищаем подписки.

Построим!

Ниже подпись декоратора observed:

export function observed() {
  return function(
    target: object,
    propertyKey: string,
    descriptor?: PropertyDescriptor
  ) {}
}

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

  • мы сохраняем значение исходного метода
  • мы переопределяем метод: мы вызываем исходную функцию, а затем вызываем markDirty(this), чтобы запустить обнаружение изменений
if (descriptor) {
  const original = descriptor.value; // store original
  descriptor.value = function(...args: any[]) {
    original.apply(this, args); // call original
    markDirty(this);
  };
} else {
  // check property
}

Двигаясь дальше, теперь нам нужно проверить, с каким типом свойства мы имеем дело: наблюдаемым или объектом. Теперь мы представляем еще один частный API, предоставляемый Angular, который я, конечно же, не должен использовать (извините!):

  • свойство ɵcmp дает нам доступ к свойствам пост-определения, обрабатываемым Angular, которые мы можем использовать для переопределения методов onInit и onDestroy компонента
const getCmp = type => (type).ɵcmp;
const cmp = getCmp(target.constructor);
const onInit = cmp.onInit || noop;
const onDestroy = cmp.onDestroy || noop;

Чтобы пометить свойство как «подлежащее наблюдению», мы используем ReflectMetadata и устанавливаем его значение на true, чтобы мы знали, что нам нужно наблюдать за свойством при инициализации компонента:

Reflect.set(target, propertyKey, true);

Пришло время переопределить ловушку onInit и проверить свойства при ее создании:

cmp.onInit = function() {
  checkComponentProperties(this);
  onInit.call(this);
};

Давайте определим функцию checkComponentProperties, которая будет просматривать свойства компонента, фильтровать их, проверяя значение, которое мы установили ранее с помощью Reflect.set:

const checkComponentProperties = (ctx) => {
  const props = Object.getOwnPropertyNames(ctx);

  props.map((prop) => {
    return Reflect.get(target, prop);
  }).filter(Boolean).forEach(() => {
    checkProperty.call(ctx, propertyKey);
  });
};

Функция checkProperty будет отвечать за оформление отдельных свойств. Во-первых, мы хотим проверить, является ли свойство Observable или объектом. Если это Observable, то мы подписываемся на него и добавляем подписку в список подписок, которые мы храним в частном порядке в компоненте.

const checkProperty = function(name: string) {
  const ctx = this;

  if (ctx[name] instanceof Observable) {
    const subscriptions = getSubscriptions(ctx);
    subscriptions.add(ctx[name].subscribe(() => {
      markDirty(ctx);
    }));
  } else {
    // check object
  }
};

Если вместо этого свойство является объектом, мы преобразуем его в прокси и вызываем markDirty в его функции-обработчике.

const handler = {
  set(obj, prop, value) {
    obj[prop] = value;
    ɵmarkDirty(ctx);
    return true;
  }
};

ctx[name] = new Proxy(ctx, handler);

Наконец, мы хотим очистить подписки при уничтожении компонента:

cmp.onDestroy = function() {
  const ctx = this;
  if (ctx[subscriptionsSymbol]) {
    ctx[subscriptionsSymbol].unsubscribe();
  }
  onDestroy.call(ctx);
};

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

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

Результаты производительности и соображения

Теперь, когда мы немного узнали о внутреннем устройстве Ivy и о том, как создать декоратор, использующий его API, пришло время протестировать его в реальном приложении.

Я использовал свой проект подопытных кроликов Cryptofolio, чтобы протестировать изменения производительности, вызванные добавлением и удалением Zone.

Я применил декоратор ко всем необходимым ссылкам на шаблоны и удалил Zone. Например, см. Компонент ниже:

  • две переменные, используемые в шаблоне, - это цена (число) и тенденция (рост, устаревание, снижение), и я украсил их обоими @observed
@Component({...})
export class AssetPricerComponent {
  @observed() price$: Observable<string>;
  @observed() trend$: Observable<Trend>;
  
  // ...
}

Размер пакета

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

Если нижеприведенное изображение было взято без зоны:

Принимая во внимание пакет ES2015, ясно, что Zone занимает почти 35 КБ места, тогда как пакет без Zone занимает всего 130 байт.

Начальная загрузка

Я прошел несколько аудитов с Lighthouse, без ограничения: я бы не стал воспринимать приведенные ниже результаты * слишком * серьезно: на самом деле результаты довольно сильно различались, пока я пытался усреднить результаты.

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

Вместо этого изображение ниже было взято без зоны:

Производительность во время выполнения 🚀

А теперь мы переходим к самому интересному: производительность во время выполнения под нагрузкой. Мы хотим проверить, как ведет себя ЦП при рендеринге сотен цен, обновляемых несколько раз в секунду.

Чтобы загрузить приложение под нагрузку, я создал около 100 прайсеров, выдающих фиктивные данные, причем каждая цена меняется каждые 250 мс. Каждая цена будет зеленой, если она увеличилась, или красной, если она снизилась. Это может привести к значительной нагрузке на мой MacBook Pro.

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

Я использовал Chrome Dev Tools, чтобы проанализировать использование ЦП каждой версии. Начнем с Angular и Zone:

Ниже приведено без зоны:

Давайте проанализируем сказанное выше и посмотрим на график использования ЦП (желтый):

  • Как видите, в версии для зоны загрузка ЦП постоянно находится в пределах от 70% до 100%! Держите вкладку под этой нагрузкой достаточно времени, и она обязательно выйдет из строя
  • Во втором случае использование стабильно составляет от 30% до 40%. Милая!

Примечание. Приведенные выше результаты получены при открытом DevTools, что снижает производительность.

Увеличение нагрузки

Я продолжил и попытался обновлять еще 4 цены каждую секунду для каждого ценообразователя:

  • Версия без зоны по-прежнему могла управлять нагрузкой без проблем с использованием ЦП на 50%.
  • Мне удалось поднять нагрузку на ЦП до такой же, как у версии Zone, только обновляя цену каждые 10 мс (цены x 100)

Бенчмаркинг с Angular Benchpress

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

Я черпал вдохновение из этого, и я создал проект, который выполняет некоторые тяжелые операции, которые я протестировал с помощью Angular Benchpress.

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

@Component({...})
export class AppComponent {
  public data = [];
  @observed()
  run(length: number) {
    this.clear();
    this.buildData(length);
  }
  @observed()
  append(length: number) {
    this.buildData(length);
  }
  @observed()
  removeAll() {
    this.clear();
  }
  @observed()
  remove(item) {
    for (let i = 0, l = this.data.length; i < l; i++) {
      if (this.data[i].id === item.id) {
        this.data.splice(i, 1);
        break;
      }
    }
  }

  trackById(item) {
    return item.id;
  }

  private clear() {
    this.data = [];
  }

  private buildData(length: number) {
    const start = this.data.length;
    const end = start + length;

    for (let n = start; n <= end; n++) {
      this.data.push({
        id: n,
        label: Math.random()
      });
    }
  }
}

Затем я запускаю небольшой набор тестов для Protractor и Benchpress: он выполняет операции определенное количество раз.

Полученные результаты

Вот пример результатов, возвращаемых этим инструментом:

А вот объяснение показателей, возвращаемых выходными данными:

- gcAmount: gc amount in kbytes
- gcTime: gc time in ms
- majorGcTime: time of major gcs in ms
- pureScriptTime: script execution time in ms, without gc nor render
- renderTime: render time in ms
- scriptTime: script execution time in ms, including gc and render

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

Тест: создать 1000 строк

Первый тест создает 1000 строк:

Тест: создать 10000 строк

По мере увеличения нагрузки мы видим большую разницу:

Тест: добавить 1000 строк

Этот тест добавляет 1000 строк к списку из 10000:

Тест: удалить 10000 строк

Этот тест создает 10000 строк и удаляет их:

Заключительные слова

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

Всегда следует отдавать предпочтение таким методам, как обнаружение изменений OnPush, trackBy, отсоединение компонентов, запуск вне зоны и занесение событий зоны в черный список (среди многих других). Компромиссы значительны, и это налог, который вы, возможно, не захотите платить.

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

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

Я надеюсь, что эта статья дала вам хорошее представление о том, что может появиться в Angular, что Ivy позволяет делать и как вы можете работать с Zone для достижения максимальной скорости для ваших приложений.

"Исходный код"

Исходный код декоратора Typescript можно найти на его странице Github Project:

Ресурсы

Если вам нужны какие-либо разъяснения, или если вы считаете, что что-то неясно или неправильно, оставьте, пожалуйста, комментарий!

Надеюсь, вам понравилась эта статья! Если да, подпишитесь на меня в Medium, Twitter или на моем веб-сайте, чтобы увидеть больше статей о разработке программного обеспечения, Front End, RxJS, Typescript и многом другом!

Учить больше