Блог компании Конференции Олега Бунина (Онтико)
JavaScript
Высокая производительность
Разработка веб-сайтов
25 декабря 2017

Оптимизация производительности фронтенда

Тормозящий сайт — это боль не только пользователя, но и разработчика. Как можно исправить ситуацию, в каких случаях нужно делать ставку на кэширование, а где можно довериться процессору, и как все это может помочь оптимизировать производительность сложного фронтенд-приложения, на практике готов объяснить эксперт по JS и преподаватель Академии HTML Игорь Алексеенко (@iamo0). Под катом — расшифровка его доклада с Frontend Conf 2017.




О спикере
Игорь Алексеенко — разработчик с большим стажем, ведет базовый и продвинутый курсы по JS  в Академии HTML. Работал в Студии Лебедева, Островке и JetBrains.

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



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



Почему? Потому, что мы, как разработчики, отвечаем за те эмоции, которые люди испытывают на сайтах, за их experience. Если человек придет на сайт и получит какой-то негативный опыт – это наша вина. Это не дизайнер, не сложная технология — это мы.



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



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

Потерянный пользователь – это потеря денег, недополученная выручка с точки зрения компании и, возможно, ваше увольнение.

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



Тормоза на сайтах возникают, когда взаимодействие с пользователем перестает быть ровным. Что это значит? Дело в том, что когда пользователи просматривают сайт, они видят не просто какую-то статическую картинку. Потому что сайт – это не просто картинка, это это процесс взаимодействия пользователя с интерфейсом, который мы ему предлагаем.



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



Почему это работает динамически? Почему сайты могут жить продолжительное время?
Это происходит из-за того, что во все движки браузеров встроена такая конструкция, как Event Loop.



На самом деле Event Loop – это такой простой программистский прием, который заключается в том, что мы просто запускаем бесконечный цикл с какой-то определенной частотой. Так чтобы у нас не забился стек и была какая-то производительность.

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



С Event Loop, который встроен в движок браузера, синхронизированы все взаимодействия с пользователем – прокрутки, прочие штуки и код, который мы выполняем в долгосрочной перспективе. То есть все зависит от этого Event Loop.

Как попадать в кадры Event Loop?

У нас есть цикл, который крутится с определенной частотой. Но мы, как фронтендеры, не можем контролировать эту частоту и не знаем ее. У нас есть лишь возможность пользоваться уже готовыми, предназначенными для нас кадрами. То есть частоту мы не контролируем, но вписаться в нее можем. Для этого есть конструкция requestAnimationFrame.

Если мы передаем код в callback requestAnimationFrame, то попадаем в начало очередного кадра обновления. Кадры обновления бывают разные. На MAC, например, эти кадры стараются вписаться в 60 Fs, но частота не всегда бывает равной 60 Fs. Дальше на примерах я это покажу.



Мы уже разобрали, что в основе Javascript лежит бесконечный цикл, который обновляется со временем. Теперь можно предположить, откуда могут возникать тормоза.

Есть кадры, которые со временем обновляются. Мы запускаем на сайте какие-то вычисления. Любые. Все, что мы пишем в Javascript, – это фактически вычисления. Если эти вычисления занимают дольше одного видимого кадра обновления, пользователь видит лаги.



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



Или вычисления могут стать слишком большими, и тогда страница зависнет и с ней ничего нельзя будет делать.

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



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

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

Об этом я как раз и хотел поговорить.



Первая оптимизация, которая видится логичной, – использовать память для того, чтобы не использовать вычисления.

В принципе такая стратегия звучит выигрышно. Более того, это хорошая стратегия и она уже используется.



Например, в Javascript есть встроенный объект Math, который предназначен для работы с математикой и вычислениями. Этот объект содержит не только методы, но и некоторые посчитанные популярные значения — чтобы их не пересчитывать. Например, как в случае числа π, которое сохранено до определенного знака.

Во-вторых, есть хороший пример про старые времена. Я очень люблю программистов 80-х годов потому, что они писали эффективные решения. Железо было слабое, и им приходилось придумывать какие-то хорошие штуки.

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

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

В принципе звучит очень круто.



Смотрите, вот кадры, и вместо того, чтобы запускать вычисления, которые занимают несколько кадров, мы запускаем вычисления, которые занимаются только чтением: прочитали готовое значение, подставили, использовали. И получается, что интерфейс работает очень быстро.
Теоретически это звучит очень круто. Но давайте подумаем о том, как именно фронтендеры работают с памятью.



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

