Catberry.js: Прогрессивный рендеринг
В предыдущей статье я рассказывал про реализацию 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», который работает с -элементом страницы. Если до момента рендеринга HTML этого компонента вы используете методы:
this.$context.notFound()
— передаёт управление следующему Express middlewarethis.$context.redirect('/some/path')
this.$context.cookie.set({key: 'hello', value: 'world'})
То произойдёт честный редирект через HTTP-код ответа 302 и заголовок Location, а в случае с Cookie, будет заголовок Set-Cookie. Другими словами, пока ваш метод «render» компонента «head» не вернёт данные, все эти методы выставят нужный HTTP-код ответа и заголовки.
Если вызывать эти методы позже, в начале веб-компонента, где они были вызваны, будет добавлен -тэг с кодом, который делает редирект через
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 -элемента на каждой итерации Event Loop. Если вы зайдете на любой большой сайт и запустите этот код в Chrome Dev Tools, то увидите, что такая страшная, казалось бы, вещь происходит не так уж и болезненно и вы даже можете спокойно скролить страницу.
А как же обработчики событий, спросите вы?
Catberry.js отлично с этим справляется благодаря встроенному механизму делегирования событий. То есть обработчики событий есть только у элементов веб-компонентов и они отвязываются и привязываются когда это необходимо.
После каждого процесса изменения состояния, Catberry.js удаляет объекты веб-компонентов, которые уже отсутствуют в новом состоянии приложения, что устраняет возможность каких-либо утечек памяти.
Если вам интересно взглянуть на код браузерной реализации, то можно это сделать здесь.
Разумеется реализация прогрессивного рендеринга 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