У меня есть мечта, и она утопична: я хочу, чтобы мои веб-приложения работали идеально. 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 новый элемент или добавляю элементу новый класс. Какие элементы при этом пройдут пересчёт и рендеринг?

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

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

И так далее до корня 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. А вот немного материалов по ней:


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

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

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

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

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


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


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

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

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

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

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

image

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



FPS очень редко падает ниже 60, но всего на пару кадров и не ниже 45. Хороший результат, учитывая то, что браузер не знает размер картинок и текста заранее.

Заключение


Это не самый впечатляющий и полезный пример использования IntersectionObserver. Куда интереснее попробовать использовать его в связке с компонентами React/Vue/Polimer. Тогда можно при инициализации компонента вешать на него IntersectionObserver и продолжать инициализацию только когда он появится во viewport. Это открывает широкие возможности. Остаётся лишь скрестить пальцы и верить, что IntersectionObserver получит своё дальнейшее развитие.
Поделиться с друзьями
-->

Комментарии (25)


  1. Aingis
    05.12.2016 17:36
    -3

    Новые технологии — это круто, но кажется можно прекрасно обойтись и без IntersectionObserver. Достаточно замерить размеры элементов после рендеринга, вычислить нужные контрольные точки, а затем сверять с ними scrollTop, вычисляя очередные контрольные точки после рендера по достижению текущих. Добавить throttle по вкусу. Ваша виртуальная прокрутка готова!


    1. gibson_dev
      06.12.2016 12:04

      А теперь как узнать высоту элемента без вставки его в дом?


      1. Aingis
        06.12.2016 13:57
        +1

        Замерьте после вставки:

        Достаточно замерить размеры элементов после рендеринга


        1. DmitrySkripkin
          06.12.2016 17:40

          Это всё усложняет. Нет проблемы получить размер элементов рендеря их где-то в скрытом блоке. Есть другие проблемы:

          1. Нужно еще дожидаться загрузки всех картинок (если их размер не известен заранее)
          2. При любом изменении размеров окна браузера нужно проводить все расчеты заново


          1. faiwer
            06.12.2016 17:59

            А самое веселье начинается, когда речь идёт о таблицах в 300 тысяч строк, где чуть ли не произвольным образом расставлены col&rowspan-ы. Тут уже правда без изменения чего-либо в самом контенте даже чёрная магия не спасёт.


          1. Aingis
            06.12.2016 19:25
            +1

            Обе проблемы — плохой дизайн. Зачем создавать себе проблемы на ровном месте?


            1. DmitrySkripkin
              07.12.2016 11:27
              +1

              Скажем у нас есть лента. Обычная лента – например как в Instagram. В ней есть картинки, комментарии, текст. Размер всего перечисленного не известен заранее.
              Тут пользователь решает перевернуть свой iPad/перенести окно браузера на внешний монитор/ изменить размер окна. Всё ломается.


              1. Aingis
                07.12.2016 13:01
                -1

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


            1. Arta
              07.12.2016 17:01

              самый обычный чат — размер (высота) любого сообщения неизвестны до рендеринга


              1. Aingis
                07.12.2016 21:12

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


        1. gibson_dev
          09.12.2016 10:15

          Дак мы как раз и боремся с тем чтобы их рендерить, вы вообще статью читали? Или вас просто эта проблема не ксонулась


          1. Aingis
            09.12.2016 12:03

            Вы их не покажите не отрендерив никак, и запас всё равно нужен. А если один раз рендерить, то какая разница когда?

            Вы как себе процесс представляете? Даже с новым API браузер будет рендерить, просто «под капотом».


  1. seokirill
    05.12.2016 17:59

    Восхитительная работа! Интересно, нет ли каких-то css-селекторов на сей счет, типа :target


  1. s1im
    05.12.2016 18:34
    +2

    Демо-пример явно недоработанный. Пока скроллим вниз — все нормально. Но если перейти в начало (нажать «Home» / перейти по якорю #) — то увидим лишь белый экран. И еще, при скролле вверх скроллбар дергается как ненормальный. Chrome 54.


    1. crwin
      05.12.2016 20:06

      Firefox 50 (win10) — тоже белый экран.


    1. Spunreal
      06.12.2016 17:26

      Якорь — это уже «украшательства». Смысл трюка и без него понятен.

      Скролл прыгает потому, блок с новостями имеет определённую высоту, он единственный, при скролле вниз блок сдвигается с позиции 1 на позицию 3. При скролле вверх сдвигается с позиции 3 на позицию 5. Т.к. ничего кроме блока нет, то и размер документа уменьшается (голубая линия), соответственно скролл прыгает.
      Чтобы это исправить, нужно увеличивать ещё и высоту документа
      image


    1. DmitrySkripkin
      06.12.2016 17:36

      Демка нацелена лишь на то, чтобы показать работу IntersectionObserer'а. Работает корректно только когда скроллинг происходит без якорей.
      Нет смысла тратить время на доработку демо так как спецификация на IntersectionObserver и браузерная реализация могут поменяться.


      1. Large
        14.12.2016 15:55

        При быстром скроллинге вверх удалось вылететь на белый экран в chrome 55


        1. Shannon
          15.12.2016 10:45
          +1

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

          Суть в том, что эта технология позволяет делать много нового в html5 (что раньше было только в нативе) в экономичном пассивном режиме, что ведет в том числе к появлению плавных отзывчивых интерфейсов, которым надо меньше памяти и не нагружают проц


          1. Large
            15.12.2016 10:56

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

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


            1. Shannon
              15.12.2016 12:00

              Создается «listview». Подгружаться новые посты будут группами по 50 (можно меньше или больше)

              Создается единственный IntersectionObserver. При создании новой группы из 50 элементов, на эту группу делаем io.observe(groupX)

              После того как group1 исчезает из поле зрения, ее выгружаем, добавляем group3. Как только переходим на group3, выгружаем group2, загружаем group4 и т.д.

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

              Суть в том, что создается один единственный IntersectionObserver, который без потерь следить за множеством объектов, не нужно постоянно следить за скроллом и вычислять высоту, закладывать в архитектуру фиксированность размеров блоков или вычислять их после рендеринга


  1. loststylus
    05.12.2016 22:36

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


  1. Grief
    06.12.2016 00:26

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


    1. Deosis
      06.12.2016 06:57

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


      1. faiwer
        06.12.2016 07:19

        Scrollbar штука относительно простая. И никакой сложности им манипулировать нет. Я помнится поступал просто: размещал рядом с view-областью <div/> с width: 1px и программно регулируемой высотой. И scrollBar заиграл по моим правилам.