image


В предыдущей статье я рассказывал про реализацию Flux и веб-компонентов во фреймворке Catberry.js, и эта статья – обещанное продолжение про движок прогрессивного рендеринга.

Что значит «прогрессивный»?


Наверное, вы сталкивались хотя бы раз в жизни с JPEG-картинкой, которая сперва загружается с сервера мутной, а по мере загрузки остального содержимого становится чётче. Такой формат картинки называется "Progressive JPEG", и его основная идея – показать как можно скорее пользователю хоть какое-то содержимое, пусть и не до конца готовое. Пользователь с первой секунды уже будет знать размер картинки и ее примерное содержимое, а в дальнейшем содержимое будет становится только отчётливее.

Лично я не знаю откуда появилась идея назвать потоковый (stream-based) рендеринг HTML прогрессивным, но первое применение этого термина именно к рендерингу HTML я нашел в статье за декабрь 2009 года "Progressive rendering via multiple flushes". Однако и в более свежих статьях, например от разработчиков Google, можно найти термин «Optimized (progressive) rendering».

Прогрессивный рендеринг на сервере


Что обычно используется в других решениях?


Cейчас самый распространённый подход к рендерингу страниц – это полная буферизация. Движок рендеринга запрашивает данные для всей страницы, рендерит с этими данными шаблоны, собирая их в одну строку, и затем за раз отдаёт всю эту строку HTML в браузер.

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

Как бы вы отнеслись к тому, что видите около 5 секунд такую ситуацию? Лично я думаю, что вот-вот увижу «504 Gateway Timeout» или нечто подобное и закрываю вкладку браузера. Ведь абсолютно неясно, обрабатывается ли ваш запрос на сервере или он просто повис. Может быть сайт вообще под DDoS-атакой и не стоит на него заходить.

Прогрессивный подход


Прогрессивный рендеринг же использует "Chunked transfer encoding", который появился в спецификации HTTP 1.1. Это кодирование содержимого позволяет отдавать контент страницы порциями через специальные маркеры, не указывая заголовок Content-Length. Этот подход из коробки поддерживается Node.js через его Stream API, что упрощает реализацию на этой платформе.

Таким образом, Catberry.js запрашивает данные не для всей страницы, а по веб-компонентам и отдаёт порции HTML в браузер настолько быстро, насколько готовы данные и шаблон каждого веб-компонента.

Другими словами, Catberry.js сразу же начинает отдавать корневой шаблон страницы в браузер через реализованный Readable Stream. Когда внутри него встречается веб-компонент, он ставит поток данных на паузу, запрашивает данные для этого веб-компонента и после их получения двигается дальше, пропуская отрендеренный шаблон веб-компонента через такой же механизм поиска уже вложенных веб-компонентов. В результате, пользователь в браузере видит мгновенное появление частей страницы, которые вообще не требуют запросов данных. Части страницы, которые требуют данных, появляются с задержкой, равной длительности запроса данных конкретно для этой части, а не для всей страницы целиком.

Я считаю, что схема от разработчиков Google из упомянутой статьи лучше всего описывает саму суть прогрессивного рендеринга:


В случае прогрессивного рендеринга, пользователь с первых миллисекунд знает, что ваш сайт отвечает, его запрос обрабатывается и ему будет казаться, что ваш сайт невероятно быстр и отзывчив. К тому же, когда вы мгновенно отдаете части страницы со скриптами или стилями, то браузер начинает их загружать параллельно основному документу HTML. Пока ваш сервер запрашивает данные для веб-компонента, браузер уже загрузит стили и скрипты. Как демонстрация – сетевая диаграмма запросов раздела документации с официального сайта Catberry.js.


Документация каждый раз запрашивается через Github API из репозитория фреймворка, поэтому это занимает длительное время. Но во время этого процесса, как видно из диаграммы, браузер не простаивает и занят загрузкой и обработкой других ресурсов.

Недостатки?


Как и любой другой подход, этот тоже не идеален.

Как только мы отправили первый байт HTML в браузер, мы отправляем и HTTP-заголовки, после чего мы больше не имеем возможности их выставлять. Любопытно, что для решения как раз этой проблемы спецификацией HTTP 1.1 предусмотрены HTTP Trailers, которые работают аналогично заголовкам HTTP, но приходят клиенту в конце потока данных, а не в начале. Изначально я планировал использовать как раз этот механизм для управления заголовками в Catberry.js, но меня постигло разочарование. Не смотря на то, что Node.js прекрасно поддерживает трейлеры, эксперименты показали, что браузеры и не думали это поддерживать – трейлеры просто игнорируются.

Как же нам выставить Cookie или сделать редирект в Catberry.js, спросите вы?

Как вы могли прочитать в предыдущей статье, в Catberry.js есть специальный веб-компонент «head», который работает с <head>-элементом страницы. Если до момента рендеринга HTML этого компонента вы используете методы:

  • this.$context.notFound() – передаёт управление следующему Express middleware
  • this.$context.redirect('/some/path')
  • this.$context.cookie.set({key: 'hello', value: 'world'})

То произойдёт честный редирект через HTTP-код ответа 302 и заголовок Location, а в случае с Cookie, будет заголовок Set-Cookie. Другими словами, пока ваш метод «render» компонента «head» не вернёт данные, все эти методы выставят нужный HTTP-код ответа и заголовки.

Если вызывать эти методы позже, в начале веб-компонента, где они были вызваны, будет добавлен <script>-тэг с кодом, который делает редирект через window.location.assign('/some/path') и добавляет Cookie через window.document.cookie.

Работает это благодаря тому, что фреймворк «придерживает» тело ответа до тех пор, пока не получены данные для рендеринга шаблона «head», только после этого уходит первый байт HTML в браузер. А значит, что до этого можно спокойно выставлять заголовки и даже передать управление следующему Express middleware в цепочке.

Если вам интересно взглянуть на код реализации этого механизма, то можно это сделать здесь.

Прогрессивный рендеринг в браузере


Идея здесь абсолютно такая же – показывать изменения контента пользователю как можно быстрее, пошагово и без заморозки UI.

Что обычно используется в других решениях?


Опять же, очень распространенный подход многих фронт-енд фреймворков – просто запросить все данные для нового состояния страницы и затем применить все изменения к блокам страницы за раз. А в некоторых решениях разработчики думают, что сделать все эти изменения за одну итерацию Event Loop это очень хорошая идея, ведь это даёт лучшие показатели в бенчмарках.

Но стоит ли вообще обращать внимание на результаты в бенчмарках? Всё ли они учитывают?

Ведь наша основная цель – добиться, чтобы пользователю было приятно работать с приложением, чтобы он мог скролить страницу даже во время рендеринга нового состояния очень большой и сложной страницы. Каким бы не был эффективным алгоритм применения изменений к странице, даже пусть через Virtual DOM, он не будет масштабироваться, если работает в одной итерации Event Loop. Если алгоритм блокирует Event Loop, чем сложнее и больше будет страница, тем больше будет заметна заморозка UI. Пользователю будет некомфортно работать с таким приложением, если после каждого его действия вся страница будет зависать. Мы, как разработчики конечного продукта, потерпим поражение, даже если наши бенчмарки показывают невероятные результаты.

Прогрессивный подход


Catberry.js использует неблокирующий алгоритм обновления веб-компонентов на странице при смене их состояния.

Например, вы кликнули по внутренней ссылке на странице и изменили состояние нескольких Stores в приложении. После этого происходит поиск самых верхних веб-компонентов в дереве DOM, которые должны подвергнуться изменению и обновление начинается с них. Далее происходит обход DOM дерева «в ширину» начиная с найденных веб-компонентов. По пути фреймворк запрашивает данные для каждого изменившегося веб-компонента, заменяя его содержимое новым отрендеренным шаблоном через innerHTML. При этом появляются дочерние элементы веб-компонентов, которые также раскрываются в свои шаблоны рекурсивно. Работа с каждым веб-компонентом происходит в своей итерации Event Loop, не блокируя цикл обработки событий, а следовательно и UI. Так как чаще всего веб-компоненты достаточно маленькие, каждая итерация рендеринга занимает очень мало времени. Если пропустить шаг с поиском веб-компонентов верхнего уровня, то может случиться ситуация, когда дочерний веб-компонент обновится раньше чем его родитель, затем начнет обновляться родитель, заменит свое содержимое и вызовет повторный рендеринг уже готового дочернего элемента.

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

Разработчики часто говорят, что присваивание в поле innerHTML – это очень медленно, и создавать элементы, добавляя их в DOM, намного быстрее. Но вы задумывались, что innerHTML – это самый быстрый из возможных способов создания DOM модели для HTML? Ведь браузер это делает под капотом, используя очень оптимизированный нативный код. Почему это должно быть медленно?

Для такого случая, я обычно показываю вот такой пример, который дважды подменяет innerHTML <body>-элемента на каждой итерации Event Loop. Если вы зайдете на любой большой сайт и запустите этот код в Chrome Dev Tools, то увидите, что такая страшная, казалось бы, вещь происходит не так уж и болезненно и вы даже можете спокойно скролить страницу.

А как же обработчики событий, спросите вы?

Catberry.js отлично с этим справляется благодаря встроенному механизму делегирования событий. То есть обработчики событий есть только у элементов веб-компонентов и они отвязываются и привязываются когда это необходимо.

После каждого процесса изменения состояния, Catberry.js удаляет объекты веб-компонентов, которые уже отсутствуют в новом состоянии приложения, что устраняет возможность каких-либо утечек памяти.

Если вам интересно взглянуть на код браузерной реализации, то можно это сделать здесь.

UPD:
Так как было много запросов на использование алгоритма сравнения и патча DOM дерева вместо присваивания innerHTML, в Catberry, начиная с версии 6.0.0, используется библиотека morphdom как раз для такого подхода. Однако, в отличие от React используется пошаговый патч в рамках каждого отдельного компонента а не DOM целиком, что сводит к минимуму фризы больших и сложных страниц.

В заключение


Разумеется реализация прогрессивного рендеринга Catberry.js не единственная. Есть шаблонизаторы, использующие схожие подходы, например:


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

В следующей статье будет описание того, как работает сборка браузерного кода Catberry.js и как использовать Plugin API для подключения сборщиков фронт-енд ресурсов.

Официальный сайт фреймворка catberry.org
Twitter twitter.com/catberryjs
Организация на Github github.com/catberry
Репозиторий фреймворка на Github github.com/catberry/catberry
Репозиторий сайта (как пример проекта на Catberry) github.com/catberry/catberry-homepage

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


  1. garex
    05.08.2015 09:52

    Полезное направление.

    PS: В примере всё делается синхронно. А вы попробуйте асинхронно — уже интереснее становится ))


    1. pragmadash
      05.08.2015 09:56
      +1

      Вы имеете ввиду заменять body в разных итерациях Event Loop?

      Ваш пример использует setInterval, который забивает очередь Event Loop и поэтому всё будет вставать колом. Такие вещи нужно делать через рекурсивный setTimeout.


      1. garex
        05.08.2015 10:01

        Да пожалуста.

        В исходном примере innerHtml подряд в один проход изменяется, поэтому под капотом ничего и не видно. А в реальных приложениях всё чуть менее чем полностью асинхронно. Вот я о чём пытаюсь донести мысль.

        Но я всё равно за innerHtml.


        1. pragmadash
          05.08.2015 10:04
          +1

          По вашей ссылке снова же setInterval, похоже вы проигнорировали мой комментарий.

          setInterval не дожидается выполнения запланированной итерации, он будет подряд ставить в очередь обработки событий интервальную функцию, даже до того, как предыдущая итерация до конца выполниться. Это рано или поздно забьет очередь Event Loop и UI зафризиться.


          1. garex
            05.08.2015 10:07

            Тьфу, забыл про него. Вот и с ним теперь. Прыгает подлец!

            Про очередь то да. Вы просто взгляните на него, как он прыгает иногда (именно в переходе с мелкого на крупный).


            1. pragmadash
              05.08.2015 10:18
              +1

              Разумеется, когда замена innerHTML будет с такой задержкой в целую итерацию Event Loop будет видно прыжки и мигания. Браузер же должен как-то отображать результат между кадрами.

              Дело в том, что перерисовка кадра тоже находится в одной из очередей Event Loop, которую изменение innerHTML в разных итерациях через setTimeout разблокирует и браузер успевает перерисовать кадр. В реальных условиях не видел, чтобы кто-то так делал и уж тем более с body-элементом.

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

              Catberry.js делает только одну замену на новый innerHTML и по веб-компонентам а не всего документа, поэтому в проектах на Catberry.js вряд ли можно встретить мигания.

              Тут можно посмотреть великолепный доклад про Event Loop на английском.


            1. pragmadash
              17.08.2015 19:48

              По многочисленным просьбам разработчиков выпустил релиз Catberry 6.0.0 в котором используется diff/patch алгоритм применения изменений в DOM, вместо присваивания в innerHTML. Изменения применяются не на весь DOM за раз, а итерационно по компонентам, что не допускает фризы UI.


  1. nuit
    05.08.2015 12:09

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


    1. pragmadash
      05.08.2015 12:44
      +1

      Во фреймворке как раз и решена эта проблема, благодаря подходу Flux, про реализацию которого было написано в предыдущей статье. Если вы загляните в реализацию рендеринга, то там нет никакого сложного планировщика, все делается рекурсивными вызовами Promises, которые по своей природе работают на разных итерациях Event Loop, следовательно не блокируют UI. Изменение состояний контролирует Store Dispatcher, и он просто не позволяет накладывать изменения состояний друг на друга, а ставит их в очередь. Ничего сложного, по моему мнению, тут нет.

      Можете предложить конкретный пример, когда такой подход не будет работать?


      1. nuit
        05.08.2015 13:01

        Обработчики событий в устаревших view будут часто ломаться, тк текущий state != view. Если аккуратно подходить к разработке приложения, зная о подобных ограничениях, то не будет возникать проблем, но когда у вашей библиотеки вдруг появятся тысячи внешних потребителей, то эти проблемы будут постоянно возникать.


        1. pragmadash
          05.08.2015 13:11
          +1

          Мне кажется вы до конца не разобрались в подходе, раз говорите про обработчики событий. Обработчики при каждой смене стейта отвязываются от веб-компонентов и првязываются обратно после завершения смены состояния. Благодаря делегированию событий это работает очень эффективно. Архитектура фреймворка просто не позволит вам написать такое приложение, которое сможет работать некорректно, если вы будете следовать документации.

          Вы ведь ни разу сами не пробовали Catberry.js и так уверенно заявляете, что это не будет работать. Как-то неконструктивно. Если вы хотите подтвердить свою точку зрения, то реализуйте такой пример, который продемонстрирует проблему.

          Я не могу писать ссылки на проекты работающие на Catberry.js в посте, потому что это будет рекламой и противоречит правилам. Могу сказать, что сейчас на этом фреймворке, например, работает проект с 3 млн пользователей и 20 млн просмотров в месяц.


          1. nuit
            05.08.2015 13:39

            >Обработчики при каждой смене стейта отвязываются от веб-компонентов и првязываются обратно после завершения смены состояния.

            Когда компонента отмечается как грязная, то обработчики событий по всей деревяшке вниз просто отваливаются до того момента пока компонента не обновит свой view?

            >Вы ведь ни разу сами не пробовали Catberry.js и так уверенно заявляете, что это не будет работать. Как-то неконструктивно.

            Нет, не пробовал, но я работал над созданием планировщика для обновления компонентов в одной из бибиотек для ui, и достаточно будет заменить микротаски на макротаски, чтобы мой планировщик так же работал как и в catberry, вот только это может хорошенько поломать код, который зависит от того что view всегда отображает правильное состояние, ну и более сложные кэйсы с батчингом write/read/write и анимацией.


            1. pragmadash
              05.08.2015 14:12
              +3

              Если у вас есть аналогичное решение и такой опыт, то не должно составить труда написать небольшой пример, который продемонстрирует проблему и мы ее с вами обсудим.

              Я считаю разговор должен быть предметным а не «У вас не будет работать, потому что другие пробовали и не получилось. Даже я пробовал и не получилось!».


  1. nickolaym
    05.08.2015 14:45
    -1

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


    1. pragmadash
      05.08.2015 15:01
      +1

      Смотря, что вы понимаете под словом «страница».

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

      Если вы имеете ввиду еще и все ресурсы, такие как скрипты, картинки и стили, то они загрузятся из кеша браузера, так как в express (чем отдаются ресурсы) используется кеширование через условные заголовки (ETag, Last-Modified).

      Переходы по истории страниц внутри сайта работают как Single Page Application с использованием History API.


  1. tenbits
    06.08.2015 17:51

    Скажите, а из данного примера:

       <FooAsyncComponent />
       <footer> Hello </footer>
    

    `FooAsyncComponent` блокирует: а) рендеринг `footer`a, б) блокирует лишь отправку клиенту, в) не блокирует? Если последнее, то как происходит потом вставка компонента в DOM перед футером? Вместо компоненты посылается изначально placeholder?

    Размер chunka отсылаемого клиенту контролируется нодовским стримом?
    Спасибо.


    1. pragmadash
      06.08.2015 18:14

      Как написано в статье:

      Другими словами, Catberry.js сразу же начинает отдавать корневой шаблон страницы в браузер через реализованный Readable Stream. Когда внутри него встречается веб-компонент, он ставит поток данных на паузу, запрашивает данные для этого веб-компонента и после их получения двигается дальше, пропуская отрендеренный шаблон веб-компонента через такой же механизм поиска уже вложенных веб-компонентов.

      Пока любой веб-компонент асинхронно запрашивает данные, Readable Stream Catberry.js стоит на паузе, следовательно на клиент HTML уходит последовательно, без всяких подмен постфактум. Иначе это было бы нечестно, и возникли бы проблемы с индексацией поисковиками. А так, роботы или любой другой клиент поддерживающий HTTP 1.1 (сейчас фактически все), не заметят разницы вообще.

      Размер chunk'а в стримах не нормирован, зависит от размера куска HTML между предыдущим асинхронным запросом данных и следующим. Всё что накопилось в этот промежуток времени – будет отправлено одним chunk'ом. В предыдущих версиях Catberry.js было нормирование размера chunk'а, но это очень сильно сказывалось на производительности. Так как при рендеринге конечного HTML-документа нет особого backpressure, chunk'и попадающие через pipe от Readable Stream Catberry.js на Writable Stream ServerResponse буферизируются и дробятся на те, что отправляются в браузер под капотом платформы Node.js. Профилирование показало, что это самый эффективный способ.


      1. pragmadash
        06.08.2015 18:20

        Я имел ввиду, что за счет отсутствия каких-либо подмен постфактум, роботы поисковиков не отличат прогрессивный это рендеринг или обычный буферизированный при таком подходе как у Catberry.js.

        P.S.
        Всё, что я говорил – это про сервер. А в браузере веб-компоненты одного уровня в DOM будут рендериться параллельно, не блокируя друг друга.