Если бы браузер отлично справлялся с рендерингом, то не появился бы такой инструмент, как 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 постов одновременно.
Чем это удобнее отслеживания скролла? Если высота постов неизвестна, приходится искать элемент в DOM и узнавать её. Это не очень удобно и не очень производительно.
На выходе мы получаем компонент, который умеет быстро рендерить бесконечный контент неизвестной заранее высоты. Можно использовать что-то подобное не только для ленты новостей, но и для любого контента, состоящего из блоков.
Меньше слов больше дела: вот демо. И я прошу обратить внимание на то, что цель здесь — увидеть работу IntersectionObserver на реальном примере.
Вот тут можно посмотреть на FPS, если скроллить со скоростью беглого просмотра ленты (изображение кликабельно):
И максимально быстро (изображение кликабельно):
FPS очень редко падает ниже 60, но всего на пару кадров и не ниже 45. Хороший результат, учитывая то, что браузер не знает размер картинок и текста заранее.
Заключение
Это не самый впечатляющий и полезный пример использования IntersectionObserver. Куда интереснее попробовать использовать его в связке с компонентами React/Vue/Polimer. Тогда можно при инициализации компонента вешать на него IntersectionObserver и продолжать инициализацию только когда он появится во viewport. Это открывает широкие возможности. Остаётся лишь скрестить пальцы и верить, что IntersectionObserver получит своё дальнейшее развитие.
Комментарии (25)
seokirill
05.12.2016 17:59Восхитительная работа! Интересно, нет ли каких-то css-селекторов на сей счет, типа :target
s1im
05.12.2016 18:34+2Демо-пример явно недоработанный. Пока скроллим вниз — все нормально. Но если перейти в начало (нажать «Home» / перейти по якорю #) — то увидим лишь белый экран. И еще, при скролле вверх скроллбар дергается как ненормальный. Chrome 54.
Spunreal
06.12.2016 17:26Якорь — это уже «украшательства». Смысл трюка и без него понятен.
Скролл прыгает потому, блок с новостями имеет определённую высоту, он единственный, при скролле вниз блок сдвигается с позиции 1 на позицию 3. При скролле вверх сдвигается с позиции 3 на позицию 5. Т.к. ничего кроме блока нет, то и размер документа уменьшается (голубая линия), соответственно скролл прыгает.
Чтобы это исправить, нужно увеличивать ещё и высоту документа
DmitrySkripkin
06.12.2016 17:36Демка нацелена лишь на то, чтобы показать работу IntersectionObserer'а. Работает корректно только когда скроллинг происходит без якорей.
Нет смысла тратить время на доработку демо так как спецификация на IntersectionObserver и браузерная реализация могут поменяться.Large
14.12.2016 15:55При быстром скроллинге вверх удалось вылететь на белый экран в chrome 55
Shannon
15.12.2016 10:45+1В статье всего лишь простенькая демка, которая работает только вниз, чтобы работало вверх, нужно дорабатывать алгоритм, но для демонстрации принципа в этом нет смысла
Суть в том, что эта технология позволяет делать много нового в html5 (что раньше было только в нативе) в экономичном пассивном режиме, что ведет в том числе к появлению плавных отзывчивых интерфейсов, которым надо меньше памяти и не нагружают проц
loststylus
05.12.2016 22:36Прокрутил демо скроллом вниз, потом взялся за ползунок и потянул вверх — все сломалось, остался только белый экран.
Grief
06.12.2016 00:26А мне еще поведение скроллбара не нравится что в существующих сайтах, что в демо из статьи — почему в конце страницы он прыгает чуть вверх? Нет, технически оно понятно, почему — потому что снизу нарисовался контент, который мы еще пока не видим, но который увеличил высоту страницы. Но какая здесь логика? Мне кажется, что во время скролла бесконечной ленты вниз скроллбар должен оставаться внизу и только уменьшаться.
Deosis
06.12.2016 06:57Поведение скроллбара жестко зашито внутри браузера/оси и показывает физическое положение относительно высоты страницы. Он не рассчитан на бесконечные ленты, поэтому и вылезают такие косяки.
faiwer
06.12.2016 07:19Scrollbar штука относительно простая. И никакой сложности им манипулировать нет. Я помнится поступал просто: размещал рядом с view-областью <div/> с width: 1px и программно регулируемой высотой. И scrollBar заиграл по моим правилам.
Aingis
Новые технологии — это круто, но кажется можно прекрасно обойтись и без IntersectionObserver. Достаточно замерить размеры элементов после рендеринга, вычислить нужные контрольные точки, а затем сверять с ними
scrollTop
, вычисляя очередные контрольные точки после рендера по достижению текущих. Добавить throttle по вкусу. Ваша виртуальная прокрутка готова!gibson_dev
А теперь как узнать высоту элемента без вставки его в дом?
Aingis
Замерьте после вставки:
DmitrySkripkin
Это всё усложняет. Нет проблемы получить размер элементов рендеря их где-то в скрытом блоке. Есть другие проблемы:
faiwer
А самое веселье начинается, когда речь идёт о таблицах в 300 тысяч строк, где чуть ли не произвольным образом расставлены col&rowspan-ы. Тут уже правда без изменения чего-либо в самом контенте даже чёрная магия не спасёт.
Aingis
Обе проблемы — плохой дизайн. Зачем создавать себе проблемы на ровном месте?
DmitrySkripkin
Скажем у нас есть лента. Обычная лента – например как в Instagram. В ней есть картинки, комментарии, текст. Размер всего перечисленного не известен заранее.
Тут пользователь решает перевернуть свой iPad/перенести окно браузера на внешний монитор/ изменить размер окна. Всё ломается.
Aingis
Не пользуюсь Инстаграммом, не знаю какая там лента, но вроде бы размеры картинок и текста там вполне ограничены в определённых рамках. Кроме того, обычно такие ленты делаются определённой ширины и рендерятся элементы с запасом по высоте. Всё сломаться может только в очень примитивной реализации. Кроме того, ничто не мешает делать пересчёт по событию.
Arta
самый обычный чат — размер (высота) любого сообщения неизвестны до рендеринга
Aingis
Ну не так уже неизвестны: как минимум одна строка текста. А значит в окно поместится ограниченное количество. Кроме того, неизвестность сама по себе не проблема.
gibson_dev
Дак мы как раз и боремся с тем чтобы их рендерить, вы вообще статью читали? Или вас просто эта проблема не ксонулась
Aingis
Вы их не покажите не отрендерив никак, и запас всё равно нужен. А если один раз рендерить, то какая разница когда?
Вы как себе процесс представляете? Даже с новым API браузер будет рендерить, просто «под капотом».