Оптимизация AngularJS: с 1200ms до 35ms

| Категории: Javascript, AngularJS
Илья Овсянников

Иллюстрация локального сайта

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

Использование одностраничного приложения позволило бы использовать быстродействие бекенда на полную, поэтому они занялись поиском подходящего фреймворка и Ангуляр им показался достойным кандидатом. Следуя принципу “обломайся по-быстрому”, они приступили к нашему самому жесткому испытанию, просмотру логов.

Это по-настоящему серьезное испытание для фреймворка. Пользователь может кликнуть по любому слову для поиска соответствующих ему сообщений в логах, а на странице могут быть тысячи таких элементов; еще они хотят мгновенную реакцию при навигации по логам. Они уже сделали предварительную выборку следующей страницы, так что обновление пользовательского интерфейса было целым испытанием. При первичном использовании Ангуляра, переход к следующей страницы лога занимал 1.2 секунды, но после некоторой оптимизации удалось снизить это время до 35 миллисекунд. Эти оптимизации также оказались полезными и в других частях приложения и прекрасно вписывались в философию AngularJS, поэтому они выделили несколько правил, которые будут описаны в этой статье.

Иллюстрация локального сайта

Журнал Github изменений, из демо версии

Просмотрщик логов на AngularJS

По сути, отображение журнала событий- это просто отображение списка сообщений. Каждое слово кликабельно и поэтому должно быть помещено в отдельный DOM элемент. Простая реализация на AngularJS выглядит приблизительно так:

1
<span class=’logLine’ ng-repeat=’line in logLinesToShow’><span class=’logToken’ ng-repeat=’token in line’>{ {token | formatToken} } </span><br></span>

На одной странице запросто может быть несколько тысяч таких токенов. В предыдущих тестах они выяснили, что переход к следующей странице лога вызывает агонии javascript интерпретатора на несколько минут. Хуже всего, что несвязанные действия (такие, как клик на выпадающем меню навигации) сейчас выполнялись со значительными задержками. Традиционнные рекомендации по AngularJS говорили, что нельзя допускать количество связанных элементов на странице больше 200. При условии, что у них каждым элементом было слово, то и до этой цифры им было слишком далеко.

Анализ. Причины лагов

Используя профайлер Google Chrome, мы быстро обнаружили две причины тормозов.

Первое, при каждом обновлении много времени занимало создание и удаление DOM элементов. Если в новом виде было другое количество строк или в любой строке было другое количество слов, то директива AngularJS ng-repeat соответсвенно создает или удаляет DOM элементы. Как оказалось, это достаточно затратно.

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

###Оптимизация #1: Кеширование Dom элементов

Они создали свою реализацию директивы ng-repeat. В их версии, когда количество слов-элементов уменьшалось, лишние DOM элементы прятались, а не удалялись. Если после количество слов увеличивалось, то они переиспользуют закешированные элементы для вставки новых слов.

###Оптимизация #2: Объединение обработчиков

Время, потраченное на запуск обработчиков изменений, шло впустую. В приложении данные, связанные с каждым словом не изменяются, пока весь массив сообщений не изменится. Чтобы задействовать это, ребята создали директиву, которая “прячет” обработчики изменений дочерних элементов, запуская их только тогда, когда значение определенного родительского выражения изменится. Эта модернизация позволила им избежать запуска тысячей обработчиков каждого слова на каждый клик или более мелкое событие. (Чтобы добиться этого, им пришлось слегка сломать уровень абстракции AngularJS. Об этом чуть больше в заключении)

###Оптимизация #3: Откладывание создания элементов

Как было сказано ранее, сотрудники Scalyr создают отдельный DOM элемент для каждого слова в логе. Они могут получить тот же вид с одним элементом на строку; дополнительные элементы нужны только при взаимодействии мышью. Поэтому они решили отложить создание элементов для каждого слово до того момента, как курсов не пройдет над строкой.
Чтобы сделать это, программисты создают две версии каждой строки. Первая, обычный текст всего сообщения. Вторая - плейсхолдер, будет показан с элементом на каждое слово. Изначально, плейсхолдер не отображается. Когда курсор мыши проходит над этой строкой, он появляется, а простой текст прячется.

