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

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

Мені завжди було цікаво, як влаштований Хабр зсередини, як побудований 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 циклічні структури (циркулярний довідник); вирішувалося простою заміною таких структур з їхньої плоскі аналоги.

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

Мінімізуємо перемальовки

Як видно зі схеми вище, в нашому випадку один інстанс Node JS виконує дві функції: SSR і проксі в API, де саме відбувається авторизація користувача. Ця обставина унеможливлює авторизацію в момент виконання JS-коду на сервері, оскільки нода однопоточна, а функція SSR синхронна. Тобто сервер просто не може надсилати запити сам на себе, поки коллстек чимось зайнятий. Вийшло так, що state ми прокинули, але інтерфейс не переставав смикатися, так як дані на клієнті слід було оновити з урахуванням сесії користувача. Потрібно було навчити наш додаток класти в initial state правильні дані з урахуванням логіну користувача.

Вирішень проблеми знайшлося всього два:

  • чіпляти авторизаційні дані до міжсерверних запитів;
  • розбити шари Node JS у дві окремі інстанси.

Перше рішення вимагало використовувати глобальні змінні на сервері, а друге розтягувало терміни реалізації завдання щонайменше на місяць.

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

Наслідуючи цю логіку і своє власне бажання якнайшвидше вирішити проблему, я вибрав глобальні змінні. І, як це часто трапляється, за них рано чи пізно доводиться платити. Ми заплатили майже одразу: попрацювали у вихідні, розгребли наслідки, написали посмертний та почали ділити сервер на дві частини. Помилка була дуже дурною, а баг за її участю відтворювався непросто. І так, за таке соромно, але так чи інакше, спотикаючись і крекчучи, мій PoC із глобальними змінними все ж таки вийшов у продакшн і цілком успішно працює в очікуванні переїзду на нову «двохрічну» архітектуру. Це був важливий крок, адже формально мети було досягнуто — SSR навчився віддавати повністю готову до використання сторінку, а UI став набагато спокійнішим.

Логи фронтенд-розробника Хабра: рефакторим та рефлексуємоІнтерфейс мобільного Хабра після першого етапу рефакторингу

Зрештою архітектура SSR-CSR мобільної версії веде ось до такої картини:

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

Виключаємо дублі запитів

Після виконаних маніпуляцій, початковий рендер сторінки перестав провокувати епілепсію. Але подальше використання Хабра в режимі SPA все ще викликало подив.

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

Логи фронтенд-розробника Хабра: рефакторим та рефлексуємоПовернення до стрічки постів провокує новий запит даних

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

ArticlesList: [
  { Article1 },
  ...
],
PageArticle: { ArticleFull1 },

Отже, у нас був модуль ArticlesList, який містить у собі об'єкти типу Стаття та модуль PageArticle, який був розширеною версією об'єкту Стаття, свого роду ArticleFull. За великим рахунком, ця реалізація нічого страшного в собі не несе — це дуже просто, можна навіть сказати наївно, але цілком зрозуміло. Якщо випилити обнулення модуля при кожній зміні роуту, то можна навіть жити. Проте перехід між стрічками статей, наприклад /feed → /all, гарантовано викине все, що пов'язано з персональною стрічкою, тому що у нас лише один ArticlesList, до якого потрібно покласти нові дані. Це знову призводить до дублювання запитів.

Зібравши до купи все, що вдалося розкопати на тему, я сформулював нову структуру стейту і представив її колегам. Обговорення були тривалими, але в результаті аргументи за переважили сумніви, і я приступив до реалізації.

Логіка рішення найкраще розкривається за два етапи. Спочатку ми намагаємося відв'язати модуль Vuex від сторінок та прив'язати безпосередньо до роутів. Так, даних у сторі стане трохи більше, гетери стануть трохи складнішими, але ми не вантажитимемо статті по два рази. Для мобільної версії це, мабуть, найсильніший аргумент. Вийде приблизно так:

ArticlesList: {
  ROUTE_FEED: [ 
    { Article1 },
    ...
  ],
  ROUTE_ALL: [ 
    { Article2 },
    ...
  ],
}

Але якщо списки статей можуть перетинатися між кількома роутами і що, якщо ми хочемо перевикористовувати дані об'єкта Стаття для відображення сторінки посту, перетворивши його на ArticleFull? У цьому випадку більш логічним було б використання такої структури:

ArticlesIds: {
  ROUTE_FEED: [ '1', ... ],
  ROUTE_ALL: [ '1', '2', ... ],
},
ArticlesList: {
  '1': { Article1 }, 
  '2': { Article2 },
  ...
}

ArticlesList тут — це просто сховище статей. Всі статі, які були завантажені протягом користувальницької сесії. Ми ставимося до них максимально дбайливо, адже це трафік, який, можливо, був завантажений через біль десь у метро між станціями, і ми абсолютно точно не хочемо заподіяти користувачеві цей біль знову, змусивши його вантажити дані, які він уже завантажив. Об'єкт ArticlesIds є просто масивом айдішників (як би «посилань») на об'єкти Стаття. Така структура дозволяє не дублювати загальні для роутів дані та перевикористовувати об'єкт Стаття при рендері сторінки посту за допомогою мержа до нього розширених даних.

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

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

Робимо завантаження приємніше

Вишнею на торті рефакторингу стала концепція скелетонів, яка робить процес завантаження контенту на повільному інтернеті трохи менш огидним. Жодних дискусій з цього приводу не було, шлях від ідеї до прототипу зайняв буквально дві години. Дизайн намалювався практично сам, і ми навчили наші компоненти рендерити невигадливі, ледь мерехтливі div-блоки під час очікування даних. Суб'єктивно такий підхід до лоадінг дійсно зменшує кількість гормонів стресу в організмі користувача. Скелетон виглядає так:

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

Рефлексуємо

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

Після релізу всіх цих змін ми отримали позитивний фідбек, і це було дуже приємно. Це надихає. Дякую! Пишіть ще.

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

Джерело: habr.com

Додати коментар або відгук