Но мало того — мы не можем этой памятью управлять.

Есть еще особенность - в этой памяти уже что-то хранится:

  1. В этой памяти хранится ран-тайм языка: все конструкторы, все функции. Сам язык хранится в памяти;
  2. Вы тоже пользуетесь какими-то данными: скачиваете что-то с Аякса, генерируете какие-то структуры;
  3. У вас есть DOM-дерево, и оно тоже попадает в память, потому что Javascript не умеет читать HTML, и браузер преобразовывает для него разметку в набор объектов, в дерево. В память браузера, во вкладку попадает все, что есть в разметке, в том числе все теги в виде каждого отдельного объекта. Попадают тексты.

То есть для каждого переноса, который стоит между тегами, создается объект памяти браузера, как text-mode, и он висит.
Мы видим, что у нас и так куча всего в памяти, но при этом хотим записать туда что-то свое. Это достаточно опасно, потому что память может начать тормозить.



Есть две основных причины — и, как ни странно, они противоречат друг другу. Первая называется «Сборка мусора», а вторая – «Отсутствие сборки мусора».



Разберем каждую из них.

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



Дело в том, что все эти вещи можно измерить. В любом браузере есть инструменты разработчика. Я буду показывать на примере Chrome, но в других браузерах это тоже есть. Мы будем смотреть вкладку «Профилирование» или «Perfomance».

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

Из чего состоит эта вкладка?

  • Во-первых, сверху есть тайм-лайн, которая показывает секунды, то есть время жизни вкладки.
  • Fps – частота кадров, которая была в тот момент времени;
  • Загруженность процессора – на совместном графике он показывает разные вычисления;
  • Скриншоты. Можно их, кстати, не показывать. Советую их отключать, потому что если вы записываете профиль производительности со включенными скриншотами, у вас гарантированно проседает Fps. То есть если вам нужно посчитать именно Fps, выключите скриншоты на всякий случай.
  • Память. Это самый нижний график.
  • Детальная статистика той же самой информации, которую мы видим сверху. То есть можно увеличить в любой момент профайлеры и посмотреть именно по кадрам, что происходило. Мы можем увеличить даже до кадра и посмотреть, какие операции на нем выполнялись, — даже с ссылками на код.

Итак, эту производительность мы будем мерять на Instagram с котиками. Все любят котиков, все любят Instagram – поэтому я решил сделать так.



Котиков не бывает много, поэтому мы будем смотреть большие страницы. У нас будет пять страниц с 5 000 котиков, и мы научимся их переключать.

Ниже код, с помощью которого я генерирую котиков. Они все уникальные.



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



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



Это работает быстрее.

Итак, сборка мусора.



Сборка мусора – это такой процесс, который предназначен для оптимизации работы с памятью. Он не контролируется нами. Браузер сам запускает его тогда, когда понимает, что выделенная под эту вкладку память заканчивается и нужно удалить старые неиспользуемые объекты.

Старые неиспользуемые объекты – это объекты, на которые больше нет ссылок. То есть это объекты, не записанные в переменные, в объекты, в массивы – в общем, никуда.

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

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

Давайте посмотрим на примере.



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

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

Мне хватило памяти на первые две страницы, и я отрисовал 10 000 котиков. Дальше память закончилась и, чтобы отрисовать еще 5 000 котиков, я удалил старых, потому что они больше не используются.

В принципе это круто. Действительно, браузер обо мне позаботился и удалил то, что я не использую. В чем проблема?



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

Если сложить 4 записи garbadge collecting’а, видно, что процесс сборки мусора занял 134 мс – это 10 кадров при 60 Fps.



То есть если бы вы хотели проанимировать за какое-то определенное время перемещение блока на 600 Ps, то у вас пропало бы перемещение на 100 Ps просто за счет того, что браузер решил почистить память. Вы не контролируете ни наступление этого процесса, ни длительность. Это плохо.

Утечка памяти — ситуация, когда при сборке мусора некоторые неиспользуемые объекты остаются в памяти, потому что сборщик мусора считает, что они могут использоваться

Вторая проблема абсолютно противоположна первой. Она называется утечка памяти.

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

Утечка памяти – это такой процесс, когда той самой сборки мусора нет. То есть даже она, может быть, происходит, но не чистит то, что нам нужно. Иногда мы можем накидать что-то эдакое — и, глядя на него, движок браузера поймет, что не может это почистить.

