У меня есть мечта, и она утопична: я хочу, чтобы мои веб-приложения работали идеально. JQuery, AngularJs, React, Vue.js — все обещают производительность. Но проблема совсем не во фреймворках и не в JavaScript. Проблема в том, как браузер рендерит страницу. А делает он это очень плохо.
Если бы браузер отлично справлялся с рендерингом, то не появился бы такой инструмент, как React Native. Под капотом React Native всё тот же JavaScript, а View нативное, и разница в производительности между нативным приложением и приложением на React Native не будет заметна для рядового пользователя. Другими словами, проблема не в JavaScript.
Если что-то оптимизировать, то как раз рендеринг. Инструментов, которые нам даёт JavaScript и API браузера, недостаточно. Два года я пытаюсь сделать работу своих продуктов плавной и быстрой, но тщетно. Я почти смирился с тем, что веб останется таким навсегда. В этой статье я собрал всё, что успел узнать об оптимизации рендеринга и применить на проектах, над которыми работал, и рассказываю о своих надеждах на ближайшее будущее. Это будущее, в котором я хочу опираться на устойчивый фундамент стандартов и API браузера, а не CSS-хаки и third-party репозитории для оптимизации производительности.
Гибридные приложения и производительность
Я писал приложения с довольно тривиальной функциональностью: новостные ленты с комментариями, категориями и тегами. В них можно смотреть видео, делать поиск по новостям и т.д. Ну, ещё push-нотификации. Ничего сложного. По причине NDA я не могу показать вам эти проекты, зато в блоге нашей компании мы рассказываем о принципах выбора подхода к мобильной разработке.
В разработке гибридных приложений хорошо. JavaScript меня полностью устраивал, полностью устраивали меня и элементы интерфейса, которые щедро предоставляли фреймворки Framework7 и Ionic. Даже плагинов, позволяющих пользоваться нативными функциями, хватало. Пиши одно приложение и получай сразу десять — под все платформы, какие только придумали. Мечта да и только. Но сейчас будет «но», жирное и ставящее крест на всём.
Как только приложение становится заметно сложнее, чем “Hello, world!”, начинаются проблемы с производительностью. Приложение работает лучше, чем мобильная версия сайта, но далеко не так хорошо, как аналогичное нативное приложение.
Если кто-то и готов с этим мириться, то для меня это стало вызовом. Мне нужно было написать гибридное приложение так, чтобы его невозможно было отличить от нативного. Тогда я чуть-чуть покопался и пришёл к одному простому выводу: с js всё хорошо, проблема в рендеринге. Я перепробовал всё css-хаки от “transform: translate3d(0,0,0)” (который вскоре перестал работать) и до замены градиентов png с альфа-каналом. Это, конечно же, не решало проблему, а лишь чуть маскировало её. По ссылкам несколько таких хаков:
» Force Hardware Acceleration in WebKit with translate3d
» 60fps scrolling using pointer-events: none
» CSS box-shadow Can Slow Down Scrolling
После я работал над другими проектами, не связанными с мобильными браузерами и устройствами. И тут всё неплохо, нет никаких проблем с производительностью, ведь откуда им взяться на машине с хорошим железом. Но если проблем с производительностью не видно, это ещё не значит, что всё оптимизировано.
Medium, у вас проблемы
На сайтах и в приложениях мы видим бесконечные ленты: Instagram, Facebook, Twitter, Medium — из этих примеров, пожалуй, можно составить свою ленту с подгрузкой. И в этом нет ничего плохого. Скролл позволяет перемещаться в пределах одного поста и перемещаться между постами. Можно скроллить быстро, можно медленно. Добавляешь новые элементы в список сколько душе угодно. Я и сам так делал.
Давайте проведем эксперимент. У вас шумный кулер? Откройте Medium.com и мотайте вниз. Как скоро ваш кулер выйдет на максимальные обороты? Мой результат — примерно 45 секунд. И это не Chrome виноват. И даже не то, что моему ноутбуку много лет. Проблема в том, что никто не занимается оптимизацией того, что мы видим во viewport.
Что же происходит, когда мы мотаем ленту? Оказавшись внизу страницы, мы получаем от сервера ещё немного постов, и они добавляются в конец списка. Список растёт бесконечно. Что делает браузер? Ничего. Посты в начале ленты всё ещё существуют и браузер всё еще рендерит их. И “visibility: hidden” тут никак не поможет, даже если мы будем вешать это свойство на каждый пост, который находится за пределами viewport’а. Кстати, такая бесполезная оптимизация была замечена мною в Ionic. Серьезно. Но потом это исправили. Если кому интересно, вот тема на форуме Ionic, которую я создал, чтобы обсудить проблему.
Загадочный мир оптимизации
Что же мешает писать хороший, оптимизированный код? Мешает то, что мы не так много знаем об этом процессе. Большая часть знаний пришла к нам методом проб и ошибок, а статьи с заголовком вроде «Как браузер рендерит страницу» рассказывают нам о том, как HTML совмещается с CSS и как страница разбивается на слои. Мне непонятно, что происходит, когда я добавляю в DOM новый элемент или добавляю элементу новый класс. Какие элементы при этом пройдут пересчёт и рендеринг?
Вот мы добавим новый элемент в список. Что дальше?
- Новый элемент нужно отрендерить и поставить на место;
- нужно заново сдвинуть другие элементы списка;
- нужно заново рендерить другие элементы списка;
- нужно обновить высоту «родителя»;
- обновился «родитель», и теперь непонятно, поменялись ли соседние элементы.
И так далее до корня DOM. В итоге мы рендерим всю страницу целиком.
Рендеринг в браузере работает иначе. Вот одна из массы статей на эту тему, где автор рассказывает о процессе совмещения DOM-дерева и CSS-дерева и о том, как браузер впоследствии рисует полученную конструкцию. Всё очень здорово, однако не совсем ясно, что может сделать разработчик, чтобы помочь браузеру. Есть неофициальный ресурс CSS Triggers, на котором представлена таблица, позволяющая определить, какие CSS-свойства вызывают Layout/Paint/Composite-процессы в браузере, но так как страницы обычно содержат большое количество стилей и элементов, единственный разумный выход — это постараться исключить всё, что может ударить по производительности.
В общих чертах оптимизация состоит из нескольких пунктов:
- облегчить CSS, сделать стили удобочитаемыми для браузера;
- избавится от тяжелых для рендеринга стилей (теней и прозрачности лучше избегать вовсе);
- уменьшить число DOM элементов и производить как можно меньше изменений в нём;
- Правильно работать с GPU.
Всё это помогает немного ускорить рендеринг страницы, но что делать если хочется большего?
Отсечение лишнего
Как мне кажется, единственный способ, который действительно работает – добиться того, чтобы на странице были только те элементы, которые действительно нужны пользователю. Реализовать такое поведение проблематично.
Многие знают про 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
Вот теперь на сцену выходит IntersectionObserver, первый луч надежды, упавший на описанное мной во вступлении будущее. Фича новая, поэтому вот информация о поддержке браузерами на сайте caniuse.com. А вот немного материалов по ней:
- черновик спецификации
- репозиторий с чероновиком спецификации, объяснением с примерами и полифиллом
- статья с наглядными примерами в блоге Google
IntersectionObeserver сообщает, когда интересующий нас элемент появляется во viewport и когда покидает его. Теперь не нужно следить за скроллом и считать высоту элементов, чтобы понять, какие из них нужно рендерить, а какие нет.
Теперь немного практики. Мне захотелось сделать виртуальный скролл с подгрузкой элементов, используя IntersectionObserver. Задача примерно такая:
- бесконечная лента с постами, в которых есть заголовок, картинка и текст;
- содержание постов и их высота не известны заранее;
- никаких остановок на подгрузку контента;
- 60 fps.
А вот что я понял, пока писал этот компонент:
- нужно переиспользовать элементы, производя минимум операций с DOM;
- не нужно создавать IntersectionObserver для каждого элемента списка, достаточно двух.
нужн
Принцип работы
Контент разбивается на части по 12 постов каждая. Когда компонент инициализировался, в DOM находится только одна такая часть. Первая и последняя часть имеют скрытый элемент вверху и внизу соответственно. За видимостью этих элементов мы следим. Когда какой-то из этих элементов попадает на экран, мы добавляем новую часть и удаляем уже ненужную. Таким образом мы имеем в DOM две части по 12 постов одновременно.
Чем это удобнее отслеживания скролла? Если высота постов неизвестна, приходится искать элемент в DOV и узнавать её. Это не очень удобно и не очень производительно.
На выходе мы получаем компонент, который умеет быстро рендерить бесконечный контент неизвестной заранее высоты. Можно использовать что-то подобное не только для ленты новостей, но и для любого контента, состоящего из блоков.
Меньше слов больше дела: вот демо. И я прошу обратить внимание на то, что цель здесь — увидеть работу IntersectionObserver на реальном примере.
Вот тут можно посмотреть на FPS, если скроллить со скоростью беглого просмотра ленты (изображение кликабельно):
И максимально быстро (изображение кликабельно):
FPS очень редко падает ниже 60, но всего на пару кадров и не ниже 45. Хороший результат, учитывая то, что браузер не знает размер картинок и текста заранее.
Заключение
Это не самый впечатляющий и полезный пример использования IntersectionObserver. Куда интереснее попробовать использовать его в связке с компонентами React/Vue/Polimer. Тогда можно при инициализации компонента вешать на него IntersectionObserver и продолжать инициализацию только когда он появится во viewport. Это открывает широкие возможности. Остаётся лишь скрестить пальцы и верить, что IntersectionObserver получит своё дальнейшее развитие.
Автор: Лайв Тайпинг