Pull to refresh
113.48

Вечный RnD: chunk flushing для серверного рендеринга React + WMF

Level of difficultyHard
Reading time9 min
Views2.1K

Все говорят про webpack-module-federation - микрофронты тут, микрофронты там.
— "А мы уже внедрили", "а мы уже построили микрофронтовую-архитектуру", "мы релизим независимо".

Но начинаешь расспрашивать, "а что сделали", "а как связали" - выходит что за всеми этими броскими фасадами скрывалось добавление вызова ModuleFederationPlugin(...) на уровне рядового потребителя, в лучшем случае с подстройкой конфигурации под локальный и продуктовый стенды. А независимые релизы - обычный авто деплой trunk'а.

На этом фоне доклады, повествующие о динамическом развертывании k8s pod'ов с версиями микрофронтов, указанных в заголовках браузера (на базе argo-cd) производят вау-эффект. Но даже все эти истории имеют один общий недостаток.

На вопрос:
— А как вы реализовали SSR?

Следует ответ:
— Мы не стали этого делать, у нас админка / дэшборд / MVP / *.

Два самых сложных аспекта WMF - это оркестрация версий и Server Side Rendering.
Оркестрация сводится к архитектуре, инструментам и процессам, в то время как SSR - задача прикладного характера. Для не погруженных в контекст микрофронтов может показаться, что сложность SSR кроется в исполнении кода по http. Но это не так - настроить асинхронную подгрузку кода можно в 10 строк. В react-18 эта задача и вовсе выполняется в рамках коробочного API.

Самой сложной задачей является так называемый chunk flushing - вывод в html script-/link-тегов с асинхронными кусочками кода, необходимого для гидрации. А WMF - не более чем дополнительная нагрузка и так непростой задачи.

Ресерч против прокрастинации

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

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

Так как же с пользой занять оставшееся время? Ответ тех же гайдов - заняться любимой задачей. А что может быть прекраснее хардкорного R&D - даже код писать не надо.

Заклятый соперник, предыстория

В прошлом году, настроенный сдвинуть с мертвой точки мировое сообщество в вопросе серверного рендеринга микрофронтов, я посвятил немногим больше месяца на исследование WMF в совокупности с инструментами, позволяющими реализовать серверный рендеринг.

На выходе исследование дало множество побочных продуктов:

  • направление, в котором нужно подталкивать нашу enterprise-архитектуру (что без NodeJS в качестве базы для серверного рендеринга - мы никогда не заведем ни микрофронты, ни даже React-18. У нас до этого использовалась связка .NET -> React.NET -> V8);

  • прототип полностью автоматизированных микрофронтов - о нем я расскажу подробнее на HolyJS 2023 Spring;

  • и конечно же самое ценное - знания. Сюда можно отнести:

    • детальное представление о том, как работает WMF;

    • базовое представление о том, что происходит в недрах webpack'а;

    • Работа с open-source пакетом @loadable/* открыла новые возможности по мета-программирования - изменение AST babel-плагином, подписка на webpack compilation для формирования карты зависимостей;

Однако ожидаемого финального результата это исследование так и не принесло. Мы так и не научились собирать js-/css- chunk'и при серверном рендеринге, асинхронно подгружаемых модулей.

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

У самурая нет цели, есть только путь

Одним из таких микро-исследований стало погружение в react-18 suspense API for SSR. С одной стороны очевидно, что надо двигаться в сторону обновлений - зачем строить что-то для старой версии библиотеки? С другой, как отметили в комментариях одного из issue @loadable, сore-команда React больше не рекомендует loadable для SSR, в react-18 Suspense работает из коробки.

React удаляет рекомендацию использовать loadable-components для CodeSplitting SSR в react-18
React удаляет рекомендацию использовать loadable-components для CodeSplitting SSR в react-18

Другое микроисследование привело меня к документам от reactwg (react working group). В которых описывается алгоритм SSR для React-18 для разработчиков "meta-фреймворков". https://github.com/reactwg/react-18/discussions/108

Из этой статьи можно извлечь одну важную деталь: команда react'а не дает ответа на вопрос о том, как правильно реализовывать самую сложную часть SSR - chunk flushing'а. То есть привязки отрендеренных кусочков html к тем самым js-/css- chunk'ам, необходимым для hydration на клиенте. Вместо этого они предоставляют template, в котором указывают где нужно совершать flush'ing.

Еще одно микроисследование привело меня к ReactFlight:

Что такое ReactFlight? Мне до сих пор сложно ответить на этот вопрос, но я бы назвал это неймспейсом для маркировки эксперементальных наработок в области стримингового рендеринга, созданный в качестве маяка для мета-фреймворков.

Можно ли просто взять и использовать ReactFlight для решения chunk flushing'а? Определенно нет. Но и nextjs, и gatsby в своих исходниках копируют код плагинов ReactFlight, модифицируя их под себя, оставляя ссылки в формате "inspired by ReactFlightWebpackPlugin".

Еще одно исследование привело меня к статье, которую писал настоящий гений. Сколько бы времени я не потратил на изучение механизмов сбора chunk'ов, на анализ дерева зависимостей, в этой статье я подчеркнул еще столько же нового. Статья о том, как подобная задача решается в gatsby: https://www.gatsbyjs.com/docs/how-code-splitting-works/.

Может использовать готовое решение

Эта мысль не раз посещала меня, а наткнувшись на статью gatsby в которой так детально описано решение, этот вопрос поднялся в полную силу. Однако немного подумав - я понял, почему как бы сильно не хотелось взять и использовать nextjs или gatsby - этого делать нельзя.

Ответ прост: нельзя писать фреймворк на базе другого и без того раздутого фреймворка. Еще конкретнее: нельзя писать узко-специализированную библиотеку на базе фреймворка. Ведь любое решение, в том числе chunk flushing'а, в этих фреймворках сильно связано с особенностями их структуры.

Одной из важных черт практически всех фреймворков в мире разработки - то, что им недостаточно вашего кода, вместе с кодом они забирают душу вашего проекта - фиксированная файловая структура, особые имена файлов, обрабатываемые в процессе инициализации. Фреймворк "гвоздями" прибивает ваш проект к файловой структуре и к самому себе. Эта особенность приятно дополняет неявный характер комбайна, перемалывающего в процессе решения своей задачи все на своем пути, но она же делает невозможным превратить фреймворк или его часть в бибилотеку для решения узко-специализированной задачи.

Кстати, вероятно одной из причин, почему многие называют React библиотекой - состоит как раз в том, что React не навязывает определенную структуру приложения. Каждый может писать на нем как хочет или даже написать свой фреймворк поверх React.

Второй проблемой всех существующих решений - является обертка над асинхронными импортами:

  • nextjs - использует у себя dynamic из react-loadable;

  • react-universal-component - использует universal;

  • loadable - использует одноименную loadable обертку;

  • gatsby - одна из самых интересных реализаций, они генерируют entry-point'ы, с простановкой магических комментариев webpack-а - /** webpackChunkName **/

