Вырезаем SSR и ускоряем Хабр в 10 раз
- понедельник, 10 августа 2020 г. в 00:32:28
Здравствуйте, меня зовут Дмитрий Карловский и я… тот ещё токсичный перец. Недавно я источал свои альфа-флюиды на Альфа-банк. Ребята в ответ поступили достойно, и не стали атаковать меня в личку объяснениями, как сильно я не прав, а завели задачу на гитхабе. И даже что-то пофиксили, сделав часть моих претензий несостоятельными. Но не ту часть, где SSR портит всё.
Время утекло, пыль улеглась, и тут история получает продолжение: недавно ко мне обратился продюсер контент-студии Хабра с предложением пропесочить их Торт. Что ж, расчехляем вентилятор!
Возьмём, например, вот эту страницу, содержащую 2500 комментариев. Это настолько огромная страница, что если вы откроете её в Хроме, то он обрежет её уже на 1400 комментарии. Чтобы прочитать оставшиеся вам придётся открыть её, например, в Огнелисе. Причину этого оставим на совести разработчиков. Давайте лучше подумаем как этого не допустить. Но сперва проведём замеры:
Показатель | Десктопная версия (HTML) | Мобильная версия (JSON) | Ускоренная универсальная версия (JSON) |
---|---|---|---|
Размер данных | 12 MB | 3.4 MB | 3.4 MB |
Размер сжатых данных | 1000 KB | 700 KB | 700 KB |
Время полной загрузки | 45 s | 42 s | 5 s |
Время показа первого экрана | 5 s | 42 s | 5 s |
Число DOM элементов | 116K | 100K | < 1K |
Отзывчивость при прокрутке | 700 ms | не удалось замерить | 30 ms |
Пересчёт лейаута | 1800 ms | не удалось замерить | 30 ms |
Потребление памяти | 15 MB | 390 MB | 63 MB |
document.getElementsByTagName('*').length
в режиме наблюдения, приведено максимально наблюдаемое число.Для всех замеров использовался обычный домашний вайфай и ноутбук с развёрнутыми на пол экрана девтулзами. Если что-то не учёл — обязательно докопайтесь к этому в комментариях.
У Хабра есть две версии: десктопная и мобильная. Десктопная загружает статью со всеми комментариями единым HTML. Мобильная же поступает хитрее: сначала загружается статья в виде HTML, а по клику на кнопку "комментарии" она подгружает JSON с комментариями и рендерит их с помощью VueJS вместо статьи. Но если на комментарии зайти по прямой ссылке, то будет загружен пререндеренный HTML. Пререндеренный HTML ничем принципиально не отличается от десктопной версии, поэтому я замерял именно вариант с динамическим рендером, который отрабатывает в большинстве реальных сценариев использования.
Как видно, вес JSON примерно в 4 раза меньше HTML. В формате Tree эти данные весили бы ещё меньше, но парсились бы дольше, ибо кастомный парсер на яваскрипте даже более простого формата всё же медленнее нативного JSON парсера. В любом случае, после сжатия, которое применяется сейчас повсеместно, разница уже не столь существенна — порядка 30%.
Пререндеренный HTML довольно долго грузится, это связано с двумя факторами:
При динамическом рендеринге загрузка данных происходит быстрее, но сэкономленное время компенсируется временем на работу яваскрипта по генерации DOM дерева. В обоих случаях пользователю приходится ждать не меньше полу минуты до возможности прочитать новые комментарии, что крайне долго. Также динамический рендеринг на VueJS не прогрессивен, то есть не позволяет начать чтение контента до его полной обработки.
Кроме того, использование VueJS даёт довольно серьёзное пенальти по памяти — оно увеличивается более чем в 25 раз.
Всего на страницу выводится около 100K DOM элементов. Это в среднем около 40 элементов на каждый комментарий.
Браузеру становится очень плохо от такого большого DOM дерева и он начинает серьёзно тупить. Например, обновление экрана при скроллинге, несмотря на его аппаратное ускорение, занимает 700мс. И это чисто скроллинг, без пересчёта лейаута. С ним же — почти 2 секунды. А пересчитывается лейаут почти на любое изменение DOM дерева. DevTools тоже порой сходят с ума, что осложняет профилирование.
Основная причина тормозов — размер DOM дерева. Поэтому требуется такая реализация, которая позволит иметь на пару порядков меньшее число элементов на странице. Выпишем возможные стратегии:
Сворачивание | Паджинация | Виртуализация | |
---|---|---|---|
Описание | Ветки комментариев изначально свёрнуты и разворачиваются вручную. | На одну страницу выводится ограниченное число комментариев. Чтобы увидеть остальные нужно переходить между страницами | Рендерятся только комментарии, попадающие в видимую область, а остальные удаляются. |
Решается ли проблема скорости загрузки | Да | Да | Да |
Решается ли проблема отзывчивости работы | Нет, пользователь с большой вероятностью откроет много веток и получит тормоза. ### | Да, размер дерева на каждой странице примерно одинаков и регулируем. | Да, размер дерева зависит от размера экрана. |
Поведение браузерного поиска по странице | Ищет только в развёрнутом. ### | Ищет только по текущей странице. ## | Ищет только по видимым комментариям. ### |
Что может вызвать раздражение пользователя | Необходимость постоянно разворачивать ветки комментариев. ## | Необходимость переходить между страницами. ## Трудно уследить за иерархией ответов. ### | Скачки скроллбара. # Возможные скачки контента при скроллинге. ## |
Наибольший уровень недовольства пользователя | ### ### ## | ### ## ## | ### ## # |
Выводы:
Самый простой способ воспользоваться виртуальным рендерингом — переписать страницу на $mol, что позволит нам писать код даже не думая о виртуализации, но всё равно её получить, так как в $mol она встроена под капотом.
Хабр возвращает статью и комментарии в виде JSON. Давайте опишем его схему, чтобы тайпскрипт давал нам подсказки и тайпчек, а рантайм проверял, что выдача сервера соответствует ожидаемой. Для этого мы воспользуемся семейством функций $mol_data:
const Person = Rec({
alias: Str,
id: Str,
login: Str,
fullname: Maybe( Str ),
avatarUrl: Maybe( Str ),
speciality: Maybe( Str ),
})
const Comment = Rec({
id: Int,
author: Maybe( Person ),
children: List( Int ),
isAuthor: Maybe( Bool ),
isPostAuthor: Maybe( Bool ),
message: Str,
parentId: Int,
score: Maybe( Int ),
timeChanged: Maybe( Moment ),
timePublished: Maybe( Moment ),
})
const Comments_response = Rec({
comments: Dict( Comment ),
threads: List( Int ),
})
Некоторые поля опциональны — в них возвращается null
для сообщений, оставленных НЛО.
Теперь мы можем легко загрузить данные, проверить их и вернуть типизированную структуру. Например, так выглядит загрузка комментариев:
@ $mol_mem
comments_data() {
const uri = `https://m.habr.com/kek/v2/articles/${ this.article_id() }/comments/`
const data = Comments_response( this.$.$mol_fetch.json( uri ) )
return data
}
Хорошо, данные мы загрузили, осталось их показать. Текст статей и комментариев Хабр возвращает в виде строки, содержащей довольно разношёрстный HTML. Для его отображения воспользуемся компонентом $mol_html_view:
<= Article $mol_html_view
html <= article_content \
highlight <= search
image_uri!node <= image_uri!node \
(Если вы не знакомы с синтаксисом view.tree, предназначенным для декларативной композиции компонент, то можете ознакомиться с кратким или полным его описанием.)
Этот компонент берёт HTML, парсит его и для каждого элемента создаёт соответствующий $mol_view компонент, а он уже сам себя виртуализирует. Кроме того, $mol_html_view позволяет указать строку, которая будет подсвечена в тексте.
Также нам потребовалось перегрузить метод image_uri
, который вызывается для каждого IMG
элемента, чтобы получить ссылку на картинку. Причина в том, что в атрибуте src
в выдаче Хабра изначально находится лишь ссылка на заглушку, а реальная ссылка на изображение берётся из атрибута data-src
. Поэтому реализуем этот метод так:
image_uri( node : HTMLImageElement ) {
return node.dataset.src || node.src || 'about:blank'
}
Рендеринг дерева комментариев не представляет из себя ничего особенного. Разве что мы добавим возможность сворачивать/разворачивать ветки комментариев.
@ $mol_mem_key
comments_visible( id : number ) : readonly number[] {
if( this.comment_expanded( id ) ) {
return this.comments_all( id )
} else {
return this.comments_filtered( id )
}
}
Эта функциональность полезна и сама по себе, но нам она ещё пригодится и для поиска. Дело в том, что $mol пока ещё не умеет прокручивать скролл к заданному компоненту. Штука эта вполне реализуема, но у меня руки пока не дошли, так что если если кто-то возьмётся за это дело — было бы супер.
А сейчас, мы, вместо прокрутки страницы между найденными постами, будем сворачивать все ветки комментариев, где искомая подстрока в тексте не встречается. Таким образом пользователь может ввести запрос и просто скроллить страницу. А при желании — развернуть ветку и почитать все ответы. Выглядит это примерно так:
Многие пользователи привыкли к хоткею Ctrl+F
для поиска по странице, поэтому добавим плагин $mol_hotkey для его перехвата:
plugins /
<= Search_key $mol_hotkey
mod_ctrl true
key * F?event <=> search_focus?event null
<= Theme $mol_theme_auto
Ну а для фокусирования просто доберёмся до нужного нам компонента и скажем ему сфокусироваться:
search_focus( event : Event ) {
this.Search().Suggest().Filter().focused( true )
event.preventDefault()
}
Обратите внимание, что мы так же добавили плагин $mol_theme_auto, который автоматически ставит светлую или тёмную тему в зависимости от предпочтений пользователя.
Кроме того, добавим пользователю возможность вручную менять тему, разместив на тулбаре кнопку $mol_lights_toggle:
tools /
<= Lights $mol_lights_toggle
<= Sources $mol_link_source
uri \https://github.com/nin-jin/habrcomment
<= Search $mol_search
query?val <=> search?val \
Заодно мы тут расположили стандартную ссылку на исходники и поисковое поле, связав его двусторонней связью с поисковым запросом.
Наведём немного красоты, переопределив некоторые дефолтные стили, используя модуль $mol_style, который обеспечивает тайпчек и подсказки, учитывающие реальную иерархию компонент:
$mol_style_define( $my_habrcomment , {
Orig: {
flex: {
grow: 1,
shrink: 0,
basis: per(50),
},
},
Article: {
maxWidth: rem(60),
},
Comments: {
flex: {
shrink: 0,
grow: 1,
},
},
Comments_empty: {
padding: rem(1.5),
},
} )
Последним штрихом добавим поддержку оффлайна:
include \/mol/offline/install
Этим не хитрым кодом мы установили кеширующий Service Worker, который в случае проблем с соединением будет выдавать данные из кеша.
Не то чтобы оффлайн и переключение цветовых тем были необходимы, но если есть возможность практически бесплатно получить вау фичи, которых нет в оригинале, то почему бы и нет.
Что ж, вот наш прототип читалки хабракомментариев и готов: https://nin-jin.github.io/habrcomment/#article=423889
Держите букмарклет, позволяющий открывать в ней любую тяжёлую статью:
javascript: document.location = document.location.href.replace( /\D+/ , 'https://nin-jin.github.io/habrcomment/#article=' )
Код исходников уложился в 400 строк, на написание которых требуется не более пары часов. По функциональности:
Мы добились ускорения полной загрузки огромной страницы в 5 раз. А потребление памяти уменьшилось в 6 раз по сравнению с мобильной версией (и в 2 раза, если отключить виртуализацию). Для мобилок это куда актуальней, чем для десктопа. При этом мы ещё даже не приступили к собственно оптимизации кода.
Если пошаманить можно выиграть ещё несколько секунд. Например, в наивной реализации HTML всех комментариев парсится при открытии страницы для расчёта их минимальных размеров исходя из содержимого. Однако, можно руками задать какое-то константное значение, меньше которого текст комментария точно быть не может:
<= Message $mol_html_view
minimal_height 60
highlight <= search \
html <= message \
image_uri!node <= image_uri!node \
Таким образом парсинг HTML будет происходить лишь при фактическом отображении. Позиция скроллбара, конечно, тогда будет рассчитываться несколько менее точно, но это не страшно.
Итого по скорости загрузки: почти десятикратное ускорение полной загрузки при сохранении времени появления контента в 5 секунд.
А если есть возможность менять API, то можно пойти ещё дальше — изначально загружать лишь информацию о вложенности комментариев, а собственно информацию по ним загружать лениво, по мере необходимости. Благодаря виртуализации необходимость эта будет возникать лишь при приближении комментария к видимой области. А самое примечательное, что для перехода на ленивую загрузку даже не придётся обновлять вьюшки — достаточно обновить код получения данных. Аналогичным образом легко и переключить клиентский поиск на серверный:
@ $mol_mem
comments_data() {
const search = encodeURIComponent( this.search() )
const uri = `https://m.habr.com/kek/v2/articles/${ this.article_id() }/comments/?search=${search}`
const data = Comments_response( this.$.$mol_fetch.json( uri ) )
return data
}
Реализована сейчас, разумеется, не вся функциональность. Но всё остальное особой погоды не делает. Чтобы довести до продакшена, надо будет ещё много чего реализовать. Если кто-то готов этим заняться — можете попробовать. Правда сперва придётся договориться с руководством Хабра об использовании их API. Я пробовал несколько раз подкатить с разных сторон — никакого ответа не получил.
Также можно заметить, что отступы вокруг разных комментариев пляшут как попало — это от того, что HTML, отдаваемый Хабром, это какая-то дичь от слова совсем: слова то в параграфах, то в дивах, то параграфов вообще нет, а абзацы разделяются переводами строк или тегом BR, что вообще вырубает виртуализацию. В общем, тут надо либо чтобы Хабр отдавал что-то единообразное и валидное, либо пилить какой-то свой нормализатор выдачи.
$mol_html_view поддерживает сейчас лишь сравнительно небольшой набор HTML-элементов — далеко не всё, что может выдавать Хабр. Добавить поддержку остальных в принципе не сложно и она, конечно, будет расширяться по мере необходимости.
Кроме того, есть и технические косяки:
А вот появление горизонтального скролла на узких экранах и глубоких обсуждениях — это не баг, а фича. Лучше уж иногда скроллить в сторону, чем вообще терять понимание какой комментарий на какой отвечает начиная с определённого уровня, как это происходит в оригинале. А вот на широком экране мы не ужимаем все комментарии в одну узкую колонку, а занимаем столько места, чтобы их было комфортно читать.