Хабрахабр

Ускорение Angular-приложений

Многие знают Минко Гечева (rhyme.com) по книге «Switching to Angular» и по тексту «Angular Performance-Checklist», помогающему Angular-разработчикам оптимизировать свои проекты. На нашей декабрьской конференции HolyJS 2017 Moscow он тоже развивал тему Angular-производительности, выступив с докладом «Faster Angular applications». А теперь на основе этого выступления мы подготовили хабрапост, переведя все на русский. Добро пожаловать под кат! А если предпочитаете англоязычную видеозапись выступления, прилагаем и ее тоже:

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

В этом направлении есть много исследований. В первом случае обычно пытаются сократить количество HTTP-запросов или передаваемых по сети данных. Также у нас есть различные алгоритмы сжатия, и в команде webpack тоже ставят подобные цели. Например, над этим бьется коллектив Google Closure Compiler, достигая цели более эффективным удалением неиспользуемого кода и минификацией кода. Наконец, в Angular CLI пытаются совместить лучшее из разных подходов и дают очень хорошо инкапсулированные сборки.

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

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

У нас будет два списка сотрудников: для отдела продаж и для R&D-отдела. В нашем максимально упрощенном приложении можно добавлять новых работников, представлять их в списке и рассчитывать для них некоторое значение. Уже имеющиеся элементы представлены в списке, где видно имя и некоторое числовое значение (предположим, это оценка работы сотрудника). В оба можно добавлять новые элементы. При добавлении сотрудника мы можем просто взять откуда-то число, высчитать что-то и отобразить все на экране. Также есть поле ввода имени нового сотрудника.

Структура приложения состоит из AppComponent (охватывающего приложение целиком) и двух EmployeeListComponent (по одному на каждый список).

Вот шаблон EmployeeListComponent:

В нем используется синтаксис формата «ящик с бананами» (вначале квадратные скобки, затем круглые), чтобы установить двустороннюю привязку данных между свойством label, объявленном в контроллере EmployeeListComponent, и текстовым полем. Здесь обратите внимание на элемент input.

Для каждого элемента мы отображаем имя сотрудника и рассчитываем числовое значение при помощи метода calculate(), определенного в классе EmployeeListComponent. Кроме того, в EmployeeListComponent происходит итерация по списку сотрудников в массиве данных, и для каждого сотрудника создается элемент списка.

Теперь взглянем на сам этот класс:

Для начала, в нем не хранится состояние, он получает все необходимые данные (массив EmployeeData[]) на вход от родительского компонента. Тут есть несколько важных вещей. Таким образом, этот родительский компонент, AppComponent, выступает в роли Container Component в Redux.

На первый взгляд, числа Фибоначчи здесь неудобно использовать, однако у них есть ряд важных преимуществ. Кроме того, в классе EmployeeListComponent есть метод calculate(), единственная задача которого — передать выполнение функции, вычисляющей числа Фибоначчи. Можно было бы заменить на стандартное отклонение или еще что-то подобное. Во-первых, метод их расчета всем известен, нет необходимости объяснять сложную работу подходящей примеру функции.

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

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

У нас будет два списка, содержащие в общей сложности 140 элементов. Попробуем использовать в этом приложении некоторые реальные данные. Вряд ли пользователям может понравиться такая работа приложения. В этом случае при вводе новых имен набор текста оказывается крайне замедлен. Профилировать эту проблему достаточно легко с помощью Chrome DevTools. Но почему настолько медленно? Мы можем узнать точное количество вызовов, добавив в эту функцию логирование. Сделав это, мы выясняем, что наша функция расчета чисел Фибоначчи вызывается очень часто.

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

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

Как правило, мы не хотим пересчитывать числовые значения для каждого элемента в массиве, такое мы хотели бы только при появлении нового массива. Всё это крайне неэффективно. Есть мысли, как это лучше всего сделать? Вот если происходит передача нового массива из AppComponent в EmployeeListComponent, тогда можно высчитывать.

Благодаря ей обнаружение изменений будет запускаться только при появлении новых входных данных у компонента. Например, можно использовать стратегию OnPush. То есть, если у нас есть дерево компонентов, когда корневой компонент получает новые данные, мы обновляем всю ветку, начиная с этого компонента. Когда Angular при проверке ссылок обнаруживает появление новых входных данных, в компонентах будет выполнено обнаружение изменений. Мы чуть позже посмотрим, как это выглядит.