Посмотрим на примере кода.



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

Допустим, мне нужно нажать на пробел. Я повесил на всякий случай на документ обработчик, чтобы Keydo никуда не исчез. Что произойдет в этом случае?

Когда я чищу контейнер с помощью удаления HTML, у меня удалятся DOM- ноды с DOM-дерева. Но обработчики на документе останутся, потому что документ останется на странице. Ничего с ним не произойдет. Когда будет происходить сборка мусора, сборщик мусора эти обработчики не удалит.
Давайте посмотрим, что написано внутри этих обработчиков?

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

Что произойдет в этом случае? Посмотрим на график.



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

Память не освобождается. Компьютер тоже начинает тормозить.

Почему? Потому что большая память – это очень плохо. Процессор будет забиваться при выполнении операций на большом DOM’е.

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



Сам Брендан Айк, который создал Javascript, в недавнем интервью (WebAssembly) сказал: «Javascript – хороший язык. Он быстрый и по производительности иногда может тягаться с C, но проблема начинается, когда возникает сборка мусора, потому что мы не знаем, когда к нам придет сборщик и сколько времени он проработает».

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

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



Поэтому давайте посмотрим на другую сторону картины, которую я показывал: как можно оптимизировать скорость приложения с точки зрения процессора – основного вычислительного устройства.



Есть три основных способа ускорить работу процессора:

  1. Уменьшить объем вычислений;
  2. Затротлить – чуть позже я поясню, что это такое;
  3. Не пользоваться процессором. Это тоже достаточно странный, но хороший способ оптимизировать процессор.



Давайте посмотрим на нашу картину с котиками.



Я говорил, что у меня отрисовывается пять страниц по 5 000 котиков. В принципе, это, кстати, реальный объем. Вы можете поскроллить в течение 0,5 минуты этих котиков, и у вас получится DOM на 5 000 элементов.

Но если подумать — для первой загрузки это не нужно. Мы сейчас видим четыре ряда и пять колонок котиков. Это 20 котиков. Получается, что пользователь, открывая страницу в первый раз, видит 20 котиков, а не все 5 000 картинок.

А браузер отрисовывает 5 000. Получается, что мы отрисовываем очень много лишнего. Зачем рисовать 5 000 котиков, если показываем 20?

Хорошо, мы даже можем сделать задел – пользователь может скроллить сайт и ему тоже нужно что-то показывать. Но если отрисовать 100 котиков, это уже будет 5 экранов.



Поэтому первое, что можно сделать, – уменьшить объем DOM’а. Это самый простой способ. Вы уменьшаете DOM, и все работает быстрее.

Давайте я вам это докажу с точки зрения профайлера.



Я отрисовываю первую страницу на 5 000 элементов и с помощью профайлера записываю процесс загрузки. Кстати, здесь есть кнопка «Reload», и если вы на нее нажмете, профайлер будет записывать скорость загрузки страницы. Он ее перезагрузит и, когда страница полностью отрисуется и все будет готово, он прекратит запись этого скриншота, и 5 000 котиков отрисуются за четыре секунды.

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



Если уменьшить страницу до 100 элементов (до пяти экранов), то загрузка будет идти всего 0,5 с. Причем пользователь сразу увидит готовый результат — без оберток.

Поэтому в первую очередь нужно уменьшить объемы вычислений.

Второй момент – это тротлинг.



Что такое тротлинг?

Представьте, что у вас есть определенная частота кадров и она не вписывается в ту частоту кадров, которую мы выбрали. Мы об этом уже говорили.

Тротлинг – это такой способ мышления, когда мы делаем шаг назад и пытаемся понять, действительно ли нам нужна та частота обновления, которая есть?



Допустим, это 60 Fps, и операции выполняются с этой частотой. Но вычисления занимают больше 16,5 мс. И тут нужно подумать – действительно ли нужно вписывать эти вычисления в 16,5 мс? Если нет, то мы можем прорядить частоту до нужной.

Давайте рассмотрим пример. Мы только что оптимизировали котиков и показываем не 5 000 котиков, а 100. Давайте изменим способ взаимодействия пользователя с этими котиками. Мы не будем показывать сразу большие страницы, мы будем показывать котиков по мере необходимости.



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

Но я в этот код дописал немного статистики. Я записал дельту в пикселях – как часто у меня срабатывает событие скролла, и счетчик общих событий скролла. Когда я проскроллил страницу сверху вниз, при ее высоте в 1 000 ps событие скролла срабатывало каждые четыре. Сверху донизу произошло 500 проверок.



Почему так много? Потому что скролл происходит как раз с частотой 60 Fps – той самой. Причем вчера LG показал Ipad с частотой экрана 120 Гц, значит, они теперь будут стараться делать 120 Fps, а не 60. И тут нужно будет еще сильнее задуматься об этом. Помните осла из мультика про Шрека, который спрашивал «Мы уже приехали? Мы уже приехали?» –  точно так же ведет себя моя проверка. Она работает слишком часто и навязчиво.

Тротлинг заключается в том, чтобы прорядить количество кадров. Мне не нужно проверять каждые 4 ps до скроллинга низа страницы. Я могу это делать, допустим, 1 раз в 100 мс.

Здесь я добавил небольшую проверку, основанную на датах.



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

Когда я использовал 100 мс, проверка выполнялась каждые 20-30 ps, и сверху донизу произошло всего лишь 100 проверок. В принципе это нормально – раз в 100 ps спросить, не находимся ли мы внизу.

Это второй способ вычисления. Проверьте частоту кадров. Может быть, для определенной задачи вам не нужно 60 Fps и можно ее снизить.

Третий способ – отдать вычисления.



Как можно отдать вычисления с процессора? Есть несколько вариантов:

  1. Можно некоторые вычисления отдать на видеокарту.
  2. Некоторые вычисления можно отдать на сервер;
  3. Можно отдать вычисления в другой поток. Это не разгрузит процессор пользователя — но разгрузит процесс, который открыт во вкладке.

Рассмотрим каждый из этих методов.



Для начала расскажу, почему браузерные игры делаются не на SVG, а на сanvas.

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

Рассмотрим сравнение идеологии SVG и сanvas.



Под SVG, когда вы что-то отрисовываете, нужен DOM-элемент. SVG – это DOM. Вы же описываете формат в виде разметки, а, как мы раньше уже выяснили, вся разметка попадает в JS в виде DOM-дерева – в виде объекта, например, с класс-листом или со всеми остальными свойствами.

Когда вы пишете на сanvas, вы просто оперируете пикселями. У вас есть методы, которые описывают взаимодействие с сanvas. В итоге получаются пиксели на экране — и больше ничего.
Поэтому на сanvas приходится придумывать свои структуры данных — поскольку нет DOM-дерева, которое за нас было кем-то придумано. Но зато это может быть чуть-чуть лучше для решения задач, чем те структуры, которые предлагает SVG.

Раз у SVG есть какая-то стандартная структура —  у нее есть API. То есть с SVG можно делать какие-то взаимодействия, например, обновлять по одиночке, анимировать.

На сanvas всего этого делать нельзя. Вам придется все писать руками, как на ассемблере. Но зато вы можете получить прирост производительности. Ведь SVG – это DOM, и он будет считаться на процессоре, а отрисовка пикселей — на видеокарте.

Давайте рассмотрим пример, для чего немного повращаем Землю.



Здесь разрешение смешное по нынешним временам – 800х600 – вообще ни о чем. Та Земля, которую вы видите, – это векторная графика. Все страны описаны через один сложный path. Я не отрисовывал каждую страну отдельно в виде объекта. Они все уложены в одну линию определенной формы.

Я буду обновлять кадры через requestAnimationFrame. То есть браузер сам мне скажет, какой у меня Fps для отрисовки этой штуки. Он сам поймет, за сколько он сможет отрисовать 1 кадр.

В качестве анимации я буду проворачивать Землю на 360° – от Лондона до Лондона. Так было проще написать – в массив мне нужно передать 0, потому что это координата Лондона.



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

Здесь задействована вкладка Main. Это работа непосредственно процессора по изменению в массиве числа 0 на число 360 и просчет этого контура в зависимости от угла – умножает известные ему координаты стран на формулу проекции их на окружность.

Дальше используется только видеокарта.

В итоге я запустил несколько тестов, и у меня получилось, что в среднем анимация длится шесть секунд. С помощью нехитрых вычислений – 360° за 60 с — получается 60 Fps – все хорошо.



