Статья была впервые опубликована здесь.
У меня есть мечта, и она утопична: я хочу, чтобы мои
Если бы браузер отлично справлялся с рендерингом, то не появился бы такой инструмент, как React Native. Под капотом React Native всё тот же JavaScript, а View нативное, и разница в производительности между
нативным приложением и приложением на React Native не будет заметна для рядового пользователя. Другими словами, проблема не в JavaScript.
Если
Гибридные приложения и производительность
Я писал приложения с довольно тривиальной функциональностью: новостные ленты с комментариями, категориями и тегами. В них можно смотреть видео, делать поиск по новостям
В разработке гибридных приложений хорошо. JavaScript меня полностью устраивал, полностью устраивали меня и элементы интерфейса, которые щедро предоставляли фреймворки Framework7 и Ionic. Даже плагинов, позволяющих пользоваться нативными
функциями, хватало. Пиши одно приложение и получай сразу десять — под все платформы, какие только придумали. Мечта да и только. Но сейчас будет «но», жирное и ставящее крест на всём.
Как только приложение становится заметно сложнее, чем «Hello, world!», начинаются проблемы с производительностью. Приложение работает лучше, чем мобильная версия сайта, но далеко не так хорошо, как аналогичное нативное приложение.
Если
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. В итоге мы рендерим всю страницу целиком.
Рендеринг в браузере работает иначе. Вот одна из массы статей на эту тему, где автор рассказывает о процессе
совмещения
В общих чертах оптимизация сводится к решению следующих задач:
- облегчить CSS, сделать стили удобочитаемыми для браузера;
- избавится от тяжелых для рендеринга стилей (теней и прозрачности лучше избегать вовсе);
- уменьшить число DOM элементов и производить как можно меньше изменений в нём;
- Правильно работать с GPU.
Всё это немного помогает оптимизации загрузки страницы, но что делать если хочется большего?
Отсечение лишнего
Как мне кажется, единственный способ, который действительно работает — добиться того, чтобы на странице были только те элементы, которые действительно нужны пользователю. Реализовать такое поведение проблематично.
Многие знают про Virtual list/Virtual scroll/Grid View/Table View. Названия разные, но суть одна: это компонент для эффективного отображения очень длинных списков на странице. В основном подобные интерфейсные компоненты используют в мобильной разработке.
GitHub полон
Есть несколько вариантов реализации, и разница между ними лишь в том, как мы позиционируем элементы и разбиваем ли их на группы, но это не столь важно. Проблема в том, что событие scroll при идеальных
условиях срабатывает ровно столько раз, сколько пикселей было проскроллено. То есть очень много. Добавьте к этому необходимость производить вычисления при каждом срабатывании события. На мобильных устройствах событие scroll и сама
механика scroll работает
Здесь стоит упомянуть ещё об одной проблеме. Все компоненты, которые я видел, требуют, чтобы размер элементов списка был одинаковым или, в лучшем случае, был известен заранее для каждого элемента.
IntersectionObserver
Вот теперь на сцену выходит IntersectionObserver, первый луч надежды, упавший на описанное мной во вступлении будущее. Фича новая, поэтому вот информация о поддержке браузерами на сайте caniuse.com. А вот немного материалов по ней:
- черновик спецификации;
- репозиторий с черновиком спецификации, объяснением с примерами и полифиллом;
- статья с наглядными примерами в блоге Google
IntersectionObeserver сообщает, когда интересующий нас элемент появляется во viewport и когда покидает его. Теперь не нужно следить за скроллом и считать высоту элементов, чтобы понять, какие из них нужно рендерить, а какие
нет.
Теперь немного практики. Мне захотелось сделать виртуальный скролл с подгрузкой элементов, используя IntersectionObserver. Задача примерно такая:
- бесконечная лента с постами, в которых есть заголовок, картинка и текст;
- содержание постов и их высота не известны заранее;
- никаких остановок на подгрузку контента;
- 60 fps.
А вот что я понял, пока писал этот компонент:
- нужно переиспользовать элементы, производя минимум операций с DOM;
- не нужно создавать IntersectionObserver для каждого элемента списка, достаточно двух.
Принцип работы
Контент разбивается на части по 12 постов каждая. Когда компонент инициализировался, в DOM находится только одна такая часть. Первая и последняя часть имеют скрытый элемент вверху и внизу соответственно. За видимостью этих
элементов мы следим. Когда
Чем это удобнее отслеживания скролла? Если высота постов неизвестна, приходится искать элемент в DOM и узнавать её. Это не очень удобно и не очень производительно.
На выходе мы получаем компонент, который умеет быстро рендерить бесконечный контент неизвестной заранее высоты. Можно использовать
Меньше слов больше дела: вот демо. И я прошу обратить внимание на то, что цель здесь — увидеть работу IntersectionObserver на реальном примере.
Вот тут можно посмотреть на FPS, если скроллить со скоростью беглого просмотра ленты (изображение кликабельно):
И максимально быстро (изображение кликабельно):
FPS очень редко падает ниже 60, но всего на пару кадров и не ниже 45. Хороший результат, учитывая то, что браузер не знает размер картинок и текста заранее.
Заключение
Это не самый впечатляющий и полезный пример использования IntersectionObserver. Куда интереснее попробовать использовать его в связке с компонентами React/Vue/Polimer. Тогда можно при инициализации компонента вешать на него IntersectionObserver и продолжать инициализацию только когда он появится во viewport. Это открывает широкие возможности. Остаётся лишь скрестить пальцы и верить, что IntersectionObserver получит своё дальнейшее развитие.