И тут поднимается вопрос, зачем? Чуйка подсказывает, что раз react-dom может делать асинхронный рендеринг кусочками без костылей, то узнать из какого модуля был вызов, а потом сопоставить клиентскую и серверную сборку не должно составить особого труда. У нас же есть статистика webpack-сборки. В ней можно найти абсолютно всю информацию о chunk'ах и зависимостях. Либо нужно найти доказательство обратного - что по статистике webpack'а невозможно склеить клиентский и серверный бандлы.

Webpack-шрёдингера

Можно ли одновременно идеально знать webpack и не знать его вовсе?

Как оказалось можно.

Собеседуя senior-разработчиков, для определения уровня их владения инструментами (bundlers, transpilers, post-processors, compilers), я люблю углубляться в вопросы работы webpack. От типовых вопросов по API, до отличий конкретных лоадеров, плагинов, и их внутренней работе. Сам я могу не глядя перечислить до 80% ключей опций webpack'а, а также многие десятки плагинов и loader'ов. Поэтому всегда считал, что знаю webpack в совершенстве. Знаю даже парочку вещей о внутреннем устройстве вебпака - плагины пишутся через tap'ы на отдельные триггеры этапов сборки, там можно сделать много чего интересного. Хотя сам я как-то до создания своих плагинов не доходил.

Исходный код webpack'а же я всегда представлял как огромную помойку из шаблонов, в которых генерируются конструкции кода по результату исполнения. Связано это представление с тем, что я не раз заглядывал в те самые исходники внутри node_modules, но найти что либо путное в них каждый раз не мог, зато через раз натыкался на подобные конструкции:

И вот одним вечером пятницы за три часа до окончания рабочего дня я поставил себе новую цель ресерча - разобраться с тем, какие есть этапы компиляции в webpack, к которым можно привязаться при создании своего плагина, реализующего chunk-flushing.

С документацией в этой области всегда были проблемы, поэтому пришлось начать с интернет-сёрфа, который к моему удивлению привел к странице документации webpack: https://webpack.js.org/api/compilation-hooks/

От обилия хуков, перечисленных на ней закружилась голова - да как же так, ведь все гайды написания плагинов всегда упирались в 1-2 типовых хука, какой-нибудь compilation.hooks.processAssets. А ведь каждый хук - это этап жизненного цикла webpack-сборки. А что я собственно знаю про жизненный цикл webpack? Парсим, обрабатываем, оптимизируем, выводим - не густо.

Сёрф плавно перешел в изучения устройства compilation, откуда я вышел на упоминание о некой очень классной deep dive презентации Johannes Ewald. Поискав еще немного наткнулся на саму презентацию из 123 слайдов: https://peerigon.github.io/talks/2018-09-28-hackerkiste-webpack-deep-dive/#1

Запись искать не стал, времени мало, но по слайдам самой презентации прекрасно укладываются в голове и особенности реализации и то, что на самом деле webpack это сотня, нет, сотнИ плагинов, основанных на tappable, собирающихся в единый механизм десятками if/else внутри простого маппера конфигурации:

lib/WebpackOptionsApply.js
lib/WebpackOptionsApply.js

Если же говорить про шаги выполнения, то можно вынести вот такие:

  • Compiler - точка входа, конфигурирование, инициализация;

  • Compilation - запущенный процесс сборки, на этом этапе происходит основная магия - loader'ы, обработка импортов через enhanced-resolve, построение дерева сборки, оптимизация.

  • Parser - после компиляции создается AST на базе ACORN, на этом этапе выполняются более низкоуровневые операции, замены, построение пути исполнения для поиска неиспользованных модулей

  • Template - тот самый механизм, который мне так не понравился, однако это лишь малая часть webpack'а, когда структура бандла уже полностью ясна, осталось склеить все во едино, используя механизмы довольно топорной шаблонизации. Например, нужно исполнить код chunk'а только после загрузки 5 других shared-chunk'ов? Не вопрос, Promise.all([...]).then(() => ...)

Так что я собственно знаю про webpack? Выходит, что ничего. Мне нужно построить нечто сложное на базе инструмента, а я даже не знаю какие хуки за что отвечают и на каком этапе исполняются. За пару часов погружения у меня сформировались только первые базовые представления о том, что и как происходит под капотом. Тут нужно детальное изучение, построение огромных блок схем жизненного цикла.

Но есть решение попроще - паразитировать на существующих решениях.

Берем все библиотеки так или иначе реализующие chunk flushing и находим по ключевому слову "tap" все хуки webpack'а на которых они основаны. Получаем не очень длинный список, с которым можно работать:

  • compiler.hooks.thisCompilation - простая обертка для того, чтобы получить объект compilation;

  • compilation.hooks.processAssets - всегда в совокупности со (stage: PROCESS_ASSETS_STAGE_REPORT). Как говорилось в комментариях к коду одной из рассматриваемых библиотек - это лучший этап для внесения финальных изменений в webpack stats;

  • compiler.hooks.beforeCompile - использовался для "лечения" проблем с HMR, когда при пересборке изменяются только часть файлов и статистика не полная, тут просто записывали старый манифест, поверх которого будут накладываться изменения;

  • compiler.hooks.finishMake, compiler.hooks.afterCompile, compiler.hooks.done - не понятно, чем отличаютс, но используются для выводов манифест-файлов, тут уже ничего не меняется;

  • compilation.hooks.optimizeChunks - привязка дополнительного css к chunk’ам, видимо если сделать это раньше, то chunk'и будут удалены как dead code;

  • parser.hooks.program.tap - На текущий момент совершенно непонятный мне хук. Используется в ReactFlightWebpackPlugin для добавения дополнительных зависимостей на client reference. Судя по неймингу program - вероятно это самый верхний уровень при прохождении по AST файла.

И вот три часа подошли к концу. Что я узнал за это время? Да то, что я ничего не знаю и впереди у меня еще многие недели исследований. По крайней мере начинает складываться общая картина, в голове рождаются правильные вопросы.

Если вам показалось, что то, что здесь написано - довольно сумбурно, то вы несомненно правы, ведь это то, на каком уровне новые знания укладываются в голове после первых шагов RnD. Еще пока сложно что-то с уверенностью утверждать, но со временем и практикой, эти знания могут перерасти в deep-dive доклад или новый плагин для webpack'а, нацеленный на решение проблемы chunk flushing'а.

А лучше, как мне кажется, оба.

Заключение

С момент имульсивного написания моей первой статьи прошли сутки. Основной вопрос, крутившийся в моей голове все это время - а что, собственно, я хотел донести до читателя данным повествованием? Какова основная мысль? Ведь две основные вещи в хорошем докладе - это структура и основная мысль.

Основных мысли и задачи как минимум две:

  • показать, что происходит в голове при погружении в сложные RnD, как перепрыгивая с одних гипотез на другие формируются и укрепляются знания;

  • раскрыть и разложить по полочкам плохо документированную информацию о внутреннем устройстве webpack'а, механизмах chunk flushing'а и проблемах микрофронтов;

Обе данные мысли имеют и обратную сторону.

  • зачем выносить глубины больного разума на публичный суд, кому это может быть интересно? Вероятно чтобы найти единомышленников. Тех, кто найдет в этом описании знакомые ему черты;

  • а второй причиной для разложения знаний по полочкам является их лучшее усвоение. Обычно эта процедура проходит в бесчисленных схемах Miro, но иногда полезно экспериментировать со способами.

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

Подробнее об этом - в продолжении.

Tags:
Hubs:
Total votes 15: ↑14 and ↓1+13
Comments11

Articles

Information

Website
beeline.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия