- PVSM.RU - https://www.pvsm.ru -

Про оптимизацию рендеринга — с оптимизмом

У меня есть мечта, и она утопична: я хочу, чтобы мои веб-приложения работали идеально. JQuery, AngularJs, React, Vue.js — все обещают производительность. Но проблема совсем не во фреймворках и не в JavaScript. Проблема в том, как браузер рендерит страницу. А делает он это очень плохо.

Если бы браузер отлично справлялся с рендерингом, то не появился бы такой инструмент, как React Native. Под капотом React Native всё тот же JavaScript, а View нативное, и разница в производительности между нативным приложением и приложением на React Native не будет заметна для рядового пользователя. Другими словами, проблема не в JavaScript.

Если что-то оптимизировать, то как раз рендеринг. Инструментов, которые нам даёт JavaScript и API браузера, недостаточно. Два года я пытаюсь сделать работу своих продуктов плавной и быстрой, но тщетно. Я почти смирился с тем, что веб останется таким навсегда. В этой статье я собрал всё, что успел узнать об оптимизации рендеринга и применить на проектах, над которыми работал, и рассказываю о своих надеждах на ближайшее будущее. Это будущее, в котором я хочу опираться на устойчивый фундамент стандартов и API браузера, а не CSS-хаки и third-party репозитории для оптимизации производительности.

Про оптимизацию рендеринга — с оптимизмом - 1

Гибридные приложения и производительность

Про оптимизацию рендеринга — с оптимизмом - 2

Я писал приложения с довольно тривиальной функциональностью: новостные ленты с комментариями, категориями и тегами. В них можно смотреть видео, делать поиск по новостям и т.д. Ну, ещё push-нотификации. Ничего сложного. По причине NDA я не могу показать вам эти проекты, зато в блоге нашей компании мы рассказываем о принципах выбора подхода к мобильной разработке [1].

В разработке гибридных приложений хорошо. JavaScript меня полностью устраивал, полностью устраивали меня и элементы интерфейса, которые щедро предоставляли фреймворки Framework7 и Ionic. Даже плагинов, позволяющих пользоваться нативными функциями, хватало. Пиши одно приложение и получай сразу десять — под все платформы, какие только придумали. Мечта да и только. Но сейчас будет «но», жирное и ставящее крест на всём.

Как только приложение становится заметно сложнее, чем “Hello, world!”, начинаются проблемы с производительностью. Приложение работает лучше, чем мобильная версия сайта, но далеко не так хорошо, как аналогичное нативное приложение.

Если кто-то и готов с этим мириться, то для меня это стало вызовом. Мне нужно было написать гибридное приложение так, чтобы его невозможно было отличить от нативного. Тогда я чуть-чуть покопался и пришёл к одному простому выводу: с js всё хорошо, проблема в рендеринге. Я перепробовал всё css-хаки от “transform: translate3d(0,0,0)” (который вскоре перестал работать) и до замены градиентов png с альфа-каналом. Это, конечно же, не решало проблему, а лишь чуть маскировало её. По ссылкам несколько таких хаков:

» Force Hardware Acceleration in WebKit with translate3d [2]
» 60fps scrolling using pointer-events: none [3]
» CSS box-shadow Can Slow Down Scrolling [4]

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

Medium, у вас проблемы

На сайтах и в приложениях мы видим бесконечные ленты: Instagram, Facebook, Twitter, Medium — из этих примеров, пожалуй, можно составить свою ленту с подгрузкой. И в этом нет ничего плохого. Скролл позволяет перемещаться в пределах одного поста и перемещаться между постами. Можно скроллить быстро, можно медленно. Добавляешь новые элементы в список сколько душе угодно. Я и сам так делал.

Про оптимизацию рендеринга — с оптимизмом - 3

Давайте проведем эксперимент. У вас шумный кулер? Откройте Medium.com и мотайте вниз. Как скоро ваш кулер выйдет на максимальные обороты? Мой результат — примерно 45 секунд. И это не Chrome виноват. И даже не то, что моему ноутбуку много лет. Проблема в том, что никто не занимается оптимизацией того, что мы видим во viewport.

Что же происходит, когда мы мотаем ленту? Оказавшись внизу страницы, мы получаем от сервера ещё немного постов, и они добавляются в конец списка. Список растёт бесконечно. Что делает браузер? Ничего. Посты в начале ленты всё ещё существуют и браузер всё еще рендерит их. И “visibility: hidden” тут никак не поможет, даже если мы будем вешать это свойство на каждый пост, который находится за пределами viewport’а. Кстати, такая бесполезная оптимизация была замечена мною в Ionic. Серьезно. Но потом это исправили. Если кому интересно, вот тема на форуме Ionic [5], которую я создал, чтобы обсудить проблему.

Загадочный мир оптимизации

Про оптимизацию рендеринга — с оптимизмом - 4

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

Вот мы добавим новый элемент в список. Что дальше?

  1. Новый элемент нужно отрендерить и поставить на место;
  2. нужно заново сдвинуть другие элементы списка;
  3. нужно заново рендерить другие элементы списка;
  4. нужно обновить высоту «родителя»;
  5. обновился «родитель», и теперь непонятно, поменялись ли соседние элементы.

И так далее до корня DOM. В итоге мы рендерим всю страницу целиком.

