Вырезаем SSR и ускоряем Хабр в 10 раз

в 19:21, , рубрики: $mol, ssr, TypeScript, view.tree, vuejs, адаптивный дизайн, виртуальный рендеринг, Клиентская оптимизация, Разработка веб-сайтов, хабрахабр

Здравствуйте, меня зовут Дмитрий Карловский и я… тот ещё токсичный перец. Недавно я источал свои альфа-флюиды на Альфа-банк. Ребята в ответ поступили достойно, и не стали атаковать меня в личку объяснениями, как сильно я не прав, а завели задачу на гитхабе. И даже что-то пофиксили, сделав часть моих претензий несостоятельными. Но не ту часть, где 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

Помрёшь пока дождёшься

Методика измерений

  • Размер данных — объём HTML или JSON выдачи. Показывается в девтулзах Хрома, если включить "широкие строки". Вес указан лишь того ресурса, что выдаёт комментарии.
  • Размер сжатых данных — то, что девтулзы показывают по умолчанию для загруженных ресурсов.
  • Время полной загрузки — время открытия всех комментариев (да, иногда Хром всё же рендерит всё — не понятно от чего зависит), обычным секундомером на глазок от нажатия F5 (или ссылки "показать комментарии") до завершения всех видимых пользователю загрузок. Кеш отключался через девтулзы.
  • Время показа первого экрана — то же, что и время полной загрузки, но дожидаясь лишь появления контента, заполняющего весь экран, что позволяет начать его читать.
  • Число DOM элементов — выводился document.getElementsByTagName('*').length в режиме наблюдения, приведено максимально наблюдаемое число.
  • Отзывчивость при прокрутке — длительность блокировки основного потока при скроллинге на одну страницу. Замерялось через профайлер.
  • Пересчёт лейаута — длительность блокировки основного потока при однократном изменении размера окна. Замерялось через профайлер.
  • Потребление памяти — объём, который показывают девтулзы для основного потока, после полной загрузки и ручного запуска сборки мусора.

Для всех замеров использовался обычный домашний вайфай и ноутбук с развёрнутыми на пол экрана девтулзами. Если что-то не учёл — обязательно докопайтесь к этому в комментариях.

Флюгегехаймен

Предварительный анализ

У Хабра есть две версии: десктопная и мобильная. Десктопная загружает статью со всеми комментариями единым HTML. Мобильная же поступает хитрее: сначала загружается статья в виде HTML, а по клику на кнопку "комментарии" она подгружает JSON с комментариями и рендерит их с помощью VueJS вместо статьи. Но если на комментарии зайти по прямой ссылке, то будет загружен пререндеренный HTML. Пререндеренный HTML ничем принципиально не отличается от десктопной версии, поэтому я замерял именно вариант с динамическим рендером, который отрабатывает в большинстве реальных сценариев использования.

Как видно, вес JSON примерно в 4 раза меньше HTML. В формате Tree эти данные весили бы ещё меньше, но парсились бы дольше, ибо кастомный парсер на яваскрипте даже более простого формата всё же медленнее нативного JSON парсера. В любом случае, после сжатия, которое применяется сейчас повсеместно, разница уже не столь существенна — порядка 30%.

Пререндеренный HTML довольно долго грузится, это связано с двумя факторами:

  1. Параллельно начинают грузиться все изображения со страницы, а это 8 мегабайт несжимаемого трафика, который капитально забивает канал. Решить эту проблему могли бы через loading="lazy", но не стали.
  2. После загрузки очередного куска HTML происходит его подклейка в общее дерево, что вызывает повторное вычисление стилей, лейаута и рендеринга. Так как стоимость этого процесса растёт по мере роста DOM, то зависимость суммарной задержки загрузки от размера HTML тут экспоненциальная. Решается только одним способом — уменьшением HTML.

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

Кроме того, использование VueJS даёт довольно серьёзное пенальти по памяти — оно увеличивается более чем в 25 раз.

Всего на страницу выводится около 100K DOM элементов. Это в среднем около 40 элементов на каждый комментарий.

Браузеру становится очень плохо от такого большого DOM дерева и он начинает серьёзно тупить. Например, обновление экрана при скроллинге, несмотря на его аппаратное ускорение, занимает 700мс. И это чисто скроллинг, без пересчёта лейаута. С ним же — почти 2 секунды. А пересчитывается лейаут почти на любое изменение DOM дерева. DevTools тоже порой сходят с ума, что осложняет профилирование.

Дерево угнетает

Выбор стратегии

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

Сворачивание Паджинация Виртуализация
Описание Ветки комментариев изначально свёрнуты и разворачиваются вручную. На одну страницу выводится ограниченное число комментариев. Чтобы увидеть остальные нужно переходить между страницами Рендерятся только комментарии, попадающие в видимую область, а остальные удаляются.
Решается ли проблема скорости загрузки Да Да Да
Решается ли проблема отзывчивости работы Нет, пользователь с большой вероятностью откроет много веток и получит тормоза. ### Да, размер дерева на каждой странице примерно одинаков и регулируем. Да, размер дерева зависит от размера экрана.
Поведение браузерного поиска по странице Ищет только в развёрнутом. ### Ищет только по текущей странице. ## Ищет только по видимым комментариям. ###
Что может вызвать раздражение пользователя Необходимость постоянно разворачивать ветки комментариев. ## Необходимость переходить между страницами. ## Трудно уследить за иерархией ответов. ### Скачки скроллбара. # Возможные скачки контента при скроллинге. ##
Наибольший уровень недовольства пользователя ### ### ## ### ## ## ### ## #

Выводы:

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

Выбор из трёх зол

Прототипирование

Самый простой способ воспользоваться виртуальным рендерингом — переписать страницу на $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-элементов — далеко не всё, что может выдавать Хабр. Добавить поддержку остальных в принципе не сложно и она, конечно, будет расширяться по мере необходимости.

Кроме того, есть и технические косяки:

  1. Контент при скроллинге иногда скачет — это какой-то косяк в логике виртуализации $mol_list. Не приятно, но жить можно. Как-нибудь конечно же починю.
  2. В сафари автоматически отключается виртуализация сверху, так как он не поддерживает overflow-anchor, необходимый для того, чтобы можно было менять контент сверху от видимой области без скачков видимого контента. Это означает, что страница будет открываться всё так же быстро, но по мере прокрутки вниз будет дорендеривать всё остальное содержимое, пока не отрендерит все 100K элементов, когда пользователь домотает до самого конца.

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

Король Разработки

Резюме

  1. SSR не быстрее грамотной реализации PWA.
  2. Вместо поддержки двух абы как слепленных реализаций для разных девайсов, лучше потратить это время на одну, но толковую и адаптивную.
  3. Выбирая между двух зол, лучше сделать шаг в сторону — возможно именно там расположилось добро.
  4. VueJS — тупит, а $mol — рулит.
  5. Браузеру становится очень плохо, когда в доме много элементалей.
  6. Стоит предпочитать те решения, что не приводят к не контролируемому увеличению числа элементалей в доме.

Элементалекапец

Ссылки

  1. Исходники читалки — можете заметить, что кода там всего ничего.
  2. Страница фрейморка $mol — ужаснитесь сколько всего у нас там есть.
  3. Канал с новостями о $mol и MAM — подписывайтесь, чтобы быть в курсе всего важного, что с ними происходит.
  4. Канал с видео о $mol — когда-нибудь тут появятся видео-туториалы.

Автор: Дмитрий Карловский

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js