Angular-разработчики в большом долгу перед библиотекой zone.js. Она, например, помогает достичь в работе с Angular почти волшебной лёгкости. На самом деле, практически всегда, когда нужно просто поменять какое-нибудь свойство, и мы меняем его, ни о чём не задумываясь, Angular производит повторный рендеринг соответствующих компонентов. В результате то, что видит пользователь, всегда содержит самую свежую информацию. Это просто замечательно.

Здесь мне хотелось бы исследовать некоторые аспекты того, как применение нового компилятора Ivy (он появился в Angular 9) способно значительно облегчить отказ от использования zone.js.



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

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

Зачем может понадобиться использовать Angular без zone.js?


Прежде чем продолжать — давайте зададимся одним важным вопросом: «Стоит ли избавляться от zone.js, учитывая то, что эта библиотека помогает нам выполнять, без особых усилий, повторный рендеринг шаблонов?». Безусловно, эта библиотека весьма полезна. Но, как обычно, за всё надо платить.

Если у вашего приложения имеются особые требования к производительности, то отключение zone.js может способствовать выполнению этих требований. В качестве примера приложения, в котором производительность крайне важна, можно привести проект, интерфейс которого очень часто обновляется. В моём случае таким проектом оказалось приложение для трейдинга, работающее в режиме реального времени. Его клиентская часть постоянно получает сообщения по протоколу WebSocket. Данные из этих сообщений нужно как можно быстрее вывести на экран.

Убираем zone.js из Angular


Angular очень легко можно заставить работать без zone.js. Для этого сначала надо закомментировать или удалить соответствующую команду импорта, которая находится в файле polyfills.ts.


Закомментированная команда импорта zone.js

Далее — нужно оснастить корневой модуль следующими опциями:

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

Angular Ivy: самостоятельное обнаружение изменений с помощью ?detectChanges и ?markDirty


Прежде чем мы сможем приступить к созданию TypeScript-декоратора, нам нужно узнать о том, как Ivy позволяет вызвать процесс обнаружения изменений компонента, сделав его «грязным» (dirty), и обойдя при этом zone.js и DI.

Сейчас нам доступны две дополнительные функции, экспортированные из @angular/core. Это — ?detectChanges и ?markDirty. Эти две функции всё ещё предназначены для внутреннего использования и нестабильны — в начале их имён находится символ ?.

Посмотрим на то, как можно воспользоваться этими функциями.

?Функция ?markDirty


Эта функция позволяет маркировать компонент, сделав его «грязным», то есть — нуждающимся в повторном рендеринге. Она, если компонент до её вызова не был маркирован как «грязный», запланирует запуск процесса обнаружения изменений.

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

?Функция ?detectChanges


Во внутренней документации Angular говорится, что, из соображений эффективности, пользоваться функцией ?detectChanges не стоит. Вместо неё рекомендуется использовать функцию ?markDirty. Функция ?detectChanges синхронно вызывает процесс обнаружения изменений в компоненте и его подкомпонентах.

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

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


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

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

?Знакомство с декоратором @observed


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

  • К синхронным методам.
  • К Observable-объектам.
  • К обычным объектам.

Рассмотрим пару небольших примеров. В следующем фрагменте кода мы применяем декоратор @observed к объекту state и к методу changeTitle:

export class Component {
    title = '';

    @observed() state = {
        name: ''
    };

    @observed()
    changeTitle(title: string) {
        this.title = title;
    }

    changeName(name: string) {
        this.state.name = name;
    }
}

  • Для проверки изменений объекта state мы используем Proxy-объект, который перехватывает изменения объекта и вызывает процедуру обнаружения изменений.
  • Мы переопределяем метод changeTitle, применяя функцию, которая сначала вызывает этот метод, а потом запускает процесс обнаружения изменений.

А вот — пример с BehaviorSubject:

export class AppComponent {
    @observed() show$ = new BehaviorSubject(true);

    toggle() {
        this.show$.next(!this.show$.value);
    }
}

В случае с Observable-объектами применение декоратора выглядит несколько сложнее. А именно, нужно подписаться на наблюдаемый объект и отметить компонент как «грязный» в подписке, но нужно и выполнить очистку подписки. Для того чтобы это сделать, мы переназначаем ngOnInit и ngOnDestroy для оформления подписки и для её последующей очистки.

?Создание декоратора


Вот сигнатура декоратора observed:

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

Как видите, descriptor — это необязательный параметр. Это так из-за того, что нам нужно, чтобы декоратор можно было бы применять и к методам, и к свойствам. Если параметр существует, это означает, что декоратор применяется к методу. В таком случае мы поступаем так:

  • Сохраняем свойство descriptor. value.
  • Переопределяем метод следующим образом: вызываем исходную функцию, после чего вызываем markDirty(this) для того, чтобы запустить процесс обнаружения изменений. Вот как это выглядит:

    if (descriptor) {
      const original = descriptor.value; // сохраним исходные данные
      descriptor.value = function(...args: any[]) {
        original.apply(this, args); // вызовем исходный метод
        markDirty(this);
      };
    } else {
      // проверим свойство
    }

Далее — нужно проверить то, со свойством какого типа мы имеем дело. Это может быть Observable-объект или обычный объект. Тут мы воспользуемся ещё одним внутренним 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 {
    // проверим объект
  }
};

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

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);
};

Возможности этого декоратора нельзя назвать всеобъемлющими. Они не охватывают всех вариантов его возможного применения, которые могут обнаружиться в большом приложении. Например — это вызовы шаблонных функций, которые возвращают Observable-объекты. Но я над этим работаю.

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

Анализ результатов ускорения приложения


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

Я, для того, чтобы выяснить влияние избавления от zone.js на производительность Angular-приложений, использовал мой хобби-проект Cryptofolio.

Я применил декоратор ко всем необходимым ссылкам, используемым в шаблонах, и отключил zone.js. Для примера рассмотрим следующий компонент:

@Component({...})
export class AssetPricerComponent {
  @observed() price$: Observable<string>;
  @observed() trend$: Observable<Trend>;
  
  // ...

}

В шаблоне используются две переменные: price (тут будет находиться цена актива) и trend (эта переменная может принимать значения up, stale и down, указывающие на направление изменения цены). Их я декорировал с помощью @observed.

?Размер бандла проекта


Для начала давайте взглянем на то, насколько уменьшился размер бандла проекта при избавлении от zone.js. Ниже показан результат сборки проекта с zone.js.


Результат сборки проекта с zone.js

А вот — сборка без zone.js.


Результат сборки проекта без zone.js

Обратите внимание на файл polyfills-es2015.xxx.js. Если в проекте используется zone.js, то его размер составляет примерно 35 Кб. А вот без zone.js — всего 130 байтов.

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


Я исследовал два варианта приложения с помощью Lighthouse. Результаты этого исследования приведены ниже. Надо отметить, что я не относился бы к ним слишком серьёзно. Дело в том, что я, пытаясь найти средние значения, получил существенно различающиеся результаты, выполнив несколько замеров для одного и того же варианта приложения.

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

Итак, вот результат, полученный для приложения, в котором используется zone.js.


Результаты анализа приложения, в котором используется zone.js

А вот — то, что получилось после анализа приложения, в котором zone.js не используется.


Результаты анализа приложения, в котором не используется zone.js

?Производительность


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

Для того чтобы нагрузить приложение, я создал 100 сущностей, выдающих условные данные по ценам, меняющимся каждые 250 мс. Если цена растёт — она выводится зелёным. Если снижается — красным. Всё это вполне могло серьёзно нагрузить мой MacBook Pro.

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

Для анализа того, как разные варианты приложения используют ресурсы процессора, я воспользовался инструментами разработчика Chrome.

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


Нагрузка на систему, создаваемая приложением, в котором используется zone.js

А вот как работает приложение, в котором zone.js не используется.


Нагрузка на систему, создаваемая приложением, в котором не используется zone.js

Проанализируем эти результаты, обращая внимание на график загрузки процессора (жёлтый):

  • Как видите, приложение, в котором используется zone.js, постоянно нагружает процессор на 70-100%! Если достаточно долго продержать открытой вкладку браузера, создающую такую нагрузку на систему, то приложение, работающее в ней, вполне может дать сбой.
  • А та версия приложения, где zone.js не применяется, создаёт стабильную нагрузку на процессор в диапазоне от 30 до 40%. Замечательно!

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

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


Я попробовал сделать так, чтобы каждая сущность, ответственная за обновление цены, выдавала бы каждую секунду ещё 4 обновления в дополнение к тем, что она уже выдаёт.

Вот что при этом удалось выяснить о приложении, в котором zone.js не используется:

  • Это приложение нормально справлялось с нагрузкой, используя теперь около 50% ресурсов процессора.
  • Ему удалось нагрузить процессор так же сильно, как приложению с zone.js, только тогда, когда цены обновлялись каждые 10 мс (новые данные, как и прежде, поступали от 100 сущностей).

?Анализ производительности с помощью Angular Benchpress


Тот анализ производительности, который я проводил выше, нельзя назвать особенно научным. Для более серьёзного исследования производительности различных фреймворков я посоветовал бы воспользоваться этим бенчмарком. Для исследования Angular стоит выбрать обычный вариант этого фреймворка и его вариант без zone.js.

Я, вдохновившись некоторыми идеями этого бенчмарка, создал проект, выполняющий тяжёлые вычисления. Его производительность я исследовал с помощью 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. Операции выполнялись заданное количество раз.


Benchpress в действии

Результаты


Вот образец результатов, полученных с помощью Benchpress.


Результаты работы Benchpress

Вот объяснение показателей, представленных в этой таблице:

  • gcAmount: объём операций gc (сборка мусора), Кб.
  • gcTime: время операций gc, мс.
  • majorGcTime: время основных операций gc, мс.
  • pureScriptTime: время выполнения скрипта в мс, без учёта операций gc и рендеринга.
  • renderTime: время рендеринга, мс.
  • scriptTime: время выполнения скрипта с учётом операций gc и рендеринга.

Теперь рассмотрим анализ выполнения некоторых операций в различных вариантах приложения. Зелёным показаны результаты приложения, в котором используется zone.js, оранжевым — результаты приложения без zone.js. Обратите внимание на то, что тут проанализировано только время рендеринга. Если вас интересуют все результаты испытаний — загляните сюда.

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


В первом тесте выполняется создание 1000 строк.


Результаты теста

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


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


Результаты теста

Тест: присоединение 1000 строк


В этом тесте к 10000 строк присоединяется 1000 строк.


Результаты теста

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


Здесь создаются 10000 строк, которые потом удаляются.


Результаты теста

Исходный код TypeScript-декоратора


Ниже приведён исходный код рассмотренного здесь TypeScript-декоратора. Этот код можно найти и здесь.

// tslint:disable

import { Observable, Subscription } from 'rxjs';
import { Type, ?ComponentType as ComponentType, ?markDirty as markDirty } from '@angular/core';

interface ComponentDefinition {
  onInit(): void;
  onDestroy(): void;
}

const noop = () => {
};

const getCmp = <T>(type: Function) => (type as any).?cmp as ComponentDefinition;
const subscriptionsSymbol = Symbol('__ng__subscriptions');

export function observed() {
  return function(
    target: object,
    propertyKey: string,
    descriptor?: PropertyDescriptor
  ) {
    if (descriptor) {
      const original = descriptor.value;
      descriptor.value = function(...args: any[]) {
        original.apply(this, args);
        markDirty(this);
      };
    } else {
      const cmp = getCmp(target.constructor);

      if (!cmp) {
        throw new Error(`Property ?cmp is undefined`);
      }

      const onInit = cmp.onInit || noop;
      const onDestroy = cmp.onDestroy || noop;

      const getSubscriptions = (ctx) => {
        if (ctx[subscriptionsSymbol]) {
          return ctx[subscriptionsSymbol];
        }

        ctx[subscriptionsSymbol] = new Subscription();
        return ctx[subscriptionsSymbol];
      };

      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 {
          const handler = {
            set(obj: object, prop: string, value: unknown) {
              obj[prop] = value;
              markDirty(ctx);
              return true;
            }
          };

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

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

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

      cmp.onInit = function() {
        const ctx = this;

        onInit.call(ctx);
        checkComponentProperties(ctx);
      };

      cmp.onDestroy = function() {
        const ctx = this;

        onDestroy.call(ctx);

        if (ctx[subscriptionsSymbol]) {
          ctx[subscriptionsSymbol].unsubscribe();
        }
      };

      Reflect.set(target, propertyKey, true);
    }
  };
}

Итоги


Хотя я и надеюсь на то, что мой рассказ об оптимизации производительности Angular-проектов вам понравился, ещё я надеюсь на то, что не склонил вас к тому, чтобы вы бросились бы убирать zone.js из своего проекта. Описанная здесь стратегия должна быть самым последним средством, к которому можно прибегнуть ради увеличения производительности Angular-приложения.

Сначала надо попробовать такие подходы, как использование стратегии обнаружения изменений OnPush, применение trackBy, отключение компонентов, выполнение кода за пределами zone.js, внесение в чёрный список событий zone.js (этот перечень методов оптимизации можно продолжить). Показанный здесь подход — это удовольствие довольно-таки дорогое, и я не уверен в том, что все готовы будут заплатить столь высокую цену за производительность.

На самом деле, разработка без zone.js может оказаться не самым привлекательным делом. Пожалуй, это не так лишь для того, кто занимается проектом, находящимся под его полным контролем. То есть — является владельцем зависимостей и имеет возможности и время для того, чтобы привести всё к надлежащему виду.

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

Надеюсь, эта статья позволила вам увидеть то, что ждёт Angular в будущем, то, на что способен Ivy, и то, что можно сделать с zone.js ради максимального ускорения приложения.

Уважаемые читатели! Как вы оптимизируете свои Angular-проекты, которые нуждаются в максимальной производительности?


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


  1. vintage
    22.11.2019 13:02
    -3

    Что-то эти анимации с выводом 8 чисел не впечатляют. Реализовали бы хотя бы такую штуку: https://mathieuancelin.github.io/js-repaint-perfs/mol/index.html


    1. arvitaly
      23.11.2019 02:33

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


    1. justboris
      24.11.2019 13:57

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


      1. faiwer
        24.11.2019 15:45
        +2


        не совсем :)


  1. c_kotik
    22.11.2019 15:29
    -1

    Ребят, за статьи спасибо. Но убедительная просьба — выработайте единый подход к формированию статей и разбитию их на части. А то как то не понятно — этот объемный материал зашёл одной публикацией, а вот сравнение 5 фрейворков вдруг понадобилось бить на 2 части (https://habr.com/ru/company/ruvds/blog/476288/ и habr.com/ru/company/ruvds/blog/476286)


    1. ru_vds Автор
      22.11.2019 15:38
      +8

      Собственно как раз после предыдущего разделения решили одним большим куском разделить.


      1. Drag13
        22.11.2019 15:58
        +4

        Спасибо, одним куском лучше.


  1. Paskin
    22.11.2019 23:21

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


  1. faiwer
    22.11.2019 23:43

    Reflect.set(target, propertyKey, true)

    А чем это отличается от target[propertyKey] = true? Автор этим хотел явно указать на то, что target есть объект сторонний и мутный? Или есть какое-то функциональное отличие?


  1. amakhrov
    25.11.2019 06:31

    из соображений эффективности, пользоваться функцией ?detectChanges не стоит
    вместо неё рекомендуется использовать функцию ?markDirty.

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


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


    Буду рад, если кто-то сможет пояснить, почему при этом markDirty() является рекомендуемым подходом (а он-таки является, судя по этому комментарию к функции detectChanges())