###Оптимизация #4: Обходной путь вотчеров для спрятанных элементов

Программисты написали еще одну директиву, которая блокирует выполнение обработчика (или его “детей”), если элемент спрятан). Это дополняет Оптимизацию #1, убирая любые лишние “расходы” для элементов, которые были спрятаны по причине ненадобности. Также дополняет Оптимизацию #3, облегчая откладывание обертки каждого слова в элемент, пока строка с токеном не будет показана.

Вот как выглядит код после всех нововведений. Пользовательские директивы выделены жирным шрифтом:

1
2
3
4
<span class=’logLine’ sly-repeat=’line in logLinesToShow’ sly-evaluate-only-when=’logLines’><div ng-mouseenter=”mouseHasEntered = true”><span ng-show=’!mouseHasEntered’>{ {logLine | formatLine } } </span><div ng-show=’mouseHasEntered’ sly-prevent-evaluation-when-hidden><span class=’logToken’ sly-repeat=’tokens in line’>{ {token | formatToken } }</span></div>
</div>
<br>
</span>

sly-repeat- вариант ng-repeat, это наш вариант ng-repeat, который скрывает лишние DOM элементы, вместо того, чтобы их уничтожать.
sly-evaluate-only-when блокирует внутренние обработчики изменений, пока переменная logLines не изменится, что говорит о переходе пользователя к новой части лога.

И sly-prevent-evaluation-when-hidden препятствует выполнению внутренних пунктов, пока курсор не пройдет над строкой и не div не отобразится.

Вышеперечисленное показывает мощь AngularJS в инкапусуляции и выделении проблемы. Они провели довольно тонкие оптимизации без сильного затрагивания шаблонов. (Это не точный код, который они используем на продакшне, но он илюстрирует большинство элементов)

Результаты Оптимизаций AngularJS

Чтобы замерить производительность, разработчики добавили код, который засекает время с момента клика и до конца $digest-цикла (что говорит, что бы обновили DOM). Полученное время показывается в виджете сбоку на странице. Они замерили время действия кнопки “следующая страница” просматривая лог доступа Tomcat в Chrome на недавнем Macbook Pro. И вот результаты (каждое число - результат 10 прогонов):





















  Data already cached Data fetched from server
Simple AngularJS 1190 ms 1300 ms
With Optimizations 35 ms 201 ms

Сюда не включено время на отрисовку браузером (после каждого выполнения Javascript), это 30 миллисекунд в каждом случае. В любом случае, разница существенная: время “следующей страницы” сократилось с чудовищных 1.2 секунд до незаметных 35 миллисекунд (65 с рендерингом).

“Данные, запрошенные с сервера” включает время AJAX-запроса к серверу для получения лога. Это нестандартно для “следующей страницы”, учитывая то, что данные перезагружаются, но применимо для других компонент интерфейса. Но и здесь все работает мгновенно.

Заключение

Этот код был в продакшне 2 месяца и ребята из Scalyr очень довольны результатами. Вы можете увидеть его в действии в Scalyr лог демо. После входа, нажмите на ссылку “Log view” и поиграйтесь с кнопками “Next/Prev”. Работает настолько быстро, что трудно поверить, что мы имеем дело с данными с реального сервера.

Применение этих оптимизаций заняло приличное количество времени. Наверное, было бы легче создать одну директиву, которая бы генерировала весь HTML для отображения логов, не используя ng-repeat. Но это бы шло вразрез с духом Angular, усложняя поддержку кода, его тестируемость и многое другое. Так как отображение логов являлось нашим тестовым заданием для AngularJS, они хотели убедится, что чистое решение возможно. К тому же, директивы, которые они создали, уже использовались в других частях приложения.

Программисты сделали все возможное, чтобы следовать философии AngularJS, но им пришлось изменить слой абстракций, чтобы применить некоторые из этих оптимизаций. Они переписали $watch scope-a, чтобы перехватывать регистрацию обработчиков и потом производить осторожные манипуляции с переменными scope, чтобы контролировать, какие обработчики будут выполнятся во время $digest-цикла.

В следующий раз

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

По мотивам Steve Newman