Входные данные для компонента — это входные аргументы функции, а изображение на экране — результат функции. Обратимся за помощью к функциональному программированию и попробуем представить, что EmployeeListComponent — это функция. Продемонстрирую свою мысль с помощью псевдокода.

В константе f мы сохраняем ссылку на EmployeeListComponent (теперь это функция), в константе data — входные аргументы (данные одного сотрудника).

Поскольку до этого значением было undefined, сравнив data и undefined, Angular увидит изменение значения входных данных. Для начала осуществляем вызов функции с ее изначальными входными данными, и тут Angular выполнит обнаружение изменений.

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

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

По нескольким причинам это было бы крайне неэффективно. Значит ли это, что каждый раз, когда нам необходимо обнаружение изменений, нам нужно копировать массив целиком? Для каждого обнаружения изменений нам нужно вначале выделить память под весь новый массив, а затем сборщику мусора нужно будет освободить ее. Во-первых, это было бы крайне неоптимальное использование памяти. Временная сложность такого алгоритма по меньшей мере O(n). Во-вторых, это неэффективно с точки зрения вычислений.

Immutable

Это набор различных неизменяемых структур данных с двумя очень важными свойствами. В отношении обоих этих вещей разумнее будет воспользоваться чем-либо наподобие Immutable.js.

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

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

Во-первых, мы поменяли содержимое методов add() и remove(). Вот какой рефакторинг нам надо провести. То же самое в методе remove(), вызов splice() возвращает нам новый список. В add() при выполнении процедуры unshift(), в которой элемент переводится в начало списка, мы получаем новый список.

Иначе мы не могли бы оповестить EmployeeListComponent, что входные данные поменялись. Кроме этих двух методов, нам необходимо поменять ссылку на список. Таким образом, выходные значения add() и remove() должны быть присвоены свойству list в AppComponent.

Мы же тут оптимизировали, должно было стать лучше… Хм, приложение работает по-прежнему очень медленно. Запустим приложение и посмотрим, насколько быстрее теперь все получилось. Возможно, стало быстрее прежнего, но все равно впечатления у пользователя вряд ли будут хорошо.

Чтобы измерить, насколько быстрее стало работать приложение, я написал несколько сквозных тестов и запустил их на Angular Benchpress.

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

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

Причина заключается в определенных характеристиках обнаружения изменений OnPush, не слишком хорошо задокументированных.

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

В этом есть своя позитивная сторона, поскольку мы заодно сможем улучшить разделение ответственности в нашем приложении и сделать дерево компонентов стройнее. Зная эту особенность, мы теперь можем провести рефакторинг кода. Сделаем в EmployeeListComponent два дочерних компонента: NameInputComponent и ListComponent.

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

Как именно осуществляется работа приложения теперь? После этих изменений в коде приложение стало работать значительно быстрее. Но на этот раз в дочерних компонентах EmployeeListComponent обнаружение изменений уже не вызывается. К сожалению, при нажатии пользователем клавиши обнаружение изменений по-прежнему вызывается в AppComponent, и затем в обоих экземплярах EmployeeListComponent. е. Дело в том, что у ListComponent используется обнаружение изменений OnPush, а событие происходит в области EmployeeListComponent, т. Скорость печати увеличивается на несколько порядков. в родительском компоненте EmployeeList.

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

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

Во-первых, у них нет побочных эффектов, то есть не осуществляется никаких вызовов через сеть, не происходит логирования, и так далее. У чистых функций есть два очень важных свойства. В мире функционального программирования это и получило название «pure function». Во-вторых, повторный вызов функции с такими же аргументами дает идентичный результат.

В Angular есть «чистые пайпы» (pure pipes) и «грязные пайпы» (impure pipes, т. И это очень важная концепция. пайпы с внутренним состоянием). е. Чистые пайпы обычно форматируют данные, пример «чистого» — DatePipe. Они, как правило, используются для обработки данных.

Разница между этими двумя случаями в том, что Angular выполняет чистый пайп, только когда обнаруживает, что изменился его аргумент. Грязные пайпы хранят внутри определенное состояние, как, например, AsyncPipe. Это понятие из функционального программирования, чтобы лучше его понять, взглянем на код, созданный компилятором Angular для шаблона с чистыми и грязными пайпами. Как правило, выражения с чистыми пайпами рассматриваются Angular как не имеющие побочных эффектов, референциально прозрачные.

На экране показаны два разных результата. Мы применяем к переменной birthday вначале чистый пайп date, а затем грязный impureDate. Загадочные символы в начале выражения нас не интересуют, они нужны только для того, чтобы разработчики не пользовались этими импортами. Поначалу тут сложно разобраться.

_ck() — это проверка, в ней текущее значение date будет сравнено с предыдущим, и, если значение отличается, будет вызван метод date.transform(). Важная для нас часть следует за ними. В случае же с impureDate просто будет вызван метод impureDate.transform(). Если же изменений нет, будет возвращен предыдущий результат, хранящийся в кэше.

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

Кроме того, нам нужно будет поменять шаблон. Основываясь на этом принципе, я инкапсулировал нашу функцию Фибоначчи в написанном мной классе CalculatePipe, просто делегировав вычисление функции fibonacci. Вместо метода calculate мы в нем будем использовать пайп.

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

Оптимизация отрисовки

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

Удален неиспользуемый код, пакет весит 50 килобайт, мы скачиваем его за 100 миллисекунд. Предположим, наше приложение уже оптимизировано разными способами. Несмотря на то, что наша сетевая производительность отличная, пользователь по-прежнему останется недоволен. Но отрисовка изображения занимает по меньшей мере 8 секунд.

В них мы видим дублирующиеся значения. Взглянем на наши данные. Существует по несколько экземпляров функции Фибоначчи с аргументами 27, 28 и 29.

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

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

Она создаст необходимую нам функцию Фибоначчи. Через require('lodash.memoize') мы получаем функцию memoize, и затем вызываем ее. Больше нам ничего не понадобится. При каждом вызове этой созданной функции ее входной аргумент и результат будут записываться в таблицу соответствий. 7 секунд, до этого эти операции заняли 9. Мы видим, что теперь приложение отображается за 6. Для такой небольшой оптимизации это неплохо. 5 секунд.

В первом случае, когда Angular обнаруживает, что мы пытаемся вызвать 27 | calculate, выполнение делегируется функции fibonacci(27). Сравним чистые пайпы и мемоизацию. При дальнейшем обходе списка каждый раз, когда делается вызов 27 | calculate, будет выполнена та же операция, поскольку кэширование происходит только локальное.

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

Вначале мы вызовем 27 | calculate, произойдет вычисление числа Фибоначчи, и в кэш будет записано число 27 и выходное значение функции Фибоначчи. В случае с мемоизацией все будет выглядеть несколько иначе. Экономия времени очевидна. При всех следующих вызовах 27 | calculate результат будет взят из кэша.

С концептуальной точки зрения обнаружение изменений OnPush и мемоизация похожи. Итак, начинают выявляться некоторые общие тенденции. Если представить дерево компонентов как выражение, как абстрактное синтаксическое дерево, к нему также можно применить оптимизации, пользующиеся референциальной прозрачностью. И там, и там мы имеем референциальную прозрачность. Однако в обоих случаях работать это всё будет только с последними входными данными.

Для этого нам потребуется обратиться к некоторым внутренним API Angular. Попробуем провести несколько более продвинутую оптимизацию. Если вы с ними не знакомы, не переживайте, я постараюсь рассказать о них как можно более подробно.

В Angular для этой цели используется директива NgForOf. Около 90% разработки софта сводится к требованию представить пользователю список элементов. Вот как она работает: Попытаемся оптимизировать ее в соответствии с нашими потребностями.

А вот как выглядит сам класс IterableDiffers: В ней есть конструктор, который принимает на вход объект типа IterableDiffers.

Конструктор принимает на вход коллекцию IterableDifferFactory[], а метод find() принимает на вход любую коллекцию (список, двоичное дерево поиска или что-либо другое). В классе этого объекта только конструктор и метод find().

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

Взглянем еще на 3 интерфейса:

С последней вы, возможно, знакомы по директиве NgFor, там она тоже есть. В IterableDifferFactory метод supports() я только что описал, а также в нем есть метод create, принимающий на вход функцию trackByFunction. Метод create возвращает экземпляр интерфейса IterableDiffer.

Ее назначение — сравнивать два экземпляра одной структуры данных. IterableDiffer — абстракция, принимающая на вход структуру данных и хранящая некоторое состояние. Метод diff() возвращает количество отличий между двумя экземплярами (назовем их А и Б), то есть количество элементов, которые надо добавить к А, чтобы получить Б, количество элементов, которые необходимо отнять от А, и количество элементов, поменявших места.

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

IterableDiffer используется в ней для обнаружения расхождений между текущим объектом, по которому происходит итерация, и его предыдущим значением. В директиве NgForOf происходит инъекция IterableDiffers в качестве аргумента конструктору. Этот последний использует TrackByFn, чтобы определить, по каким характеристикам мы будем сравнивать друг с другом элементы в коллекции. IterableDiffers используют коллекцию фабрик, создающих, в свою очередь, IterableDiffer.

Взглянем на то, как NgForOf использует differ.

Если обнаруживаются изменения, они применяются к DOM. Он вызывает метод diff() с текущим значением коллекции, по которой происходит итерация, и сравнивает его с предыдущей версией коллекции.

Посмотрим, как все это будет работать с IterableDiffers и конкретной функцией trackBy:

И у нас есть две коллекции, a и b. У нас есть функция trackBy, которая возвращает идентификатор предоставленного элемента. Они обе являются списками, и в них находятся только элементы.

То же произойдет со вторыми элементами. IterableDiffer вначале сравнит первый элемент из a с первым элементом из b, и, поскольку у них одинаковые идентификаторы, IterableDiffer придет к выводу, что элементы идентичны. Для IterableDiffer это не имеет значения. Обратите внимание, что здесь имена работников отличаются. Однако идентификаторы отличаются, как в случае с третьими элементами в каждом списке, IterableDiffer заключит, что элементы отличаются. Для него важны только идентификаторы. Поэтому он выдаст результат, в котором будет значиться, что последний элемент из a был удален, и заменен последним элементом из b.

Он пользуется ей, как потребитель. IterableDiffer проверяет извне, изменилась ли структура данных. Попробуем реализовать собственную структуру данных DifferableList, вдохновленную другой концепцией из функционального программирования. Но ведь структуре данных лучше знать, изменилась она или нет. В ней будет вестись учет происходящих с ней изменений.

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

При необходимости модификации мы будем вносить изменения в список changes. Сами данные мы будем хранить в неизменяемом списке Immutable.js.

Кроме того, мы реализуем шаблон «итератор», чтобы Angular мог обходить эту структуру данных. Мы, в сущности, применяем шаблон «декоратор» к неизменяемому списку.

Однако differ по умолчанию нам лучшей производительности не обеспечит. Таким образом, мы создали структуру данных, оптимизированную под Angular.

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

Нам просто потребуется расширить существующий набор IterableDiffers. Для этих изменений потребуется небольшой рефакторинг.

Они позволяют делать весьма необычные вещи: путешествовать во времени, создавать новые вселенные как ответвления существующих. Описанная структура данных сделана по общему принципу неизменяемых структур данных — это вновь понятие из функционального программирования. Рекомендую на это взглянуть.

После последнего рефакторинга наша производительность выросла где-то на 30%.

Повторим пройденное

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

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

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

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

Вот несколько полезных ссылок:

В первой статье описывается обнаружение изменений OnPush в Angular, во второй говорится о чистых пайпах и референциальной прозрачности, в третьей — про Angular Differs. По ним можно более подробно познакомиться с описанными темами. Там описано, как можно настраивать обнаружение изменений. Кроме того, есть несколько более подробный вариант чеклиста производительности Angular.

Если вам понравился этот доклад с предыдущей HolyJS, обратите внимание: уже 19-20 мая пройдёт HolyJS 2018 Piter. Минутка рекламы. А ещё обратите внимание на то, что с 1 мая цена билета возрастет, так что сейчас самое время принять решение!

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть