Многие люди знают Минько Гечева (rhyme.com) на основе книги «Переход на Angular» и на текст «Контрольный список производительности Angular», который помогает разработчикам Angular оптимизировать свои проекты.
На нашей декабрьской конференции HolyJS 2017 Москва он также развил тему производительности Angular, выступив с докладом «Быстрые приложения Angular».
И сейчас на основе этого выступления мы подготовили хабрапост, переведя всё на русский язык.
Добро пожаловать коту! А если вы предпочитаете англоязычную видеозапись выступления, прилагаем и ее: Сегодня мы поговорим о производительности во время выполнения.
В случае одностраничных приложений мы обычно говорим либо о производительности сети, либо о производительности во время выполнения.
В первом случае обычно стараются уменьшить количество HTTP-запросов или данных, передаваемых по сети.
В этом направлении проводится много исследований.
Например, над этим работает команда Google Closure Compiler, достигая цели за счет более эффективного удаления неиспользуемого кода и минимизации кода.
У нас также есть различные алгоритмы сжатия, и команда веб-пакетов также преследует схожие цели.
Наконец, Angular CLI пытается объединить лучшее из разных подходов и дает очень хорошо инкапсулированные сборки.
Однако, когда дело касается производительности во время выполнения, развития мало.
Здесь все в наших руках, нет сторонней «волшебной палочки», которая заставит наше приложение работать быстрее.
Есть несколько возможных подходов к проблеме, сегодня я расскажу о более общих решениях, зачастую применимых не только к Angular. Чтобы проиллюстрировать эти решения, я написал «простое бизнес-приложение».
В нем я постарался воспроизвести как можно больше проблем с производительностью, с которыми столкнулся за последние месяцы.
В результате получается совершенно ужасный продукт, который мы постараемся как-то улучшить.
В нашем чрезвычайно упрощенном приложении вы можете добавлять новых работников, представлять их в списке и рассчитывать для них некоторую стоимость.
У нас будет два списка сотрудников: для отдела продаж и для отдела исследований и разработок.
К обоим можно добавлять новые элементы.
Уже существующие элементы представлены в виде списка, где можно увидеть название и некоторое числовое значение (предположим, это оценка работы сотрудника).
Также имеется поле для ввода имени нового сотрудника.
При добавлении сотрудника мы можем просто взять откуда-то число, что-то посчитать и отобразить всё на экране.
Структура приложения состоит из AppComponent (охватывающего все приложение) и двух компонентов EmployeeListComponent (по одному для каждого списка).
Вот шаблонСотрудникаListComponent:
Здесь обратите внимание на элемент ввода.
Он использует синтаксис бананового ящика (сначала квадратные скобки, затем круглые скобки) для установления двусторонней привязки данных между свойством метки, объявленным в контроллереСотрудникListComponent, и текстовым полем.
Кроме того, компонент «СотрудникListComponent» перебирает список сотрудников в массиве данных и создает элемент списка для каждого сотрудника.
Для каждого элемента мы отображаем имя сотрудника и вычисляем числовое значение с помощью метода Calculation(), определенного в классеСотрудникListComponent.
Теперь давайте взглянем на сам этот класс:
Здесь есть несколько важных вещей.
Начнем с того, что он не сохраняет состояние; он получает все необходимые данные (массив EmployeeData[]) в качестве входных данных от родительского компонента.
Таким образом, родительский компонент AppComponent действует как компонент-контейнер в Redux. Кроме того, в классе РаботодательListComponent есть метод Calculation(), единственной целью которого является передача выполнения функции, вычисляющей числа Фибоначчи.
На первый взгляд числа Фибоначчи здесь использовать неудобно, но они имеют ряд важных преимуществ.
Во-первых, способ их расчета всем известен; нет необходимости объяснять сложную работу функции, подходящей, например.
Его можно заменить стандартным отклонением или чем-то подобным.
Во-вторых, эту функцию можно реализовать очень неэффективно, как мы видим на экране.
У нас есть два рекурсивных вызова, и для каждого числа Фибоначчи мы будем заново пересчитывать все предыдущие.
Таким образом, здесь я искусственно замедлил работу приложения, чтобы лучше был виден эффект от последующей оптимизации.
Итак, у нас есть приложение с компонентом приложения и двумя компонентами списка.
Каждый из элементов в списке требует большой вычислительной мощности.
Давайте попробуем использовать в этом приложении некоторые реальные данные.
У нас будет два списка, содержащих в общей сложности 140 элементов.
В этом случае при вводе новых имен набор текста становится крайне медленным.
Вряд ли пользователям понравится то, как работает приложение.
Но почему так медленно? Профилировать эту проблему довольно легко с помощью Chrome DevTools. Сделав это, мы обнаруживаем, что наша функция для вычисления чисел Фибоначчи вызывается очень часто.
Мы можем узнать точное количество вызовов, добавив в эту функцию логирование.
Получается, что каждый раз, когда пользователь нажимает клавишу, все дерево компонентов пересчитывается как минимум дважды (один раз при нажатии и один раз при отпускании клавиши).
Так мы пересчитываем все ранее полученные значения при каждом нажатии.
Вот как эта ситуация выглядит с точки зрения дерева компонентов.
При каждом нажатии клавиши сначала происходит изменение в AppComponent. Поскольку обнаружение изменений в Angular работает как поиск в глубину, обнаружение изменений также будет вызываться для компонентаСотрудникListComponent, а затем для каждого из элементов сотрудника.
Для каждого из этих элементов будет пересчитано их числовое значение.
Затем произойдет тот же обход второго компонента MemberListComponent. Все это крайне неэффективно.
Обычно мы не хотим пересчитывать числовые значения для каждого элемента массива; мы хотели бы сделать это только тогда, когда появится новый массив.
Теперь если новый массив передать из AppComponent в EmployeeListComponent, то можно посчитать.
Есть мысли о том, как лучше всего это сделать? Например, вы можете использовать стратегию OnPush. Благодаря этому обнаружение изменений будет срабатывать только тогда, когда на компоненте появятся новые входные данные.
Когда Angular обнаруживает новые входные данные при проверке ссылок, в компонентах будет выполняться обнаружение изменений.
То есть, если у нас есть дерево компонентов, то когда корневой компонент получает новые данные, мы обновляем всю ветку, начиная с этого компонента.
Чуть позже мы посмотрим, как это будет выглядеть.
Обратимся за помощью к функциональному программированию и попробуем представить, что РаботникListComponent — это функция.
Входные данные компонента — это входные аргументы функции, а изображение экрана — выходные данные функции.
Я продемонстрирую свою идею с помощью псевдокода.
В константе f мы храним ссылку на СотрудникListComponent (теперь это функция), в константных данных мы храним входные аргументы (данные одного сотрудника).
Сначала мы вызываем функцию с ее исходными входными данными, а затем Angular выполнит обнаружение изменений.
Поскольку значение ранее было неопределенным, при сравнении данных и неопределенных Angular увидит изменение значения входных данных.
Но добавление нового элемента в список уже вызовет функцию с тем же аргументом, что и раньше: мы изменим структуру данных, на которую указывает та же константа данных.
Поэтому Angular не будет инициировать обнаружение изменений.
Однако обнаружение изменений произойдет, если мы передадим копию массива в качестве входного аргумента функции: ссылка там изменится.
Означает ли это, что каждый раз, когда нам нужно обнаружить изменения, нам нужно копировать весь массив? Это было бы крайне неэффективно по нескольким причинам.
Во-первых, это было бы крайне неоптимальное использование памяти.
Для каждого обнаружения изменения нам нужно сначала выделить память для всего нового массива, а затем сборщику мусора необходимо ее освободить.
Во-вторых, это вычислительно неэффективно.
Временная сложность такого алгоритма составляет не менее O(n).
Неизменный
В обоих случаях разумнее использовать что-то вроде Immutable.js. Это коллекция различных неизменяемых структур данных с двумя очень важными свойствами.Во-первых, мы не можем изменить какую-либо существующую структуру данных.
Вместо этого вызовы, которые изменяют такую структуру данных, получают новую ссылку на нее с уже примененными изменениями.
Во-вторых, мы не копируем всю структуру данных: новый экземпляр этой структуры по возможности будет использовать элементы старой.
Вот такой рефакторинг нам нужно сделать.
Во-первых, мы изменили содержимое методов add() и Remove().
В add(), когда мы выполняем процедуру unshift(), которая перемещает элемент в начало списка, мы получаем новый список.
То же самое и в методе remove(): вызов splice() возвращает нам новый список.
Помимо этих двух методов нам необходимо изменить ссылку на список.
В противном случае мы не смогли бы уведомить MemberListComponent о том, что входные данные изменились.
Таким образом, выходные значения add() и Remove() должны быть присвоены свойству списка AppComponent. Давайте запустим приложение и посмотрим, насколько теперь все работает быстрее.
Мы тут его оптимизировали, должно было стать лучше.
Хм, приложение по-прежнему очень медленное.
Возможно, это будет быстрее, чем раньше, но пользовательский опыт вряд ли будет хорошим.
Чтобы измерить, насколько быстрее стало приложение, я написал несколько сквозных тестов и запустил их на Angular Benchpress.
Благодаря им мы видим, что приложение ускорило работу как минимум в два раза.
Однако этого недостаточно.
Причина низкой производительности заключается в том, что при вводе текста по-прежнему срабатывает обнаружение изменений.
Хорошая новость в том, что теперь он работает только по одному из двух списков, но даже там он не нужен, поскольку ни одно числовое значение не изменилось.
Посмотрим, как теперь выглядит приложение с точки зрения дерева компонентов.
При каждом нажатии клавиши мы несколько раз запускаем обнаружение изменений в AppComponent, StudentListComponent и каждом из отдельных компонентов.
При этом вызовы во второй список не совершаем.
Но почему вообще происходит обнаружение изменений, если не было обращений ни к одному из вызовов, изменяющих структуру данных списка? Причина в некоторых характеристиках обнаружения изменений OnPush, которые не очень хорошо документированы.
Суть в том, что обнаружение изменений OnPush срабатывает не только при изменении входных данных, но и при срабатывании события в соответствующем компоненте.
Зная эту особенность, мы теперь можем выполнить рефакторинг кода.
В этом есть своя положительная сторона, поскольку в то же время мы можем улучшить разделение обязанностей в нашем приложении и сделать дерево компонентов более стройным.
Давайте создадим два дочерних компонента вСотрудникListComponent: NameInputComponent и ListComponent. Первый будет отвечать только за сохранение текущего значения входной строки и вызов события.
Во втором будет оцениваться функция и будет использоваться обнаружение изменений OnPush. После этих изменений в коде приложение стало работать значительно быстрее.
Как именно сейчас работает приложение? К сожалению, когда пользователь нажимает клавишу, обнаружение изменений по-прежнему вызывается в AppComponent, а затем в обоих экземплярах StudentListComponent. Но на этот раз обнаружение изменений больше не вызывается в дочерних компонентахСотрудникListComponent. Дело в том, что ListComponent использует обнаружение изменений OnPush, и событие происходит в области действия EmployeeListComponent, т. е.
в родительском компоненте EmployeeList. Скорость печати увеличивается на несколько порядков.
Однако нам этого недостаточно.
Другая возможная оптимизация касается добавления элементов.
Когда мы создаем новый элемент, мы вызываем операцию добавления к неизменяемому списку, поэтому создается новый список и передается в качестве входных данных в компонентСотрудникListComponent. Это запускает обнаружение изменений.
То есть при вводе текста все теперь происходит быстро, но при добавлении элемента все равно происходит ненужный пересчет числового значения во всех этих компонентах.
Чтобы решить эту проблему, нам нужно обратиться к нашей функции вычисления чисел Фибоначчи.
О чистых функциях мы сегодня уже упоминали, и это одна из них.
Хорошей новостью является то, что чистые функции также являются некоторыми из вещей, которые действительно полезны в наших приложениях, например, вычисление стандартного отклонения.
Чистые функции обладают двумя очень важными свойствами.
Во-первых, у них нет побочных эффектов, то есть через сеть не осуществляются вызовы, не происходит логирование и так далее.
Во-вторых, повторный вызов функции с теми же аргументами дает тот же результат. В мире функционального программирования это называется «чистая функция».
И это очень важная концепция.
В Angular есть «чистые каналы» и «нечистые каналы» (т. е.
каналы с внутренним состоянием).
Обычно они используются для обработки данных.
Чистые каналы обычно форматируют данные, примером «чистого» является DatePipe. Грязные каналы хранят внутри себя определенное состояние, например AsyncPipe. Разница между этими двумя случаями заключается в том, что Angular выполняет чистый канал только тогда, когда обнаруживает, что его аргумент изменился.
Обычно выражения с чистыми каналами рассматриваются в Angular как не имеющие побочных эффектов и ссылочно прозрачные.
Это концепция из функционального программирования, чтобы лучше ее понять, давайте взглянем на код, сгенерированный компилятором Angular для шаблона с чистыми и грязными каналами.
Сначала мы применяем чистый канал даты к переменной дня рождения, а затем грязный канал нечистой даты.
На экране показаны два разных результата.
Сначала сложно это понять.
Нас не интересуют загадочные символы в начале выражения; они нужны только для того, чтобы разработчики не могли использовать этот импорт. Важная часть для нас следует за ними.
_ck() — это проверка, при которой текущее значение даты будет сравниваться с предыдущим, и если значение отличается, будет вызван метод date.transform().
Если изменений нет, будет возвращен предыдущий результат, хранящийся в кеше.
В случае с impureDate будет просто вызван метод impureDate.transform().
Таким образом, ссылочная прозрачность означает, что семантика выражения никак не изменится, если вместо этого выражения подставить его выходное значение.
Побочные эффекты будут незначительными.
Основываясь на этом принципе, я инкапсулировал нашу функцию Фибоначчи в написанный мной класс CalculatePipe, просто делегировав вычисления функции Фибоначчи.
Кроме того, нам нужно будет изменить шаблон.
Вместо метода расчета мы будем использовать в нем канал.
Теперь попробуем протестировать приложение: Benchpress несколько раз добавит и удалит нового пользователя.
Видно, что приложение уже работает довольно быстро.
Производительность выросла на несколько порядков.
Оптимизация рендеринга
Я хотел бы поговорить еще о двух оптимизациях.Первое касается эффективности рендеринга.
Попробуем одновременно отобразить в нашем приложении 1000 элементов.
В реальном приложении мы этого делать, конечно, не будем — для таких ситуаций есть виртуальная прокрутка или пагинация.
Но здесь мы попробуем оптимизировать работу по-другому.
Предположим, наше приложение уже оптимизировано различными способами.
Удален неиспользуемый код, пакет весит 50 килобайт, скачиваем за 100 миллисекунд. Но рендеринг изображения занимает не менее 8 секунд. Даже несмотря на то, что производительность нашей сети превосходна, пользователь все равно будет недоволен.
Давайте посмотрим на наши данные.
В них мы видим дублирующие смыслы.
Существует несколько экземпляров функции Фибоначчи с аргументами 27, 28 и 29.
Благодаря чистым каналам у нас есть некоторое кэширование, но эти значения все равно вычисляются несколько раз.
К счастью, все наши примеры находятся в пределах небольшого разрыва.
Вы можете попробовать создать глобальную систему кэширования.
Чистые каналы создают кеш только для одного выражения.
Мы увидим разницу между этим подходом и настоящим кэшированием с использованием мемоизации.
Мемоизация, которую мы будем использовать, возможна только для чистых функций.
Его использование довольно просто:
Через require('lodash.memoize') мы получаем функцию memoize и затем вызываем ее.
Это создаст нужную нам функцию Фибоначчи.
Каждый раз, когда вызывается эта созданная функция, ее входной аргумент и результат будут записаны в таблицу поиска.
Больше нам ничего не понадобится.
Видим, что теперь приложение отображается за 6,7 секунды, раньше эти операции занимали 9,5 секунды.
Для такой маленькой оптимизации это неплохо.
Давайте сравним чистые пайпы и мемоизацию.
В первом случае, когда Angular обнаруживает, что мы пытаемся вызвать 27 | вычислить, выполнение делегируется функции fibonacci(27).
Продолжая перемещаться по списку, каждый раз при вызове 27 | рассчитать, будет выполнена та же операция, поскольку кэширование происходит только локально.
Однако в следующий раз, когда будет обнаружено изменение, Angular не будет пересчитывать результат, если аргументы вычисления не изменились.
Таким образом, наша оптимизация будет работать при каждом следующем запуске обнаружения изменений.
В случае с мемоизацией все будет выглядеть немного иначе.
Сначала мы позвоним 27 | вычислить, будет вычислено число Фибоначчи, а число 27 и выходное значение функции Фибоначчи будут записаны в кэш.
Для всех последующих вызовов 27 | результат расчета будет взят из кеша.
Экономия времени очевидна.
Итак, начинают проявляться некоторые общие тенденции.
Концептуально обнаружение изменений и запоминание изменений OnPush аналогичны.
В обоих случаях мы имеем ссылочную прозрачность.
Если вы думаете о дереве компонентов как о выражении, как об абстрактном синтаксическом дереве, вы также можете применить к нему оптимизации, которые используют преимущества ссылочной прозрачности.
Однако в обоих случаях это будет работать только с последними входными данными.
Давайте попробуем более продвинутую оптимизацию.
Для этого нам понадобится доступ к некоторым внутренним API Angular. Если вы с ними не знакомы, не волнуйтесь, я постараюсь рассказать о них как можно подробнее.
Около 90% разработки программного обеспечения сводится к требованию предоставить пользователю список элементов.
Для этой цели Angular использует директиву NgForOf. Мы постараемся оптимизировать его в соответствии с нашими потребностями.
Вот как это работает:
У него есть конструктор, который принимает объект типа IterableDiffers в качестве входных данных.
А вот как выглядит сам класс IterableDiffers:
Класс этого объекта содержит только конструктор и метод find().
Конструктор принимает на вход коллекцию IterableDifferFactory[], а метод find() принимает на вход любую коллекцию (список, двоичное дерево поиска или что-то еще).
Затем этот метод ищет среди всех доступных фабрик ту, которая поддерживает структуру данных, полученную на входе.
Если требуемая фабрика найдена, метод возвращает ее.
Больше ничего здесь не происходит.
Давайте посмотрим еще на 3 интерфейса:
В IterableDifferFactory я только что описал метод support(), а также в нем есть метод create, который принимает на вход функцию trackByFunction. Последний, возможно, вам знаком по директиве NgFor, он тоже там есть.
Метод create возвращает экземпляр интерфейса IterableDiffer. IterableDiffer — это абстракция, которая принимает структуру данных в качестве входных данных и сохраняет некоторое состояние.
Его цель — сравнить два экземпляра одной и той же структуры данных.
Метод diff() возвращает количество различий между двумя экземплярами (назовем их A и B), то есть количество элементов, которые необходимо добавить к A, чтобы получить B, количество элементов, которые необходимо вычесть из A, и количество элементов, которые поменялись местами.
Наконец, TrackByFunction. Подробно я расскажу об этом чуть позже.
Для начала давайте посмотрим на взаимоотношения между описываемыми структурами.
Директива NgForOf вводит IterableDiffers в качестве аргумента конструктора.
IterableDiffer используется для обнаружения различий между текущим объектом, по которому выполняется итерация, и его предыдущим значением.
IterableDiffer использует коллекцию фабрик, которые, в свою очередь, создают IterableDiffer. Последний использует TrackByFn, чтобы определить, по каким характеристикам мы будем сравнивать элементы коллекции друг с другом.
Давайте посмотрим, чем отличается использование NgForOf.
Он вызывает метод diff() с текущим значением коллекции, которую он перебирает, и сравнивает его с предыдущей версией коллекции.
Если изменения обнаружены, они применяются к DOM.
Давайте посмотрим, как все это работает с IterableDiffers и конкретной функцией trackBy:
У нас есть функция trackBy, которая возвращает идентификатор предоставленного элемента.
И у нас есть две коллекции: a и b. Оба списка представляют собой списки и содержат только элементы.
IterableDiffer сначала сравнит первый элемент в a с первым элементом в b, и, поскольку они имеют одинаковый идентификатор, IterableDiffer сделает вывод, что элементы идентичны.
То же самое произойдет и со вторыми элементами.
Обратите внимание, что имена рабочих здесь другие.
Для IterableDiffer это не имеет значения.
Для него важны только идентификаторы.
Однако идентификаторы разные, как и в случае с третьими элементами в каждом списке, IterableDiffer придет к выводу, что элементы разные.
Таким образом, он выдаст результат, указывающий, что последний элемент a был удален и заменен последним элементом b.
IterableDiffer проверяет извне, изменилась ли структура данных.
Он использует его как потребитель.
Но структуре данных лучше знать, изменилась она или нет. Давайте попробуем реализовать нашу собственную структуру данных DifferableList, вдохновленную другой концепцией функционального программирования.
Он будет вести учет происходящих с ним изменений.
Для этого мы будем использовать LinkedList (хранящийся в переменной изменений), поскольку он обеспечивает немного лучшую производительность, чем массив, и нам не нужен произвольный доступ к элементам.
Сами данные мы будем хранить в неизменяемом списке в Immutable.js. При необходимости внесения изменений мы внесем изменения в список изменений.
По сути, мы применяем шаблон декоратора к неизменяемому списку.
Кроме того, мы реализуем шаблон «итератор», чтобы Angular мог перемещаться по этой структуре данных.
Таким образом, мы создали структуру данных, оптимизированную для Angular. Однако разница по умолчанию не обеспечит нам более высокую производительность.
Мы можем использовать специальную разницу, где будет постоянная проверка изменений в структуре данных.
Поэтому нет необходимости каждый раз полностью обходить его.
Вместо этого вы можете просто работать со свойством изменений.
Эти изменения потребуют небольшого рефакторинга.
Нам просто нужно расширить существующий набор IterableDiffers.
Описанная структура данных выполнена по общему принципу неизменяемых структур данных — это опять-таки концепция из функционального программирования.
Они позволяют делать весьма необычные вещи: путешествовать во времени, создавать новые вселенные как ответвления уже существующих.
Рекомендую взглянуть на это.
После последнего рефакторинга наша производительность выросла примерно на 30%.
Давайте повторим то, что мы рассмотрели
Обнаружение изменений OnPush не всегда ведет себя так, как мы ожидаем.Обнаружение изменений вызывается в поддереве данного компонента не только при изменении входных данных этого компонента, но также и при возникновении события в этом компоненте.
Кроме того, мы узнали разницу между чистыми каналами и мемоизацией, а также разницу между соответствующими механизмами кэширования.
Понял концепции чистоты и ссылочной прозрачности, взятые из функционального программирования.
Наконец, мы рассмотрели, как работают объекты Differ и функция TrackByFn. И помните, что использование другого TrackByFn, отличного от этого по умолчанию, может только снизить производительность.
В заключение хочу сказать, что не существует волшебного средства для оптимизации производительности.
Нам нужно очень хорошо понимать, как структурировано дерево компонентов и данные, с которыми мы работаем, и на основе этого применять оптимизации, специфичные для нашего приложения.
И, конечно же, нам необходимо применять решения, предлагаемые нам информатикой.
Вот несколько полезных ссылок:
- mgv.io/ng-cd — Стратегия обнаружения изменений OnPush Angular
- mgv.io/ng-pure — Чистые каналы и ссылочная прозрачность
- mgv.io/ng-diff — Понимание угловых различий
- mgv.io/ng-perf-checklist - Контрольный список производительности Angular
-
Приватбанк Начал Продавать Сойлент
19 Oct, 24 -
Где, Черт Возьми, Мэтт?
19 Oct, 24 -
Intel Делает Ставку На Конкурента Skype
19 Oct, 24 -
Концепция Окна Для Любителей Ностальгии
19 Oct, 24 -
Смартфон Или Диктофон: Что Удобнее?
19 Oct, 24