-Ретроспектива рефакторинга веб-фронтенда Banjang: переопределение единиц развёртывания вокруг функций-
1. Введение — приложение, объединённое в одно целое
Веб-фронтенд Banjang долгое время работал как единое приложение под названием episode-banjang. В него входили различные функции — главная страница, вакансии, команда, мой профиль, уведомления, аутентификация, биллинг и другие — в рамках одного артефакта сборки, а способ различать пользователей также был простым. Под корневым маршрутизатором находились отдельные маршрутизаторы для работников и управляющих объектами, а роли разделялись префиксами URL.
// AS-IS: pages/worker/router.tsx
export const router: RouteObject = {
path: 'wkr', // 작업자(worker)는 /wkr/* 로
element: (
<MobileLayout role="worker" ...>
<Outlet />
</MobileLayout>
),
children: [...],
};
// AS-IS: pages/leader/router.tsx — 구조는 동일
export const router: RouteObject = {
path: 'ldr', // 현장 관리자(leader)는 /ldr/* 로
...
};
Префиксы URL (wkr/ldr) указывали на роли, и список экранов зависел от роли. Сами функции работали надёжно, но по мере роста сервиса начали проявляться ограничения в вопросе «какой код следует развёртывать вместе?». Отправной точкой этого рефакторинга стало не изменение маршрутизации, а переопределение единиц развёртывания.
2. Почему мы разделили их — согласование единиц развёртывания с функциями
В едином приложении все изменения функций включаются в один релиз. Даже небольшое изменение экрана вакансий требует пересборки и развёртывания всего приложения, а код экранов, не связанных с ролью пользователя, включается в тот же пакет. На мобильных устройствах также не было иного выбора, кроме как запускать одно приложение WebView через React Native Shell, что затрудняло применение таких стратегий, как «сначала запускать главную страницу, а остальное загружать по мере необходимости».
Поэтому мы сместили критерий с ролей на функции. Основной принцип прост: Разделять единицы развёртывания по функциям, а роли обрабатывать внутри каждой функции.
|
Эпизод |
Владелец |
|---|---|
|
episode-banjang-home |
Главная |
|
episode-banjang-job |
Вакансии |
|
episode-banjang-team |
Команда |
|
episode-banjang-chat |
Диалоги (Loopin) |
|
episode-banjang-my |
Мой профиль |
Главная, вакансии, команда, диалоги и мой профиль на нижней панели вкладок мобильного приложения соответствуют веб-эпизодам в соотношении 1:1. Административная часть (episode-banjang-admin) поддерживается как отдельная единица развёртывания веб-бэкофиса, независимо от нижней панели вкладок пользовательского приложения. Это позволяет согласовать группировку экранов и группировку развёртываний.
3. Новые границы — Shell и эпизоды
Структура репозитория также организуется в соответствии с разделением на эпизоды. Подробные определения каждой единицы приведены в общей архитектурной документации компании; здесь мы рассмотрим только границы, необходимые для понимания рефакторинга.
shell/
banjang-mobile-shell # RN 호스트. WebView, 네이티브 인증, 세션, 에피소드 전환 담당
banjang-episode-shell # 웹 에피소드들이 공유하는 UI·브라우저 런타임
shared/
banjang-bridge-contracts # Shell <-> Episode 메시지 계약
banjang-constants # 공통 상수
episodes/
episode-banjang-{home,job,team,my,chat} # + episode-banjang-admin
dramas/
banjang-view / banjang-state / banjangnote-stub
Основное правило заключается в том, что эпизод — это Microapp, которая независимо собирается и развёртывается. Она не импортирует исходный код других эпизодов напрямую, а поведение, необходимое нескольким эпизодам, предоставляется через Shell или общие слои. Ранее вопрос «что является общим и где начинается функция?» зависел от консенсуса команды, но после разделения структура папок и границы импорта гораздо яснее отражают этот принцип.
4. Обработка ролей — от оси URL к выбору экранов
Даже если существующая маршрутизация с префиксами /wkr, /ldr исчезнет, различие между ролями не исчезнет. Роли переходят из разряда оси маршрутизации верхнего уровня в критерий выбора экранов внутри каждого эпизода. У каждого эпизода есть один маршрутизатор, а текущая роль определяется через BanjangContext.displayType.
// TO-BE: shell/banjang-episode-shell/src/routing/BanjangRoleRoute.tsx
export const BanjangRoleRoute = ({ allowedRoles, redirectTo }: BanjangRoleRouteProps) => {
const { displayType } = useBanjangContext();
const roles = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
return roles.includes(displayType) ? <Outlet /> : <Navigate to={redirectTo} replace />;
};
Контроль доступа выполняется с помощью защитников маршрутов, а если начальный экран различается в зависимости от роли, отображаемый Scene выбирается внутри того же маршрутизатора.
// TO-BE: episodes/episode-banjang-job/src/pages/router.tsx
const JobRoleIndex = () => {
const { displayType } = useBanjangContext();
const selectedRoutes = displayType === 'leader' ? recruitmentRoutes : jobsRoutes;
return selectedRoutes.children?.find((route) => route.index)?.element ?? null;
};
Например, в эпизоде вакансий управление набором сотрудников (recruitment) отображается управляющим объектами как индексная страница, а список вакансий (jobs) — работникам. Структура URL становится проще: вместо управления вопросом «к какому набору URL должен перейти пользователь при изменении его роли?» система объявляет, «каким ролям разрешён доступ к этому экрану». После разделения двух осей — единиц развёртывания и ролей — добавление роли сводится к определению роли и выбору экранов, а не к дублированию маршрутизаторов.
5. Ответственный за переходы — Shell и колода keep-alive
После появления нескольких эпизодов необходимо чётко определить, кто отвечает за переходы. В Banjang веб-эпизоды не выполняют переходы напрямую; они лишь запрашивают пункт назначения. Мобильный Shell принимает решение и выполняет переход. В веб-версии он отправляет сообщение моста REQUEST_EPISODE_NAVIGATIONи оборачивает его в navigateBanjangEpisode. Запрос содержит целевой эпизод и путь, по которому нужно перейти внутри него.
Политика загрузки мобильного Shell отдаёт приоритет главной странице.
-
При запуске приложения он создаёт только WebView главной страницы.
-
Остальные вкладки создаются по одному разу при первом выборе каждой из них.
-
После создания WebView не удаляется; при переходе между ними переключается только активный слой (keep-alive).
Поэтому вначале одновременно присутствуют не все эпизоды: в наборе WebView остаются только те эпизоды, которые действительно были открыты. Сигналы готовности также разделены на WEB_APP_READY и EPISODE_SCREEN_READY. Первый указывает на готовность обмена данными через bridge, а второй — на завершение первой отрисовки данных на Home. Разделение состояний загрузки вместо сведения их к одному этапу позволяет четко понимать, чего именно ожидает Shell и когда.
6. Стратегия загрузки — включая опробованные и отмененные подходы
Вопросы производительности, возникшие в начале разделения, не были расплывчатыми гипотезами. В среде разработки запуск полного набора эпизодов занимал почти три минуты только на старт, поэтому медленный запуск был очень заметен. На период рефакторинга другие работы над frontend были временно приостановлены, чтобы избежать конфликтов, поэтому приоритетом было быстрое запуск среды разработки.
Мы сравнили последовательную загрузку, предварительную загрузку и создание эпизода при первом выборе его вкладки — именно в таком порядке. В результате мы остановились на описанной ранее политике: Home в первую очередь + отложенное создание + keep-alive. В том же контексте мы также экспериментировали с заменой корневых barrel-импортов на глубокие импорты на уровне файлов.
Мы повторяли проверку в течение нескольких дней. Мы сравнивали HAR начальной загрузки для каждого эпизода и отслеживали этапы перехода с помощью [banjang-episode-timing] в консоли. Мы также использовали EXPO_PUBLIC_BENCHMARK_EPISODE для запуска только определенного эпизода и уменьшения искажений измерений. Хотя это не было формальным стендом для бенчмаркинга, такого подхода было достаточно в качестве ориентировочной шкалы для сравнения направлений в одинаковых условиях.
Наблюдения показали, что преимущества повсеместного изменения соглашения об импортах несущественны. Поэтому мы сохранили принятую в компании конвенцию barrel-импортов и отменили глубокие импорты, оставив импорты из подкаталогов только в нескольких местах, где они были явно необходимы. Важным было не само изменение, а подтверждение того, какие решения действительно дали результат в нашей среде.
Отмененная работа также является частью результата. Мы применили и проверили в реальном коде предположение о том, что «barrel работает медленно», и оставили свидетельства того, что разница в нашей среде недостаточно велика, чтобы оправдать изменение соглашения. Даже если этот вопрос возникнет снова, мы сможем использовать наблюдения и причины, лежавшие в основе тогдашнего решения, как отправную точку вместо того, чтобы начинать со спекуляций.
7. Цена разделения и ее окупаемость
Разделение структуры увеличивает не только получаемые преимущества, но и объем того, чем необходимо управлять. В этом рефакторинге стоимость также была очевидна.
-
Поскольку количество исполняемых единиц увеличивается, локально необходимо одновременно работать с несколькими серверами Vite и шлюзами, а конвейеры сборки и развертывания также становятся объектами управления в соответствии с количеством эпизодов.
-
Сначала мы отдали приоритет развертыванию базовой среды, а не упорядочиванию общего кода, чтобы ответственные за задачи могли немедленно приступить к работе. В процессе сквозные компоненты, такие как аутентификация, темы, работа с сетью и FCM, были временно продублированы в эпизодах. Это была запланированная временная мера с намерением позднее объединить их.
-
Границы отладки также расширяются. Сначала необходимо определить, связана ли проблема с Shell, конкретным эпизодом либо со стороной отправки или получения данных через bridge.
Слоем, который компенсирует эти затраты, является Episode Shell. Маршруты аутентификации генерируются в соответствии с границей каждого эпизода с помощью createAuthRoutes({ episodePath }), вход в gate обрабатывается через BanjangAppGate, а переключение ролей выполняется с помощью useRoleSelection. Работа с сетью, офлайн-резервные сценарии и синхронизация сессии также перенесены в этот общий слой. После стабилизации основы для работы ответственных за задачи мы привели в порядок изначально дублировавшийся шаблонный код, чтобы точка входа каждого эпизода сохраняла экраны своего домена в качестве главного фокуса.
Эффект этой структуры становится очевиден при добавлении нового экрана. Поскольку общая связующая логика уже находится в Shell, новый код может сосредоточиться на домене. Цена изоляции поначалу может выглядеть расходом, но ее компенсация через общий слой создает основу, которая снижает повторяющиеся затраты в последующей работе.
8. Опыт разработчиков — организация не только кода, но и способов запуска
По мере увеличения количества единиц развертывания мы также разделили локальные команды разработки по назначению. Вместо того чтобы всегда запускать все эпизоды вместе, мы организовали их так, чтобы можно было запускать только область, необходимую для конкретной задачи.
-
pnpm start:banjang-local-single [home|job|team|my]: локально запускает только изменяемый эпизод и использует общую среду разработки для остальных. Эта команда позволяет сохранить небольшим охват, необходимый для повседневной разработки функциональности.
-
pnpm start:banjang-local-all: запускает все эпизоды вместе со шлюзом для проверки интегрированного взаимодействия между Shell и bridge. В браузере поток навигации также можно воспроизвести с помощью набора iframe, следующего политике keep-alive.
-
pnpm build:pkg home, pnpm storybook:pkg view: выполнение на уровне пакета объединено с run-pkg.mjs, чтобы стандартизировать формат команд.
9. Заключение
Главным изменением в этом рефакторинге была не форма маршрутизатора, а критерий того, «что следует считать одной единицей развертывания». Вместо структуры, разделявшей приложение по ролям, мы сделали эпизоды, основанные на функциональности, независимыми единицами развертывания, а роли перенесли на уровень выбора экранов внутри них. Мобильный Shell обрабатывает переходы между эпизодами и политики загрузки, а Episode Shell объединяет общую связующую логику, необходимую нескольким веб-эпизодам.
В конечном счете архитектурные единицы определяются не только папками с кодом. Именно единицы развертывания определяют пользовательский опыт и скорость работы организации. Если основные границы сервисов — Home, Job, Team, Conversations и My — стабильны, единицы развертывания, соответствующие этим границам, также можно использовать длительное время. Эта работа была не столько рефакторингом, отвергавшим хорошо работающую структуру, сколько попыткой заново провести границы для следующего этапа разработки и развертывания.
Границы пользовательского опыта, которые меняются нечасто, например нижний навигатор в мобильном приложении, могут стать хорошей основой для определения границ развертывания. Пока пять функций — Home, Job, Team, Conversations и My — остаются неизменными, каждый эпизод может продолжать служить независимой единицей изменений и развертывания. Дольше самого результата разделения структуры сохраняется критерий, объясняющий, почему были выбраны именно эти границы.
Hazel