Мне всегда было интересно, как устроен Хабр изнутри, как построен workflow, как выстроены коммуникации, какие применяются стандарты и как тут вообще пишут код. К счастью, такая возможность у меня появилась, ведь недавно я стал частью хабракоманды. На примере небольшого рефакторинга мобильной версии попробую ответить на вопрос: каково это — работать тут фронтом. В программе: Node, Vue, Vuex и SSR под соусом из заметок о личном опыте в Хабре.
Первое, что нужно знать о команде разработки — нас мало. Мало — это три фронта, два бэка и техлид всея Хабра — Баксли. Есть, конечно, ещё тестировщик, дизайнер, три Вадима, чудо-веник, маркетологиня и прочие Бумбурумы. Но непосредственных контрибьюторов в сорцы Хабра всего шесть. Такое встречается довольно редко — проект с многомиллионной аудиторией, который снаружи выглядит как гигантский энтерпрайз, на деле больше похож на уютный стартап с максимально плоской организационной структурой.
Как и многие другие IT-компании, Хабр исповедует идеи Agile, практику CI и вот это вот всё. Но по моим ощущениям, Хабр как продукт развивается скорее волнообразно, чем непрерывно. Так несколько спринтов подряд мы усердно что-то кодим, проектируем и перепроектируем, ломаем что-то и чиним, резолвим тикеты и заводим новые, наступаем на грабли и стреляем себе в ноги, чтобы наконец релизнуть фичу в прод. А затем наступает некоторое затишье, период перепланировки, время делать то, что находится в квадранте «важно-несрочно».
Как раз о таком «межсезонном» спринте и пойдет речь ниже. На этот раз в него попал рефакторинг мобильной версии Хабра. На нее вообще в компании возлагают большие надежды, и в перспективе она должна заменить собой весь зоопарк инкарнаций Хабра и стать универсальным кроссплатформенным решением. Когда-нибудь тут появится и адаптивная верстка, и PWA, и оффлайн-режим, и пользовательская кастомизация, и много чего интересного.
Ставим задачу
Как-то раз на рядовом стендапе, один из фронтов рассказал о проблемах в архитектуре компонента комментариев мобильной версии. С этой подачи мы организовали микро-совещание в формате групповой психотерапии. Каждый по очереди говорил, где у него болит, всё фиксировали на бумаге, сочувствовали, понимали, разве что никто не хлопал. На выходе получился список из 20 проблем, который ясно давал понять, что мобильный Хабр должен проделать еще долгий и тернистый путь к успеху.
Меня беспокоила прежде всего эффективность использования ресурсов и то, что называется smooth interface. Каждый день на маршруте «дом-работа-дом» я видел как мой старенький телефон отчаянно пытается отобразить 20 заголовков в ленте. Выглядело это примерно так:
Интерфейс мобильного Хабра до рефакторинга
Что здесь происходит? Если кратко, то сервер отдавал HTML страницу всем одинаково, вне зависимости залогинен пользователь или нет. Затем загружается клиентский JS и заново запрашивает необходимые данные, но уже с поправкой на авторизацию. То есть фактически мы делали одну и ту же работу дважды. Интерфейс мерцал, а пользователь загружал добрую сотню лишних килобайт. В подробностях все выглядело еще более жутко.
Старая схема SSR-CSR. Авторизация возможна только на этапах С3 и С4, когда Node JS не занят генерированием HTML и может проксировать запросы на API.
Нашу архитектуру того времени очень точно описал один из пользователей Хабра:
Мобильная версия — дерьмо. Говорю как есть. Ужасное сочетание SSR вместе с CSR.
Мы вынуждены были это признать, как бы печально это ни было.
Я прикинул варианты, поставил себе тикет в «Джире» с описанием на уровне «сейчас плохо, сделай норм» и широким мазками декомпозировал задачу:
- переиспользовать данные,
- минимизировать количество перерисовок,
- исключить дубли запросов,
- сделать процесс загрузки более очевидным.
Переиспользуем данные
В теории server-side rendering призван решить две задачи: не страдать от ограничений поисковых систем по части индексирования SPA и улучшить метрику FMP (неизбежно ухудшив TTI). В классическом сценарии, который окончательно сформулировали в Airbnb в 2013 году (еще на Backbone.js), SSR — это то же самое изоморфное JS-приложение, запущенное в среде Node. Сервер просто отдает в качестве ответа на запрос сгенерированную верстку. Затем происходит регидрация на стороне клиента, и дальше все работает без перезагрузок страницы. Для Хабра, как и для многих других ресурсов с текстовым наполнением, серверный рендеринг — критически важный элемент построения дружественных отношений с поисковиками.
Несмотря на то, что с момента появления технологии прошло уже более шести лет, и за это время в мире фронтэнда утекло действительно много воды, для многих разработчиков эта идея все еще покрыта завесой тайны. Мы не остались в стороне, и выкатили в прод Vue-приложение с поддержкой SSR, упустив одну маленькую деталь: мы не прокинули initial state на клиент.
Почему? Точного ответа на этот вопрос нет. То ли не хотели увеличивать размер ответа от сервера, то ли из-за букета других архитектурных проблем, то ли просто не взлетело. Так или иначе прокинуть state и переиспользовать все, что делал сервер, кажется вполне целесообразным и полезным делом. Задача на самом деле тривиальная — state просто инжектится в контекст выполнения, и Vue автоматически добавляет его к сгенерированной верстке в качестве глобальной переменной:
window.__INITIAL_STATE__
. Одна из возникших проблем — невозможность преобразовать в JSON цикличные структуры (circular reference); решалось простой заменой таких структур на их плоские аналоги.
Кроме того, имея дело с UGC-контентом следует помнить, что данные следует преобразовывать в HTML-entities, для того, чтобы не сломать HTML. Для этих целей мы используем he.
Минимизируем перерисовки
Как видно из схемы выше, в нашем случае один инстанс Node JS выполняет две функции: SSR и «прокси» в API, где как раз происходит авторизация пользователя. Это обстоятельство делает невозможным авторизацию в момент исполнения JS-кода на сервере, так как нода однопоточная, а функция SSR синхронная. То есть сервер просто не может отправлять запросы сам на себя, пока коллстэк чем-то занят. Получилось так, что state мы прокинули, но интерфейс не переставал дергаться, так как данные на клиенте следовало обновить с учетом пользовательской сессии. Нужно было научить наше приложение класть в initial state правильные данные с учетом логина пользователя.
Решений проблемы нашлось всего два:
- цеплять авторизационные данные к межсерверным запросам;
- разбить слои Node JS в два отдельных инстанса.
Первое решение требовало использовать глобальные переменные на сервере, а второе растягивало сроки реализации задачи как минимум на месяц.
Как сделать выбор? Хабр часто двигается по пути наименьшего сопротивления. Неформально здесь существует некое общее стремление сокращать до минимума цикл от идеи до прототипа. Модель отношения к продукту чем-то напоминает постулаты booking.com, с той лишь разницей, что Хабр куда более серьезно относится к пользовательскому фидбеку и доверяет принятие подобных решений тебе как разработчику.
Следуя этой логике и своему собственному желанию побыстрее решить проблему, я выбрал глобальные переменные. И, как это часто случается, за них рано или поздно приходится платить. Мы заплатили почти сразу: поработали в выходные, разгребли последствия, написали post mortem и начали делить сервер на две части. Ошибка была очень глупой, а баг с ее участием воспроизводился непросто. И да, за такое стыдно, но так или иначе, спотыкаясь и кряхтя, мой PoC с глобальными переменными все же вышел в продакшн и вполне успешно работает в ожидании переезда на новую «двухнодную» архитектуру. Это был важный шаг, ведь формально цель была достигнута — SSR научился отдавать полностью готовую к использованию страницу, а UI стал намного более спокойным.
Интерфейс мобильного Хабра после первого этапа рефакторинга
В конечном счете архитектура SSR-CSR мобильной версии ведет вот к такой картине:
"Двухнодная" схема SSR-CSR. Node JS API всегда готова к асинхронному I/O и не блокируется функцией SSR, так как последняя находится в отдельном инстансе. Цепочка запросов #3 не нужна.
Исключаем дубли запросов
После проделанных манипуляций, первоначальный рендер страницы перестал провоцировать эпилепсию. Но дальнейшее использование Хабра в режиме SPA все ещё вызывало недоумение.
Так как основу user flow составляют переходы вида список статей → статья → комментарии и обратно, важно было оптимизировать расход ресурсов этой цепочки в первую очередь.
Возврат к ленте постов провоцирует новый запрос данных
Глубоко копать не пришлось. На скринкасте выше видно, что приложение перезапрашивает список статей при свайпе назад, причём во время запроса мы статьи не видим, значит предыдущие данные куда-то исчезают. Выглядит все так, будто компонент списка статей использует локальный стейт и теряет его на destroy. На самом же деле, приложение использовало глобальный стейт, но архитектура Vuex была построена «в лоб»: модули привязаны к страницам, которые в свою очередь привязаны к роутам. Причем все модули «одноразовые» — каждый следующий заход на страницу переписывал модуль целиком:
ArticlesList: [
{ Article1 },
...
],
PageArticle: { ArticleFull1 },
Итого, у нас был модуль ArticlesList, который содержит в себе объекты типа Article и модуль PageArticle, который являлся расширенной версией объекта Article, cвоего рода ArticleFull. По большому счету, данная реализация ничего ужасного в себе не несет — это очень просто, можно даже сказать наивно, но предельно понятно. Если выпилить обнуление модуля при каждой смене роута, то с этим можно даже жить. Однако переход между лентами статей, к примеру /feed → /all, гарантированно выбросит все, что связано с персональной лентой, так как у нас всего один ArticlesList, в который нужно положить новые данные. Это снова нас приводит к дублированию запросов.
Собрав в кучу все, что удалось раскопать по теме, я сформулировал новую структуру стейта и представил ее коллегам. Обсуждения были продолжительными, но в итоге аргументы «за» перевесили сомнения, и я приступил к реализации.
Логика решения лучше всего раскрывается за два этапа. Сначала мы пытаемся отвязать модуль Vuex от страниц и привязать напрямую к роутам. Да, данных в сторе станет немного больше, геттеры станут чуть сложнее, но мы не будем грузить статьи по два раза. Для мобильной версии, это, пожалуй, самый сильный аргумент. Получится примерно так:
ArticlesList: {
ROUTE_FEED: [
{ Article1 },
...
],
ROUTE_ALL: [
{ Article2 },
...
],
}
Но что если списки статей могут пересекаться между несколькими роутами и что, если мы хотим переиспользовать данные объекта Article для отрисовки страницы поста, превратив его в ArticleFull? В этом случае, более логичным было бы использование такой структуры:
ArticlesIds: {
ROUTE_FEED: [ '1', ... ],
ROUTE_ALL: [ '1', '2', ... ],
},
ArticlesList: {
'1': { Article1 },
'2': { Article2 },
...
}
ArticlesList здесь — это просто некое хранилище статей. Всех статей, которые были загружены в течение пользовательской сессии. Мы относимся к ним максимально бережно, ведь это трафик, который, возможно, был загружен через боль где-нибудь в метро между станциями, и мы совершенно точно не хотим причинить пользователю эту боль снова, заставив его грузить данные, которые он уже загрузил. Объект ArticlesIds является просто массивом айдишников (как бы «ссылок») на объекты Article. Такая структура позволяет не дублировать общие для роутов данные и переиспользовать объект Article при рендере страницы поста посредством мержа в него расширенных данных.
Вывод списка статей стал также более прозрачным: компонент-итератор перебирает массив с айдишниками статей и отрисовывает компонент тизера статьи, передавая Id в качестве пропса, а дочерний компонент в свою очередь достает нужные данные из ArticlesList. При переходе на страницу публикации, мы достаем уже имеющуюся дату из ArticlesList, делаем запрос на получение недостающих данных и просто добавляем их к существующему объекту.
Почему этот подход лучше? Как я писал выше, такой подход бережнее по отношению к загружаемым данным и позволяет переиспользовать их. Но помимо этого он открывает дорогу некоторым новым возможностям, которые отлично вписываются в такую архитектуру. Например, поллинг и подгрузка статей в ленту по мере их появления. Мы можем просто сложить свежие посты в «хранилище» ArticlesList, сохранить отдельный список новых айдишников в ArticlesIds и уведомить пользователя об этом. При нажатии на кнопку «Показать новые публикации», мы просто вставим новые Id в начало массива текущего списка статей и все будет работать почти магическим образом.
Делаем загрузку приятнее
Вишенкой на торте рефакторинга стала концепция скелетонов, которая делает процесс загрузки контента на медленном интернете чуть менее отвратительным. Никаких дискуссий на этот счет не было, путь от идеи до прототипа занял буквально два часа. Дизайн нарисовался практически сам, и мы научили наши компоненты рендерить незатейливые, едва-мерцающие div-блоки во время ожидания данных. Субъективно такой подход к лоадингу действительно уменьшает количество гормонов стресса в организме пользователя. Скелетон выглядит так:
Хабралоадинг
Рефлексируем
Я полгода работаю в Хабре и знакомые по-прежнему спрашивают: ну что, как тебе там? Хорошо, комфортно — да. Но есть кое-что, что отличает эту работу от других. Я работал в командах, которые были абсолютно равнодушны к своему продукту, не знали и не понимали, кто их пользователи. А здесь все по-другому. Тут чувствуешь ответственность за то, что делаешь. В процессе разработки фичи, ты частично становишься ее оунером, принимаешь участие во всех продуктовых встречах, связанных с твоим функционалом, вносишь предложения и сам принимаешь решения. Делать продукт, которым ежедневно пользуешься сам, очень круто, а писать код для людей, которые, возможно, разбираются в этом лучше тебя — просто невероятное ощущение (no sarcasm).
После релиза всех этих изменений мы получили позитивный фидбэк, и это было очень и очень приятно. Это вдохновляет. Спасибо! Пишите еще.
Напомню, что после глобальных переменных мы решились на смену архитектуры и выделение проксирующего слоя в отдельный инстанс. «Двухнодная» архитектура уже добралась до релиза в виде публичного бета-тестирования. Сейчас любой желающий может переключиться на нее и помочь нам сделать мобильный Хабр лучше. На сегодня все. С удовольствием отвечу на все ваши вопросы в комментариях.