А вот SVG справился чуть хуже. Почему? Потому что если посмотреть на две последние линии, мы увидим заполненную вкладку Raster. Это значит, что SVG создавал под каждый кадр DOM-объект, просчитывал все его параметры полностью с помощью процессора, а не с помощью GPU, и с помощью видеокарты уже рендерил его в виде пикселей.

То есть сначала DOM-объект, который долго и сложно просчитывается, потом пиксели на экране. Это достаточно серьезно просадило производительность. Уходило примерно восемь секунд, а это около 45 Fps.

Я обвел 45 Fps красной рамочкой, потому что это, что называется, за гранью позора. Вы скажете: «Ты что говоришь? Есть даже фильмы, которые идут с частотой 24 Fps».

Неправда. Даже фильмы, которые еще на заре кинематографа записывались с частотой 24 Fps, показывали с частотой 48 Fps. Эту концепцию нам объясняет Томас Эдисон: «Да, человек будет видеть как движение 24 Fps, но он будет видеть мерцание от обновления картинки».



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

Для этого в старых фильмах каждый кадр показывался два раза.

То есть даже на заре кинематографа фильмы показывались с частотой 48 Fps. А SVG не справился, провалил тест производительности, и это плохо.

То есть получается, что в определенных случаях сanvas лучше SVG. Например, если у вас много таких disposable элементов, которые нужно выкидывать, лучше использовать сanvas.

Еще один тест.



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



Когда я замерил производительность оборота Земли на сanvas без чистки сanvas, у меня получилось 60 Fps. Я пять раз перепроверил, не ошибся ли я картинкой. 60 Fps – и наплевать на сanvas.

Как вы думаете, как справился SVG?



Полная анимация заняла 24 минуты! У него ничего не чистилось, и произошла классическая утечка. Память росла и росла. Я хотел показать профайлер, но в первый раз увидел окно смерти на профайлере. Это вообще очень странно.

Чтобы объяснить, что произошло, я покажу профайлер на обычном SVG.



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

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

Нет, лучше canvas.



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



Я подумал: графика? Графика. SVG не подойдет, потому что шаг обновления – 1ps, то есть каждый пиксель у меня что-то значит. Поэтому должен быть сanvas.



Я решил эту задачу на сanvas. Приходила структура данных, я проходил по ней, ставил точку на карте, которая имела цвет определенной насыщенности.

Что получилось? На самом деле мне не сильно понравилось решение, потому что:

  1. Мне пришлось писать очень много костылей. Canvas – это практически графический ассемблер, у него API достаточно низкого уровня и приходилось вручную работать с каждым пикселем.
  2. Мне приходилось запускать очень много вычислений, а пользователь видел одно и то же. То есть когда пользователь чуть сдвигал карту — я пересчитывал все заново.

Тогда я подумал: «Хорошо, я умный разработчик, я решил офигенно сложную задачу – нарисовал теплокарту, но стоит подумать о пользователе и не выделываться, а просто:
  • прийти к бэкендеру,
  • у которого Python,
  • красивая библиотека для работы с графикой,
  • которая нагенерирует мне статических картинок,
  • и они лежат не у меня,
  • и еще и закэшируются.

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

Как вы знаете, у картинок есть еще одно преимущество – их не надо пересчитывать каждый раз. Они запоминаются и отрисовываются – все хорошо.



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

Давайте рассмотрим задачу. Есть редактор, например, Ace. Поскольку это полностью клиентсайдный редактор, то клиентсайд отвечает за такие базовые вещи как мигание курсора, скролл, перемещение курсора и прочие.

Но, помимо этого, нужно подсветить код или сказать: «Чувак, ты написал неправильный оператор в неправильном месте» — и все это сломать.

Чтобы сказать, что пользователь написал неправильный оператор в неправильном месте, нужно построить ST-дерево, пробежаться по нему, проанализировать и прочее.

Что сделали разработчики Ace? Они сказали: «В основном потоке мы оставляем основное. Пусть курсор мигает и ходит, а экран скроллится. Это будет происходить без задержек. Пользователь будет доволен. А все остальные дополнительные штуки мы отдадим в Worker».

Это такой инструмент, который позволяет запустить Javascript-файл в отдельном потоке браузера и избежать лишнего расхода производительности — например, на постройку дерева.  
Есть одно ограничение. В сервис Worker нельзя отдавать работу с DOM’ом. То есть с DOM’ом вы работаете в своем потоке, а сложные вычисления отдаете в параллельный. Это повысит производительность вашего фронтенда.



Подытожим. Как можно отдать вычисления с практической точки зрения?

  • Если вы делаете визуализацию на D3, и она сложная (там есть такая штука – гравитация. Например, вы построили дерево, и чем больше у него нода, тем сильнее оно притягивает ноды, а еще это все анимируется) – ее лучше делать на сanvas, не делайте ее на SVG.
  • Много запросов к серверу — не всегда плохо. Это может быть хорошо, если пользователь быстрее видит интерфейс.
  • Неинтерактивный оверлей на картах Google – картинка. Это просто закон.
  • Если вы можете посчитать что-то в параллельном потоке – сделайте это.

Я долго рассказывал, как оптимизировать и уменьшить вычисления. Но иногда этого сделать нельзя.

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

Что сделать с задержками? Есть два способа:

  1. Использовать прозрачную обратную связь, то есть показать пользователю, что «да, я знаю, что есть задержка, и это нормально».
  2. Немножко обмануть пользователя.

Сейчас я расскажу, как это делается.



Рассмотрим правильную обратную связь. Вернемся к Instagram с котиками и добавим немножко взаимодействия.



Есть котик, и пользователь хочет лайкнуть его. Он подводит мышку, нажимает на звездочку, я показываю – да, ты лайкнул, ты молодец! Стало больше лайков! Но это же неправда. Лайк – это не лайк, пока он на сервере не лайк.

Иногда у серверов бывает иногда такая:



Что делают в этом случае все разработчики?



Никто не делает никакой прозрачной обратной связи.

Как надо делать? Я не говорю, что нужно забыть про Optimistic UI,  а показываю направление. Если пользователь нажал на что-то, а результат не мгновенный, нужно показать это, например, можно даже подсветить эту звездочку. Хотя бы покажите: «Подожди. Да, мы поняли, что ты нажал, но еще не все готово».

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



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

– Зачем вы привезли мне 10 пицц? Я же заказывала 1.
– Да, вы заказывали 1, но на кнопку нажали 10 раз

Кажется, дурацкая шутка, но я встречаюсь с таким. Иногда я пишу коммент, нажимаю на кнопку — и даже кнопка не прогибается, ничего не происходит. Думаю – глючит, что ли? Надо нажать еще раз.



А на самом деле все произошло, запрос ушел на сервер (оба запроса ушли на сервер), и оба коммента отрисовались. А пользователи не любят, когда они видят 2 одинаковых коммента.
Почему-то считается, что виноват я, хотя на самом деле виноват программист. Можно же было просто показать обратной связью – да, больше не нажимай на эту кнопку, пожалуйста. Мы поняли, что ты на нее нажал.

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



На этапе разработки это не стоит вообще ничего, но интерфейс станет лучше, задержка — прозрачнее. Пользователь не будет в обиде. Он знает, что до сервера нужно сходить. Как говорил Луи Си Кей: «Подожди! Оно летит в космос!», пока ты жалуешься, что у меня сайт не показывает за секунду.



Второй способ – можно немножко обмануть пользователя.

Например, когда ребята разрабатывали Macintosh еще в 1982 или в 1987 году, не помню точно, у них было очень слабое железо, потому что у Apple была задача вписаться в определенную цену, чтобы сделать компьютер доступным. И им приходилось придумывать интересные решения.



Джеф Раскин, который проектировал первую версию интерфейса Mac OS, придумал следующее. Понятно, что компьютер не может включиться мгновенно и сразу показать рабочий экран. Поэтому они фотографировали последнее состояние экрана перед выключением, записывали картинку на нулевую дорожку диска и, когда компьютер включался, показывали ее.

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

Когда ребята из Mac OS показывали картинку, люди смотрели и думали: «Да, здесь что-то произошло, что-то изменилось». Пока ты поймешь, что произошло, тебе покажут настоящий экран.
Это решение оказалось настолько хорошим, что работает в  Mac до сих пор. Например, когда включаешь макбук, ты видишь, что индикатор WI-FI уже горит на полную. А через две секунды он начинает искать сеть.



Второй способ, который вытекает из первого, – обрезанный скриншот. Он называется Skeleton Screens. Эту штуку придумали в Google Images.

У них была та же задача, о которой мы говорили чуть раньше, – что если мы показываем не все картинки, а пользователь скроллит.

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

Это можно тоже использовать, и это хорошо.



Подведем итог. Что делать, если сайт тормозит?

  • Начните с процессора, то есть улучшите ваш процесс вычислений:
    — Уменьшите память, не надо ее забивать, делайте маленькие операции.
    — Проверьте, не слишком ли часто вы производите эти вычисления.
    — Разгрузите процессор, отдайте сложные алгоритмы бэкендеру, несмотря на то, что вы – красавец и можете его написать.
  • Добавьте правильную обратную связь в интерфейс, чтобы пользователь понимал, что, да, идут вычисления, но это нормально.
  • И после этого я желаю вам удачи в оптимизации памяти.




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

Все под спойлером
— Worker’ы вроде бы сериализуют/десериализуют, когда в них отправляются данные. У нас была такая ситуация при сложных вычислениях. Мы считали, сколько точек на графике есть у человека, сколько пришло с сервера, и все это мержилось. Когда мы перенесли все в Worker, стало больше тормозить. И при этом мы не можем всю эту логику вынести на сервер — поскольку он тоже начинает тупить.

В итоге при загрузке страницы есть небольшой лаг.  Мы кое-как его задекорировали, но, тем не менее, он все равно есть. Как можно бороться с такими ситуациями? Что предложишь?


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

— Тысяча точек с какими-то данными.

— Может быть, есть смысл проредить данные, поменьше их показывать, если есть такая возможность.

— Разве что на какой-то старой ретроспективе проредить. Вариант, кстати, спасибо.

— Да, сначала скачивать меньше данных, а потом по мере необходимости подгружать больший объем данных, и тогда это будет работать чуть-чуть побыстрее.

— Был пример про скролл и про вычитание TimeOut, там соточка, по-моему, была. А будет ли хуже решение, если воспользоваться Windows TimeOut и Clear? То есть мы 1 раз в итоге проверим.

— Это популярное заблуждение, что тротл – это Windows TimeOut. Я не помню, кто это придумал, — кто-то из больших парней, кажется, Закас. На самом деле это debounce – немного другая операция. Throttle – это когда вычисления происходят часто, а мы берем только часть из них, но с равным промежутком времени. То есть Throttle – это когда у меня что-то выполнится гарантировано раз в 100 мс. А пример с TimeOut будет работать не гарантировано раз в 100 мс. Стек будет наполняться, а потом, когда пройдет 100 мс, выполнится.

Это хорошая штука. Она называется debounce. Она тоже помогает, например, когда пользователь вводит что-то, не нужно использовать Throttle использовать и показывать subject по мере ввода, лучше делать это debounce, когда он это ввел.

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

— Will-change имеется в виду? Да, я согласен. Просто я скорее говорил про код код, а Will-change – это декларативно API, к HTML больше относится, к разметке.

— Так или иначе, тоже оптимизация. И второй вопрос по процессору. Как быть с теми бедными пользователями браузеров, где сервис-воркеры не поддерживаются? Если мы в нашем приложении не завязываемся на то, что какие-то тяжеловесные вычисления мы перекидываем туда, мы существенно ограничены в поддержке.

— Мне еще очень нравится такая штука как приоритезация задач. Например, React Fiber это использует.

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

React Fiber это под капотом делает, это круто.

— Я так понимаю, это что-то в духе приближенной отрисовки сначала более грубой, потом более детальной?

— Это очень грубое упрощение, но примерно так.

— У меня вопрос по поводу анимации DOM SVG. Пробовал ли ты использовать lstgs sub, например, проводил ли какие-то тесты, и насколько это было быстрее? Это проект, который направлен на улучшение производительности анимации именно DOM’а.

— Нет, я показывал все хардкорно, железно. Анимация вся написана руками.

— Было бы интересно такие тесты увидеть.

— С точки зрения инструментов? Это хорошая идея, я задумаюсь. Я уже сказал, что у меня есть сайт и, может быть, со временем попробую.

— Они просто утверждают, что они могут сразу очень много нод анимировать без каких-либо лагов, и у них есть тесты. Интересно.

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

Также спешим сообщить, что открыли доступ ко всем видеозаписям выступлений с Frontend Conf 2017. Их почти три десятка.

Одновременно мы приглашаем профи стать спикерами на нашем майском фестивале конференций РИТ++. Если у вас есть интересный опыт разработки, и вы готовы им поделиться, оставьте заявку для нашего программного комитета.

+27
18,2k 184
Комментарии 13
Похожие публикации
Популярное за сутки