Рендеринг в браузере работает иначе. Вот [6] одна из массы статей на эту тему, где автор рассказывает о процессе совмещения DOM-дерева и CSS-дерева и о том, как браузер впоследствии рисует полученную конструкцию. Всё очень здорово, однако не совсем ясно, что может сделать разработчик, чтобы помочь браузеру. Есть неофициальный ресурс CSS Triggers [7], на котором представлена таблица, позволяющая определить, какие CSS-свойства вызывают Layout/Paint/Composite-процессы в браузере, но так как страницы обычно содержат большое количество стилей и элементов, единственный разумный выход — это постараться исключить всё, что может ударить по производительности.

В общих чертах оптимизация состоит из нескольких пунктов:

  • облегчить CSS [8], сделать стили удобочитаемыми для браузера;
  • избавится от тяжелых для рендеринга стилей (теней и прозрачности лучше избегать вовсе);
  • уменьшить число DOM элементов и производить как можно меньше изменений в нём;
  • Правильно работать с GPU.

Всё это помогает немного ускорить рендеринг страницы, но что делать если хочется большего?

Отсечение лишнего

Про оптимизацию рендеринга — с оптимизмом - 5

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

Многие знают про Virtual list/Virtual scroll/Grid View/Table View. Названия разные, но суть одна: это компонент для эффективного отображения очень длинных списков на странице. В основном подобные интерфейсные компоненты используют в мобильной разработке.

GitHub полон js-репозиториев а-ля virtual list, virtual grid и т.д. Оптимизация вполне рабочая, это факт. В списке из 10 тысяч элементов можно создать контейнер длинной в 10 000 px, умноженных на высоту одного элемента, далее следить за скроллом и рендерить только видимые пользователю элементы плюс ещё чуть-чуть. Сами элементы позиционируются при помощи “translate: transformY(<индекс элемента> * <высота элемента> px)”. Я недавно изучал Vue.js 2.0 и написал такой компонент за пару часов.

Есть несколько вариантов реализации, и разница между ними лишь в том, как мы позиционируем элементы и разбиваем ли их на группы, но это не столь важно. Проблема в том, что событие scroll при идеальных условиях срабатывает ровно столько раз, сколько пикселей было проскроллено. То есть очень много. Добавьте к этому необходимость производить вычисления при каждом срабатывании события. На мобильных устройствах событие scroll и сама механика scroll работает по-разному. Вывод из этого следует довольно простой: scroll event плохо подходит для таких задач.

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

IntersectionObserver

Про оптимизацию рендеринга — с оптимизмом - 6

Вот теперь на сцену выходит IntersectionObserver, первый луч надежды, упавший на описанное мной во вступлении будущее. Фича новая, поэтому вот информация о поддержке браузерами на сайте caniuse.com [9]. А вот немного материалов по ней:

  • черновик спецификации
  • репозиторий с чероновиком спецификации, объяснением с примерами и полифиллом
  • статья с наглядными примерами в блоге Google

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

Теперь немного практики. Мне захотелось сделать виртуальный скролл с подгрузкой элементов, используя IntersectionObserver. Задача примерно такая:

  • бесконечная лента с постами, в которых есть заголовок, картинка и текст;
  • содержание постов и их высота не известны заранее;
  • никаких остановок на подгрузку контента;
  • 60 fps.

А вот что я понял, пока писал этот компонент:

  • нужно переиспользовать элементы, производя минимум операций с DOM;
  • не нужно создавать IntersectionObserver для каждого элемента списка, достаточно двух.

нужн

Принцип работы

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

Чем это удобнее отслеживания скролла? Если высота постов неизвестна, приходится искать элемент в DOV и узнавать её. Это не очень удобно и не очень производительно.

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

Меньше слов больше дела: вот демо [10]. И я прошу обратить внимание на то, что цель здесь — увидеть работу IntersectionObserver на реальном примере.

Вот тут можно посмотреть на FPS, если скроллить со скоростью беглого просмотра ленты (изображение кликабельно):

image [11]

И максимально быстро (изображение кликабельно):

Автор: Лайв Тайпинг

Источник [12]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/217283

Ссылки в тексте:

[1] мы рассказываем о принципах выбора подхода к мобильной разработке: http://livetyping.com/ru/blog/cross-platform-vs-native-apps-comparing-and-selecting-approaches

[2] Force Hardware Acceleration in WebKit with translate3d: https://davidwalsh.name/translate3d

[3] 60fps scrolling using pointer-events: none: https://www.thecssninja.com/javascript/pointer-events-60fps

[4] CSS box-shadow Can Slow Down Scrolling: http://nerds.airbnb.com/box-shadows-are-expensive-to-paint/

[5] тема на форуме Ionic: https://forum.ionicframework.com/t/does-collection-repeat-really-help/31826

[6] Вот: https://developers.google.com/web/fundamentals/performance/critical-rendering-path/render-tree-construction

[7] CSS Triggers: https://csstriggers.com/

[8] облегчить CSS: https://css-tricks.com/efficiently-rendering-css/

[9] caniuse.com: http://caniuse.com/#feat=intersectionobserver

[10] демо: https://dmitryskripkin.github.io/vue-virtual-scroll-demo/

[11] Image: https://habrastorage.org/files/7aa/cc0/e69/7aacc0e692f143719e164f363fe3c70d.png

[12] Источник: https://habrahabr.ru/post/316136/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best