[Перевод] Angular без zone.js: максимум производительности

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

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

9mqlxapag9joinziuleg8frnslm.jpeg

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

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

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


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

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

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


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

7a4f9517eb4898e51240862c08e4faa9.png


Закомментированная команда импорта 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;
  @observed() trend$: Observable;
  
  // ...

}


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

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


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

a0bcb37e870dfdcafa1c49a80aeaeb04.png


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

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

7d56aeadce675e6953e5e603b3594c2c.png


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

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

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


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

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

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

dfd9225f368de1f7c3e43d47bddbad16.png


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

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

f416849ba2f210d1ed48345e607a3d8f.png


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

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


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

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

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

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

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

fbf11b56ffe060d5bcdc4680a0ddd347.gif


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

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

8fb65724082e810619f061cb238c76da.gif


Нагрузка на систему, создаваемая приложением, в котором не используется 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. Операции выполнялись заданное количество раз.

5c2167a7bae21093e5e7cdc9f2482361.gif


Benchpress в действии

Результаты


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

f5a4670f0af5b461a1f0fad2d7f982b3.png


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

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

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


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

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


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

944d48745c79b697bececc092c78c9be.png


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

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


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

7e19718aadeec04390f1f4b7fa408f89.png


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

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


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

3f3682d3064b9158ef95146426d2638b.png


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

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


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

f85d5dbb78b094bd9bb9afccbce148e3.png


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

Исходный код 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 = (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-проекты, которые нуждаются в максимальной производительности?

-o2etuqogwhmdnmysb9_vivc9v4.png


1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru