Хабрахабр

Логи фронтенд-разработчика Хабра: рефакторим и рефлексируем

К счастью, такая возможность у меня появилась, ведь недавно я стал частью хабракоманды. Мне всегда было интересно, как устроен Хабр изнутри, как построен workflow, как выстроены коммуникации, какие применяются стандарты и как тут вообще пишут код. В программе: Node, Vue, Vuex и SSR под соусом из заметок о личном опыте в Хабре. На примере небольшого рефакторинга мобильной версии попробую ответить на вопрос: каково это — работать тут фронтом.

Мало — это три фронта, два бэка и техлид всея Хабра — Баксли. Первое, что нужно знать о команде разработки — нас мало. Но непосредственных контрибьюторов в сорцы Хабра всего шесть. Есть, конечно, ещё тестировщик, дизайнер, три Вадима, чудо-веник, маркетологиня и прочие Бумбурумы. Такое встречается довольно редко — проект с многомиллионной аудиторией, который снаружи выглядит как гигантский энтерпрайз, на деле больше похож на уютный стартап с максимально плоской организационной структурой.

Но по моим ощущениям, Хабр как продукт развивается скорее волнообразно, чем непрерывно. Как и многие другие IT-компании, Хабр исповедует идеи Agile, практику CI и вот это вот всё. А затем наступает некоторое затишье, период перепланировки, время делать то, что находится в квадранте «важно-несрочно». Так несколько спринтов подряд мы усердно что-то кодим, проектируем и перепроектируем, ломаем что-то и чиним, резолвим тикеты и заводим новые, наступаем на грабли и стреляем себе в ноги, чтобы наконец релизнуть фичу в прод.

На этот раз в него попал рефакторинг мобильной версии Хабра. Как раз о таком «межсезонном» спринте и пойдет речь ниже. Когда-нибудь тут появится и адаптивная верстка, и PWA, и оффлайн-режим, и пользовательская кастомизация, и много чего интересного. На нее вообще в компании возлагают большие надежды, и в перспективе она должна заменить собой весь зоопарк инкарнаций Хабра и стать универсальным кроссплатформенным решением.

Ставим задачу

Как-то раз на рядовом стендапе, один из фронтов рассказал о проблемах в архитектуре компонента комментариев мобильной версии. С этой подачи мы организовали микро-совещание в формате групповой психотерапии. Каждый по очереди говорил, где у него болит, всё фиксировали на бумаге, сочувствовали, понимали, разве что никто не хлопал. На выходе получился список из 20 проблем, который ясно давал понять, что мобильный Хабр должен проделать еще долгий и тернистый путь к успеху.

Каждый день на маршруте «дом-работа-дом» я видел как мой старенький телефон отчаянно пытается отобразить 20 заголовков в ленте. Меня беспокоила прежде всего эффективность использования ресурсов и то, что называется smooth interface. Выглядело это примерно так:

image

Интерфейс мобильного Хабра до рефакторинга

Если кратко, то сервер отдавал 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); решалось простой заменой таких структур на их плоские аналоги.

Для этих целей мы используем he. Кроме того, имея дело с UGC-контентом следует помнить, что данные следует преобразовывать в HTML-entities, для того, чтобы не сломать HTML.

Минимизируем перерисовки

Как видно из схемы выше, в нашем случае один инстанс Node JS выполняет две функции: SSR и «прокси» в API, где как раз происходит авторизация пользователя. Это обстоятельство делает невозможным авторизацию в момент исполнения JS-кода на сервере, так как нода однопоточная, а функция SSR синхронная. То есть сервер просто не может отправлять запросы сам на себя, пока коллстэк чем-то занят. Получилось так, что state мы прокинули, но интерфейс не переставал дергаться, так как данные на клиенте следовало обновить с учетом пользовательской сессии. Нужно было научить наше приложение класть в initial state правильные данные с учетом логина пользователя.

Решений проблемы нашлось всего два:

  • цеплять авторизационные данные к межсерверным запросам;
  • разбить слои Node JS в два отдельных инстанса.

Первое решение требовало использовать глобальные переменные на сервере, а второе растягивало сроки реализации задачи как минимум на месяц.

Хабр часто двигается по пути наименьшего сопротивления. Как сделать выбор? Модель отношения к продукту чем-то напоминает постулаты booking.com, с той лишь разницей, что Хабр куда более серьезно относится к пользовательскому фидбеку и доверяет принятие подобных решений тебе как разработчику. Неформально здесь существует некое общее стремление сокращать до минимума цикл от идеи до прототипа.

И, как это часто случается, за них рано или поздно приходится платить. Следуя этой логике и своему собственному желанию побыстрее решить проблему, я выбрал глобальные переменные. Ошибка была очень глупой, а баг с ее участием воспроизводился непросто. Мы заплатили почти сразу: поработали в выходные, разгребли последствия, написали post mortem и начали делить сервер на две части. Это был важный шаг, ведь формально цель была достигнута — SSR научился отдавать полностью готовую к использованию страницу, а UI стал намного более спокойным. И да, за такое стыдно, но так или иначе, спотыкаясь и кряхтя, мой PoC с глобальными переменными все же вышел в продакшн и вполне успешно работает в ожидании переезда на новую «двухнодную» архитектуру.

image

Интерфейс мобильного Хабра после первого этапа рефакторинга

В конечном счете архитектура SSR-CSR мобильной версии ведет вот к такой картине:

image

"Двухнодная" схема SSR-CSR. Node JS API всегда готова к асинхронному I/O и не блокируется функцией SSR, так как последняя находится в отдельном инстансе. Цепочка запросов #3 не нужна.

Исключаем дубли запросов

После проделанных манипуляций, первоначальный рендер страницы перестал провоцировать эпилепсию. Но дальнейшее использование Хабра в режиме SPA все ещё вызывало недоумение.

Так как основу user flow составляют переходы вида список статей → статья → комментарии и обратно, важно было оптимизировать расход ресурсов этой цепочки в первую очередь.

image

Возврат к ленте постов провоцирует новый запрос данных

На скринкасте выше видно, что приложение перезапрашивает список статей при свайпе назад, причём во время запроса мы статьи не видим, значит предыдущие данные куда-то исчезают. Глубоко копать не пришлось. На самом же деле, приложение использовало глобальный стейт, но архитектура Vuex была построена «в лоб»: модули привязаны к страницам, которые в свою очередь привязаны к роутам. Выглядит все так, будто компонент списка статей использует локальный стейт и теряет его на destroy. Причем все модули «одноразовые» — каждый следующий заход на страницу переписывал модуль целиком:

ArticlesList: [ , ...
],
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 при рендере страницы поста посредством мержа в него расширенных данных.

При переходе на страницу публикации, мы достаем уже имеющуюся дату из ArticlesList, делаем запрос на получение недостающих данных и просто добавляем их к существующему объекту. Вывод списка статей стал также более прозрачным: компонент-итератор перебирает массив с айдишниками статей и отрисовывает компонент тизера статьи, передавая Id в качестве пропса, а дочерний компонент в свою очередь достает нужные данные из ArticlesList.

Как я писал выше, такой подход бережнее по отношению к загружаемым данным и позволяет переиспользовать их. Почему этот подход лучше? Например, поллинг и подгрузка статей в ленту по мере их появления. Но помимо этого он открывает дорогу некоторым новым возможностям, которые отлично вписываются в такую архитектуру. При нажатии на кнопку «Показать новые публикации», мы просто вставим новые Id в начало массива текущего списка статей и все будет работать почти магическим образом. Мы можем просто сложить свежие посты в «хранилище» ArticlesList, сохранить отдельный список новых айдишников в ArticlesIds и уведомить пользователя об этом.

Делаем загрузку приятнее

Вишенкой на торте рефакторинга стала концепция скелетонов, которая делает процесс загрузки контента на медленном интернете чуть менее отвратительным. Никаких дискуссий на этот счет не было, путь от идеи до прототипа занял буквально два часа. Дизайн нарисовался практически сам, и мы научили наши компоненты рендерить незатейливые, едва-мерцающие div-блоки во время ожидания данных. Субъективно такой подход к лоадингу действительно уменьшает количество гормонов стресса в организме пользователя. Скелетон выглядит так:

image
Хабралоадинг

Рефлексируем

Я полгода работаю в Хабре и знакомые по-прежнему спрашивают: ну что, как тебе там? Хорошо, комфортно — да. Но есть кое-что, что отличает эту работу от других. Я работал в командах, которые были абсолютно равнодушны к своему продукту, не знали и не понимали, кто их пользователи. А здесь все по-другому. Тут чувствуешь ответственность за то, что делаешь. В процессе разработки фичи, ты частично становишься ее оунером, принимаешь участие во всех продуктовых встречах, связанных с твоим функционалом, вносишь предложения и сам принимаешь решения. Делать продукт, которым ежедневно пользуешься сам, очень круто, а писать код для людей, которые, возможно, разбираются в этом лучше тебя — просто невероятное ощущение (no sarcasm).

Это вдохновляет. После релиза всех этих изменений мы получили позитивный фидбэк, и это было очень и очень приятно. Пишите еще. Спасибо!

«Двухнодная» архитектура уже добралась до релиза в виде публичного бета-тестирования. Напомню, что после глобальных переменных мы решились на смену архитектуры и выделение проксирующего слоя в отдельный инстанс. На сегодня все. Сейчас любой желающий может переключиться на нее и помочь нам сделать мобильный Хабр лучше. С удовольствием отвечу на все ваши вопросы в комментариях.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть