Архитектура фронтенда: слои, монорепо, микрофронтенды, деплой и мониторинг
В предыдущей статье мы выбирали фреймворк. Это решение кажется главным ровно до момента, когда проект переваливает за сотню файлов и трёх разработчиков. Дальше выясняется неприятное: скорость команды определяется не тем, React у вас или Svelte, а тем, сколько файлов приходится открыть, чтобы добавить одно поле в форму, и сколько людей нужно предупредить, чтобы выкатить это в прод.
Архитектура — это набор решений, которые дорого менять потом. Выбор библиотеки для дат меняется за вечер. Направление зависимостей между модулями, схема репозитория, формат артефакта деплоя и способ узнавать об ошибках — не меняются годами и определяют, будет ли через два года «легко добавить фичу» или «страшно трогать».
Ключевая мысль всей статьи: фронтенд-архитектура — это в первую очередь про границы, а не про технологии. Границы внутри кода (что от чего может зависеть), границы между командами (кто что релизит), границы между версиями (что уже в проде, а что ещё нет). Всё остальное — инструменты, которые эти границы либо поддерживают, либо тихо размывают.
Три вопроса, на которые отвечает архитектура фронтенда
Любое архитектурное обсуждение фронтенда сводится к трём вопросам. Полезно держать их отдельно — их часто смешивают и получают спор ни о чём.
- Где живёт код и что от чего зависит. Слои, модули, публичные API, правило зависимостей. Область ответственности — читаемость и стоимость изменения.
- Как код превращается в то, что видит пользователь. Репозиторий, сборка, артефакт, деплой, откат. Область ответственности — скорость и безопасность релиза.
- Как мы узнаём, что оно работает. Ошибки, метрики, трассировка, SLO. Область ответственности — время до обнаружения проблемы.
Микрофронтенды — это ответ на второй вопрос, который многие пытаются применить как ответ на первый. Отсюда большинство провалов: команда из шести человек режет монолит на пять независимо деплоящихся приложений, потому что «модульность», и получает пять раз ту же работу без единого выигрыша в независимости релизов.
Общая теория границ и связности — в треке «Принципы разработки» и в «Архитектурных паттернах». Здесь — только фронтенд-специфика.
Слой первый: границы внутри одного приложения
Почему components/ hooks/ utils/ разваливается
Стартовая структура почти любого проекта — группировка по техническому типу:
src/
components/ Button.tsx, UserCard.tsx, CheckoutForm.tsx, ... (147 файлов)
hooks/ useAuth.ts, useCart.ts, useDebounce.ts, ... (38 файлов)
utils/ format.ts, api.ts, helpers.ts, misc.ts
types/ index.ts (900 строк)
На двадцати файлах это удобно. На двухстах — катастрофа, и по вполне измеримой причине. Чтобы понять фичу «оформление заказа», нужно открыть components/CheckoutForm.tsx, hooks/useCheckout.ts, utils/checkout-format.ts, types/index.ts и api/orders.ts — пять каталогов, между которыми нет ничего общего, кроме вашей памяти. Изменение фичи трогает пять мест; удаление фичи не трогает ни одного, потому что никто не решается удалять из общих файлов.
Правило, которое чинит 80 % боли, формулируется одной фразой Кента Доддса: код живёт рядом с тем, что его использует. Группируйте по фиче (по причине изменения), а не по типу файла:
src/
features/
checkout/
ui/CheckoutForm.tsx
model/use-checkout.ts
api/create-order.ts
lib/format-total.ts
index.ts <- публичный API среза
cart/
...
shared/
ui/Button.tsx
api/http.ts
Теперь «удалить фичу» = rm -rf features/checkout плюс поправить один импорт. Это и есть операционное определение хорошей модульности: стоимость удаления модуля пропорциональна его размеру, а не размеру проекта.
Правило зависимостей: граф без циклов и без «вверх»
Группировки мало. Без правила направления импортов через полгода features/cart импортирует из features/checkout, тот обратно из cart, и вы получаете цикл, который ломает tree-shaking (см. «Сборка фронтенда»), делает невозможным независимое тестирование и превращает граф модулей в клубок.
Рабочее правило ровно одно: зависимости идут только вниз по слоям и никогда вбок между срезами одного слоя. Самая распространённая его формализация во фронтенде — Feature-Sliced Design: шесть слоёв, внутри слоя — срезы (домены), внутри среза — сегменты (ui, model, api, lib, config).
провайдеры, роутер, глобальные стили"] --> PAGES["pages
экраны: склейка виджетов и фич"] PAGES --> WID["widgets
самодостаточные блоки экрана"] WID --> FEAT["features
действия пользователя: добавить, оплатить, подписаться"] FEAT --> ENT["entities
бизнес-сущности и их отображение: User, Product, Order"] ENT --> SH["shared
UI-kit, http-клиент, конфиг, утилиты — ничего про домен"] PAGES --> FEAT PAGES --> ENT WID --> FEAT WID --> ENT APP --> SH PAGES --> SH WID --> SH FEAT --> SH ENT --> SH ENT -. запрещено .-> FEAT FEAT -. запрещено .-> WID SH -. запрещено .-> ENT FEAT -. сосед по слою запрещён .-> FEAT
Смысл слоёв — не бюрократия, а предсказуемость радиуса поражения. Изменение в shared/ui/Button может задеть весь проект — поэтому туда кладут только то, что действительно универсально и стабильно. Изменение в features/checkout физически не может задеть ничего, кроме страниц, которые её подключают, — и это гарантируется линтером, а не совестью.
Честно про минусы FSD. Он даёт словарь, который экономит часы споров на код-ревью, но у него реальная цена: новичок первые две недели мучительно решает, entity это или feature; на маленьком проекте шесть слоёв — оверинжиниринг; граница feature против widget объективно размыта. Рабочий компромисс: начните с трёх слоёв — shared → features → pages (+ app), — и вводите entities/widgets только когда почувствуете конкретную боль, которую они лечат. Слои дешевле добавлять, чем удалять.
Публичный API среза
Слои без инкапсуляции — это документация, а не архитектура. Каждый срез экспортирует ровно то, что можно использовать снаружи, через index.ts:
// src/features/checkout/index.ts — единственная разрешённая точка входа
export { CheckoutForm } from './ui/CheckoutForm'
export { useCheckout } from './model/use-checkout'
export type { CheckoutState } from './model/types'
// НЕ экспортируем: create-order.ts, format-total.ts, внутренние хуки —
// это свобода менять внутренности, не спрашивая соседей
Импорт import { formatTotal } from '@/features/checkout/lib/format-total' — нарушение: сосед привязался к внутренностям, и теперь их нельзя переименовать. Дальше это правило проверяет CI.
Правила, которые проверяет машина
Договорённость, которую никто не проверяет, живёт до первого дедлайна. Есть два уровня автоматизации.
Первый — ESLint. Плоский конфиг с import/no-restricted-paths описывает запрещённые направления:
// eslint.config.js
import importPlugin from 'eslint-plugin-import'
const LAYERS = ['shared', 'entities', 'features', 'widgets', 'pages', 'app']
// слой не имеет права импортировать из любого слоя выше себя
const upwardZones = LAYERS.flatMap((layer, i) =>
LAYERS.slice(i + 1).map((upper) => ({
target: `./src/${layer}`,
from: `./src/${upper}`,
message: `${layer} не может зависеть от ${upper}: зависимости идут только вниз`,
})),
)
export default [
{
files: ['src/**/*.{ts,tsx}'],
plugins: { import: importPlugin },
rules: {
'import/no-restricted-paths': ['error', { zones: upwardZones }],
'import/no-cycle': ['error', { maxDepth: Infinity }],
},
},
]
Второй — dependency-cruiser, который умеет то, чего ESLint не умеет: запрет импортов между соседними срезами и запрет обхода публичного API. Это же ваш инструмент для «фитнес-функций» архитектуры — правил, которые ломают сборку при деградации структуры:
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: 'no-cross-slice',
comment: 'Срезы одного слоя не знают друг о друге: поднимите общее вниз, в shared/entities',
severity: 'error',
from: { path: '^src/features/([^/]+)/' },
to: { path: '^src/features/([^/]+)/', pathNot: '^src/features/$1/' },
},
{
name: 'public-api-only',
comment: 'Внутрь чужого среза можно входить только через index.ts',
severity: 'error',
from: { pathNot: '^src/features/([^/]+)/' },
to: { path: '^src/features/[^/]+/.+', pathNot: '^src/features/[^/]+/index\\.ts$' },
},
{ name: 'no-circular', severity: 'error', from: {}, to: { circular: true } },
{ name: 'no-orphans', severity: 'warn', from: { orphan: true }, to: {} },
],
options: {
tsConfig: { fileName: 'tsconfig.json' },
doNotFollow: { path: 'node_modules' },
},
}
Запуск pnpm depcruise src --config в CI занимает секунды на проекте в тысячи модулей: анализ графа импортов линеен, O(V + E) по времени и памяти. Плюс к этому depcruise --output-type dot | dot -T svg рисует реальный граф зависимостей — самое отрезвляющее изображение, которое можно показать команде.
Изоляция ввода-вывода: где кончается UI и начинается чужая система
Второй по силе источник неуправляемой связности после хаоса в папках — прямое использование формы бэкендового ответа в компонентах. Бэкенд переименовал total_price в totalAmount — и правки расползаются по сорока файлам.
Лекарство — тонкий антикоррупционный слой (термин из DDD): ровно одно место, где чужая структура превращается в вашу.
// src/entities/order/api/order.dto.ts — контракт бэкенда, отдельно от модели UI
import { z } from 'zod'
export const OrderDto = z.object({
id: z.string(),
total_price: z.number(), // snake_case и копейки — как отдаёт бэкенд
created_at: z.string().datetime(),
status: z.enum(['new', 'paid', 'shipped', 'cancelled']),
})
// src/entities/order/model/order.ts — модель, удобная интерфейсу
export type Order = {
id: string
total: Money // не число: валюта и округление внутри
createdAt: Date
status: OrderStatus
isEditable: boolean // производное свойство: считаем один раз здесь
}
export function toOrder(dto: z.infer<typeof OrderDto>): Order {
return {
id: dto.id,
total: money(dto.total_price, 'RUB'),
createdAt: new Date(dto.created_at),
status: dto.status,
isEditable: dto.status === 'new',
}
}
Три следствия, ради которых это стоит писать. Во-первых, zod-схема (или проверка сгенерированного типа) ловит расхождение контракта в момент запроса, с понятным сообщением, а не через полчаса в виде Cannot read properties of undefined в чужом компоненте. Во-вторых, компоненты работают с типами, которые придумали вы, — переименование на бэкенде меняет один файл. В-третьих, тесты и Storybook получают простой конструктор моков.
Практические уточнения, которые экономят месяцы:
- Типы из спецификации, а не руками.
openapi-typescriptдля REST,graphql-codegenдля GraphQL. Ручные типы врут — вопрос лишь в том, когда это заметят. Про стили API см. «Стили API». - Валидация — на границе, не везде. Прогонять
zodчерез каждый пропс — потеря производительности без выигрыша; типизации внутри достаточно. - BFF (backend for frontend) — когда экран собирает данные из пяти сервисов. Один HTTP-запрос вместо пяти водопадных, агрегация и обрезка полей на сервере. Это отдельный деплоймент со своим оунером — фронтенд-командой, а не «ещё один микросервис соседей».
Монорепо: что оно решает и чего не решает
Как только приложений становится больше одного (веб, админка, лендинг, общий UI-kit), встаёт вопрос о числе репозиториев.
| Критерий | Много репозиториев | Монорепо |
|---|---|---|
| Изменение, затрагивающее UI-kit и три приложения | 4 PR, релиз пакета, обновление версий, ожидание | 1 PR, атомарный, CI видит всё сразу |
| Версии зависимостей | Расползаются: три версии React в организации | Одна версия, обновляется всеми сразу |
| Настройка нового пакета | Копипаста конфигов | Наследуется из корня |
| Права и владение | Естественная граница по репозиторию | Нужен CODEOWNERS |
| Время CI | Мало, только свой код | Растёт, спасают кэш и affected-сборки |
| Порог входа для новичка | Ниже: маленький репозиторий | Выше: «где тут вообще что» |
| Независимость релизов | Есть по умолчанию | Не появляется автоматически — нужен отдельный пайплайн на приложение |
Последняя строка — главное недоразумение. Монорепо — это про совместную разработку, а не про совместный деплой. Google и Meta держат всё в одном дереве и деплоят сервисы независимо. Обратное тоже верно: пять репозиториев не дают независимости, если релиз всё равно требует согласованного выката.
pnpm workspaces + Turborepo: рабочий минимум
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
apps/
web/ приложение (Vite + React)
admin/ админка
packages/
ui/ дизайн-система: кнопки, поля, токены
api-client/ сгенерированные типы + http-обёртка
config/ eslint/tsconfig/vite пресеты
Внутренний пакет подключается как обычная зависимость с протоколом workspace::
{
"name": "@acme/web",
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/api-client": "workspace:*"
}
}
Ключевое решение здесь — как публикуются внутренние пакеты. Два подхода:
- Собирать каждый пакет (
packages/ui/dist) и подключать черезmain/exports. Плюс: приложение потребляет пакет ровно так, как внешний потребитель. Минус: нужна пересборка перед стартом, HMR через границу пакета работает хуже. - Отдавать исходники (
"exports": { ".": "./src/index.ts" }, режим internal packages). Плюс: мгновенный HMR, никакой пересборки, один тайп-чек. Минус: пакет нельзя опубликовать наружу без отдельной сборки, а сборщик приложения обязан уметь транспилировать TS изnode_modules.
Для внутренних пакетов, которые не уходят в npm, второй вариант почти всегда лучше — он снимает целый класс проблем «поменял в ui, не вижу в web».
Дальше нужен раннер задач с графом и кэшем — Turborepo или Nx:
// turbo.json (Turborepo 2.x; в 1.x ключ назывался "pipeline")
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json", "vite.config.ts"],
"outputs": ["dist/**"],
"env": ["VITE_API_URL"]
},
"typecheck": { "dependsOn": ["^build"] },
"test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
"lint": {},
"dev": { "cache": false, "persistent": true }
}
}
пресеты"] --> UI["@acme/ui
build"] CFG --> API["@acme/api-client
build"] UI --> WEB["@acme/web
build"] API --> WEB UI --> ADM["@acme/admin
build"] API --> ADM WEB --> WT["@acme/web
test"] ADM --> AT["@acme/admin
test"] WT --> DEP["deploy web"] AT --> DEPA["deploy admin"]
Что тут важно понять про кэш. Turborepo считает хэш от входов задачи: перечисленные в inputs файлы, версии зависимостей, значения переменных из env, хэши выходов пакетов-зависимостей. Совпал хэш — задача не запускается, dist/** и лог восстанавливаются из кэша за миллисекунды. Отсюда два практических следствия:
- Не перечислили переменную окружения в
env— получите катастрофу: сборка со staging-адресом API уедет в прод из кэша. Это самая дорогая ошибка при переходе на Turborepo. - Remote cache (Vercel, самохост,
--api/--token) делает то же самое между машинами: CI пересобирает только то, что реально изменилось, а разработчик локально скачивает готовыйdistсоседнего пакета вместо его сборки.
Плюс фильтры по изменённому:
# собрать/проверить только пакеты, затронутые последним коммитом, и их зависимых
pnpm turbo run lint typecheck test build --filter='...[HEAD^1]'
Что ломается в монорепо на масштабе
- CI растёт квадратично к невнимательности. Без
--filterи кэша каждый PR прогоняет всё. Лечится affected-сборками и шардированием E2E. - Владение размывается.
CODEOWNERSс путями обязателен:/packages/ui/ @design-system-team. Без него дизайн-система превращается в свалку. - Версионирование публичных пакетов. Если что-то уходит в npm — Changesets: разработчик добавляет markdown-файл с типом изменения, CI собирает релизный PR с версиями и changelog.
- Соблазн общего кода. «Раз рядом лежит, заимпортирую». Монорепо снимает физический барьер, поэтому логический (линтер зависимостей между пакетами) становится обязательным, а не желательным.
- Гигантский
node_modulesи медленный IDE. pnpm со симлинками иtsconfigproject references лечат основное; на десятках тысяч файлов помогаетgit sparse-checkout.
Хороший обзор компромиссов — monorepo.tools и статья Фаулера о monorepo-подходе.
Микрофронтенды: организационное решение, которое выглядит техническим
Определение по Мартину Фаулеру: микрофронтенды — это разрезание веб-приложения на части, которые независимые команды владеют и деплоят целиком, от UI до данных.
Ключевые слова — «независимые команды» и «деплоят». Если у вас одна команда, микрофронтенды дают вам всю сложность распределённой системы и ноль её преимуществ. Полезный тест из трёх вопросов, все ответы должны быть «да»:
- Есть ли несколько команд, которые сегодня блокируют друг друга на релизе общего фронтенда?
- Готовы ли вы эксплуатировать платформу: реестр ремоутов, контракты, сквозная отладка, версионирование shared-зависимостей?
- Разные ли у частей темпы релиза? Если всё едет одним поездом раз в неделю, независимый деплой нечего оптимизировать.
Четыре шва — и что каждый стоит
- Сборкой. Команды публикуют npm-пакеты, host собирает всё в один бандл. Формально это не микрофронтенды: релиз общий. Зато один runtime, один тайп-чек, никакого дублирования. Для подавляющего большинства продуктов это правильный ответ, и монорепо — его удобная упаковка.
- На сервере. Фрагменты HTML склеиваются на бэкенде или на edge (SSI, ESI, Nginx, Cloudflare Workers). Отличный первый рендер, независимый деплой фрагментов; клиентская навигация и общий интерактив строятся вручную. Классика для порталов и витрин; подробности — на micro-frontends.org.
- В рантайме. Host в браузере грузит модули других команд по URL — Module Federation или просто динамический
import()c ESM. Максимальная независимость деплоя ценой общего JS-контекста: чужая ошибка в вашей вкладке, конфликт версий React, общие глобалы. - Изоляцией.
iframeили Web Component с shadow DOM. Полная изоляция стилей и падений, но каждый фрейм тащит свой рантайм, а модалки, фокус, история и доступность (см. «Доступность») ломаются на границе документов. Правильное применение — чужой код и легаси-встройки, а не собственный продукт.
Module Federation: рабочий минимум
Remote (команда checkout) выставляет наружу компонент:
// apps/checkout/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { federation } from '@module-federation/vite'
export default defineConfig({
plugins: [
react(),
federation({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: { './Widget': './src/CheckoutWidget.tsx' },
// singleton: в одной вкладке должен быть ровно один React,
// иначе хуки и контексты хоста не работают внутри ремоута
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
},
}),
],
build: { target: 'esnext' },
})
Host подключает его по URL — то есть на этапе выполнения, а не сборки:
// apps/shell/vite.config.ts (фрагмент)
federation({
name: 'shell',
remotes: {
checkout: {
type: 'module',
name: 'checkout',
entry: import.meta.env.VITE_CHECKOUT_ENTRY, // адрес зависит от окружения
},
},
shared: {
react: { singleton: true, requiredVersion: '^19.0.0' },
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
},
})
хуки, контексты и Suspense хоста ломаются end R-->>S: модуль Widget S->>B: рендер в общее дерево, ошибки ловит RemoteBoundary
Эта диаграмма — краткий список всего, что пойдёт не так. Минимальная защита на стороне хоста:
// apps/shell/src/shared/RemoteBoundary.tsx
import { Component, Suspense, lazy, type ReactNode } from 'react'
class Boundary extends Component<{ name: string; children: ReactNode }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() { return { failed: true } }
componentDidCatch(error: Error) {
// ошибка чужой команды — она должна попасть в её алерты, а не в наши
reportError(error, { remote: this.props.name, kind: 'remote-failure' })
}
render() {
if (this.state.failed) return <p>Раздел временно недоступен</p>
return this.props.children
}
}
const CheckoutWidget = lazy(() => import('checkout/Widget'))
export function Checkout() {
return (
<Boundary name="checkout">
<Suspense fallback={<Skeleton />}>
<CheckoutWidget />
</Suspense>
</Boundary>
)
}
Что придётся построить сверху — и это самая дорогая часть
Module Federation — это загрузчик модулей, а не архитектура. Всё остальное вы строите сами:
- Контракт между хостом и ремоутом. Пропсы, события, версия контракта. Хост и ремоут деплоятся врозь — значит, между ними полноценный версионируемый API со всеми правилами обратной совместимости (те же, что для HTTP: только добавлять, никогда не менять смысл существующего).
- Единая дизайн-система как отдельный пакет с жёсткой политикой версий. Иначе кнопки в двух частях приложения разъедутся на глазах у пользователя.
- Единый роутер и один источник правды об авторизации. Два роутера в одной вкладке — гарантированная ошибка с историей и кнопкой «Назад».
- Реестр ремоутов (какая версия чего сейчас в проде) и возможность откатить один ремоут независимо.
- Сквозная отладка. Трейс, который начинается в ремоуте и уходит на его бэкенд, а source maps каждой команды лежат в своём проекте мониторинга.
Если этот список выглядит как работа платформенной команды на несколько месяцев — так и есть. Именно поэтому честный ответ на «делать ли микрофронтенды» для команды меньше 25–30 человек — «нет, сделайте модульный монолит», ровно как в бэкенде: «Монолит и модульный монолит».
Деплой: артефакт, указатель, откат
Фронтенд деплоится проще бэкенда — и именно поэтому здесь копится удивительное количество самодельных решений. Правильная модель умещается в одну картинку.
Два класса файлов и два режима кэширования
Сборка даёт две принципиально разные категории:
- Файлы с хэшем содержимого в имени (
app-8f3ad2.js,logo-4e21.svg). Их содержимое никогда не меняется — имя вычисляется из содержимого. Кэшируются навсегда. - Точка входа
index.html(иmanifest.json,sw.js). Меняется каждый релиз, содержит ссылки на текущие хэшированные файлы. Не кэшируется вообще.
# index.html: всегда свежий, иначе пользователь останется на старом релизе навсегда
location = /index.html {
add_header Cache-Control "no-store, must-revalidate";
}
# ассеты с хэшем: вечный кэш, инвалидация не нужна по построению
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# SPA-фолбэк: любой путь отдаёт index.html, роутинг разбирает клиент
location / {
try_files $uri /index.html;
}
Ошибка «поставили max-age=3600 на index.html» стоит ровно одного инцидента: вы откатили релиз, а половина пользователей ещё час получает сломанную версию из кэша CDN и браузера.
Атомарный релиз и откат за секунды
Правильный деплой статики — не «залить файлы поверх», а загрузить новый релиз целиком и переключить указатель:
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
concurrency:
group: deploy-prod # два деплоя одновременно = смесь двух релизов у пользователя
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
permissions: { contents: read, id-token: write }
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # нужен для --filter по изменённому
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint typecheck test build --filter='...[HEAD^1]'
# 1. Ассеты: неизменяемые, вечный кэш
- run: |
aws s3 sync apps/web/dist "s3://$BUCKET/releases/$GITHUB_SHA/" \
--exclude index.html \
--cache-control "public, max-age=31536000, immutable"
# 2. Точка входа: без кэша
- run: |
aws s3 cp apps/web/dist/index.html "s3://$BUCKET/releases/$GITHUB_SHA/index.html" \
--cache-control "no-store"
# 3. Атомарное переключение: одна маленькая операция вместо сотни файлов
- run: |
echo "$GITHUB_SHA" > current.txt
aws s3 cp current.txt "s3://$BUCKET/current.txt" --cache-control "no-store"
aws cloudfront create-invalidation --distribution-id "$CF_ID" --paths "/index.html" "/current.txt"
# 4. Source maps — в мониторинг, а не в прод-бакет
- run: pnpm sentry-cli sourcemaps upload --release "$GITHUB_SHA" apps/web/dist
Три следствия такой схемы:
- Откат — это переключение указателя, секунды и ноль сборок. Возможность откатиться за 30 секунд ценнее любого набора проверок перед выкатом.
- Preview-деплой бесплатен: тот же пайплайн с
releases/pr-123/и уникальным URL в комментарии к PR. Ревью дизайна и ручная проверка перестают требовать локального запуска — см. «Pull request». - Старые релизы нельзя удалять сразу. Держите последние 5–10.
Проблема устаревшей вкладки
Самый частый фронтенд-инцидент, о котором не пишут в туториалах. Пользователь открыл сайт утром, вкладка живёт с index.html релиза N-3. Днём вышел релиз N. Пользователь нажимает «Оформить заказ», приложение делает import('./checkout-77aa.js') — а такого файла в новом релизе нет (или он удалён вместе со старым релизом). Белый экран.
Два уровня защиты, нужны оба:
// 1. Ретрай ленивой загрузки: если чанк не найден — почти наверняка вышел новый релиз
import { lazy, type ComponentType } from 'react'
const RELOAD_AT = 'chunk-reload-at'
export function lazyWithRetry<T extends ComponentType<never>>(
factory: () => Promise<{ default: T }>,
) {
return lazy(async () => {
try {
return await factory()
} catch (error) {
const last = Number(sessionStorage.getItem(RELOAD_AT) ?? 0)
// защита от цикла перезагрузок, если чанк недоступен по другой причине
if (Date.now() - last > 60_000) {
sessionStorage.setItem(RELOAD_AT, String(Date.now()))
window.location.reload()
return new Promise<never>(() => {}) // страница уже уходит, рендерить нечего
}
throw error
}
})
}
// 2. Мягкое уведомление: сравниваем свой build id с текущим в проде
const BUILD_ID = __BUILD_ID__ // define в vite.config: JSON.stringify(process.env.GITHUB_SHA)
export function useNewVersion(intervalMs = 5 * 60_000) {
const [outdated, setOutdated] = useState(false)
useEffect(() => {
const check = async () => {
if (document.visibilityState !== 'visible') return
const res = await fetch('/current.txt', { cache: 'no-store' })
const current = (await res.text()).trim()
if (current && current !== BUILD_ID) setOutdated(true)
}
const id = setInterval(check, intervalMs)
document.addEventListener('visibilitychange', check)
return () => { clearInterval(id); document.removeEventListener('visibilitychange', check) }
}, [intervalMs])
return outdated
}
// показываем ненавязчивый баннер «Доступна новая версия — обновить»,
// НЕ перезагружаем принудительно: пользователь может заполнять форму
Отвязать деплой от релиза: флаги
Архитектурно важное различие: деплой — код оказался на серверах; релиз — функциональность стала видна пользователям. Слитые вместе, они заставляют держать длинные ветки и бояться выката. Разведённые флагами, они дают ежедневный деплой без страха и мгновенное отключение сломанной фичи без отката всего приложения.
// флаги приходят вместе с данными пользователя: один источник правды
const flags = useFlags()
return flags.newCheckout ? <CheckoutV2 /> : <CheckoutV1 />
Два правила: флаг должен иметь дату удаления (иначе через год их триста и комбинаторный ад), и он должен читаться в одном месте, а не размазываться по компонентам. Подробнее о стратегиях выката — «CD и стратегии релиза».
Обратите внимание на развилку: не всякая проблема требует отката. Если сломалась новая фича — закрываем флаг, приложение остаётся на свежем релизе. Если сломался релиз целиком — возвращаем указатель. Разные рычаги под разные аварии — это и есть архитектурное свойство, а не операционная мелочь.
Мониторинг: узнать о поломке раньше пользователя
Фронтенд исполняется на чужих устройствах, в чужих сетях, с чужими расширениями. Сервер об этом не знает ничего: у него все ответы 200. Поэтому мониторинг фронтенда — не «приятное дополнение», а единственный канал обратной связи.
Собирать нужно четыре сигнала, и они не заменяют друг друга:
| Сигнал | Что ловит | Чем собирают | На что смотреть |
|---|---|---|---|
| Ошибки JS | Исключения, необработанные промисы, падения рендера | Sentry / Rollbar / свой сборщик | Crash-free sessions, новые типы ошибок после релиза |
| Веб-виталы (RUM) | Медленно у реальных пользователей | web-vitals + свой эндпоинт |
p75 LCP / INP / CLS по устройствам и странам |
| Ошибки загрузки ресурсов | 404 на чанки, упавший CDN, блокировка расширением | error на window в фазе перехвата |
Всплеск сразу после деплоя |
| Трассировка запросов | Медленный или падающий бэкенд глазами клиента | OpenTelemetry Web + traceparent |
Доля 5xx, p95 времени ответа с клиента |
Метрики производительности подробно разобраны в «Производительности фронтенда»; здесь — про ошибки и трассировку.
Минимальный сбор ошибок своими руками
// src/shared/monitoring/report.ts
type Event = { kind: string; message: string; stack?: string; url: string; release: string }
const queue: Event[] = []
function send(event: Event) {
queue.push(event)
// sendBeacon переживает закрытие вкладки — обычный fetch на unload теряется
queueMicrotask(() => {
const batch = queue.splice(0)
if (batch.length) navigator.sendBeacon('/api/telemetry', JSON.stringify(batch))
})
}
export function initMonitoring(release: string) {
window.addEventListener('error', (e) => {
// ошибки загрузки ресурсов не всплывают: ловим их в фазе перехвата
const target = e.target as HTMLElement | null
if (target && target !== (window as unknown as HTMLElement) && 'src' in target) {
send({ kind: 'resource', message: `failed: ${(target as HTMLScriptElement).src}`,
url: location.href, release })
return
}
send({ kind: 'error', message: e.message, stack: e.error?.stack, url: location.href, release })
}, true) // <- true обязателен
window.addEventListener('unhandledrejection', (e) => {
send({ kind: 'rejection', message: String(e.reason), stack: e.reason?.stack,
url: location.href, release })
})
}
Четыре нюанса, без которых мониторинг бесполезен:
- Source maps. Без них стек — это
a.b is not a functionвapp-8f3ad2.js:1:48210. Карты загружаются в сервис мониторинга с тегом релиза и не выкладываются в публичный бакет (иначе вы отдали исходники). В Vite:build.sourcemap: 'hidden'— карты генерируются, но комментарий-ссылка в бандл не попадает. - Релиз в каждом событии. Иначе невозможно ответить на единственный важный вопрос после выката: «эта ошибка новая или была всегда?».
- Шум. 30–50 % событий в свежем проекте — расширения браузера, боты и
ResizeObserver loop limit exceeded. Нужны allow-list по домену скрипта и понятный список игнорируемых сообщений, иначе алерты перестают читать. - Персональные данные. В URL и в теле запросов попадают токены и почта. Скрабер обязателен, и это требование закона, а не гигиена.
Ключевая метрика здоровья релиза — crash-free sessions: доля сессий без единой необработанной ошибки. Она сравнима между релизами и не зависит от роста трафика, в отличие от «числа ошибок в час».
Трассировка через границу фронт/бэк
Самый частый спор на инциденте — «у нас всё быстро» против «у пользователей всё медленно». Разрешается сквозным идентификатором трассы: браузер генерирует заголовок traceparent и отправляет его с запросом, бэкенд продолжает ту же трассу.
traceparent: 00-4bf92f...-00f067...-01 BFF->>S: тот же trace-id, новый span S-->>BFF: 200 за 820 мс BFF-->>A: 200 за 910 мс A->>A: рендер результата, закрытие span A->>O: span клиента: сеть 640 мс, рендер 90 мс BFF->>O: серверные span-ы Note over O: одна трасса: видно, что 640 мс —
это мобильная сеть, а не бэкенд
Без клиентского span вы видите только «сервер ответил за 910 мс» и не видите 640 мс, которые заняли DNS, TLS и радиоканал. Именно поэтому фронтенд обязан участвовать в трассировке; общая механика — в «Наблюдаемости распределённых систем» и «Observability и дежурства».
SLO и алерты, на которые реально просыпаются
Алерт на «любую новую ошибку» через неделю перестают читать. Работает набор из четырёх правил, привязанных к пользовательскому опыту:
| Условие | Почему это важно | Реакция |
|---|---|---|
| Crash-free sessions упал ниже 99,5 % за 10 минут | Приложение падает у каждого 200-го | Страница дежурному, кандидат на откат |
Всплеск resource-ошибок сразу после деплоя |
Чанки не выложились или удалены старые релизы | Немедленный откат указателя |
| p75 INP за час вырос больше чем на 30 % к недельной базе | Интерфейс «залип» у большинства | Разбор в рабочее время |
| Доля 5xx с клиента выше 1 % | Бэкенд ломается именно для реальных клиентов | Совместный разбор с бэкендом |
Про построение SLO и бюджеты ошибок — SRE Workbook и «Двенадцать факторов» для дисциплины конфигурации и логов.
Как принимать эти решения и не переусложнить
Главная ошибка архитектора — выбрать целевое состояние на три года вперёд и строить его сразу. Правильнее выбирать следующий шаг, который снимает сегодняшнюю боль и не закрывает будущие двери.
Два инструмента, которые делают эволюцию управляемой:
- ADR (Architecture Decision Record). Один markdown-файл на решение: контекст, варианты, выбор, последствия. Три абзаца, лежат в репозитории. Через год они отвечают на вопрос «почему тут так», который иначе съедает часы. Формат — от Майкла Найгарда, подробнее — «Архитектурные решения».
- Фитнес-функции. Автоматические проверки архитектурных свойств в CI: dependency-cruiser на граф зависимостей, бюджет размера бандла, порог покрытия критических путей, Lighthouse CI на ключевые страницы. Архитектура, которую не проверяет CI, деградирует ровно с той скоростью, с какой команда торопится.
Типичные ошибки
- Микрофронтенды вместо модульности. Разрезали монолит, получили пять раз ту же авторизацию, три версии React в одной вкладке и невозможность отладить сквозной сценарий. Если релизный поезд один — режьте модулями внутри приложения.
- Монорепо без раннера задач. Каждый PR прогоняет всё, CI пухнет до сорока минут, разработчики учатся не запускать тесты. Кэш и affected-фильтры — не оптимизация, а условие работоспособности.
- Переменные окружения мимо
envвturbo.json. Прод-сборка приезжает из кэша со staging-адресом API. Инцидент, который очень трудно диагностировать. - Кэшируемый
index.html. Откат сделан, а пользователи ещё час на сломанной версии. - Удаление старых релизов сразу после выката. Белые экраны у всех, кто держал вкладку открытой. Держите N-5.
- Мониторинг без source maps и без тега релиза. Тысячи событий, по которым нельзя понять ни где сломалось, ни когда началось.
shared/как свалка. Всё, что понадобилось дважды, уезжает вниз; через годsharedзнает о заказах, скидках и ролях, и любое изменение в нём — риск для всего приложения. Вsharedпопадает только то, в чём нет ни одного доменного понятия.- Правила, которые проверяют люди. Соглашение о слоях без линтера — это не архитектура, а пожелание. Через два спринта под дедлайном оно перестанет соблюдаться.
- Дизайн-система, которую никто не оунит. Общий UI-kit без владельца и версии превращается в пятнадцать вариантов кнопки.
Мини-итог
- Архитектура фронтенда отвечает на три раздельных вопроса: границы кода, путь до пользователя, обратная связь из прода. Смешивать их — источник большинства неудачных решений.
- Внутри приложения работают два правила: группировка по причине изменения (фича, а не тип файла) и однонаправленный граф зависимостей, проверяемый линтером и dependency-cruiser.
- Изоляция контракта бэкенда в один слой отображения DTO → модель UI стоит десятка файлов и экономит недели при каждом изменении API.
- Монорепо — про совместную разработку, не про совместный деплой. Оно окупается с момента, когда одно изменение регулярно затрагивает несколько пакетов; его условие работоспособности — граф задач с кэшем.
- Микрофронтенды — ответ на организационную боль. Три критерия: несколько команд, готовность содержать платформу, разные темпы релиза. Меньше трёх «да» — модульный монолит.
- Деплой статики — неизменяемое содержимое плюс изменяемый указатель. Хэши в именах,
no-storeнаindex.html, атомарное переключение, откат за секунды, хранение старых релизов и ретрай ленивых чанков. - Флаги отвязывают релиз от деплоя и дают второй рычаг аварийного управления, кроме отката.
- Мониторинг фронтенда — единственный канал правды. Crash-free sessions с тегом релиза, source maps, полевые веб-виталы, клиентский span в общей трассе.
- Двигайтесь на один шаг, а не к целевой картинке: слои → монорепо → раздельные деплои → микрофронтенды. Каждый шаг делается, когда предыдущий начал мешать, и фиксируется ADR.
Источники
- Micro Frontends, Мартин Фаулер и Кэм Джексон — https://martinfowler.com/articles/micro-frontends.html; каталог подходов — https://micro-frontends.org
- Luca Mezzalira, «Building Micro-Frontends» (O’Reilly) — https://www.buildingmicrofrontends.com
- Module Federation — https://module-federation.io, плагин для Vite — https://github.com/module-federation/vite
- Feature-Sliced Design — https://feature-sliced.design; colocation, Кент Доддс — https://kentcdodds.com/blog/colocation
- pnpm workspaces — https://pnpm.io/workspaces; Turborepo — https://turborepo.com/docs; Nx — https://nx.dev; сравнение — https://monorepo.tools
- Monolithic repository, Мартин Фаулер — https://martinfowler.com/bliki/MonolithicRepository.html
- Changesets — https://github.com/changesets/changesets; dependency-cruiser — https://github.com/sverweij/dependency-cruiser
- Vite: сборка, хэши и source maps — https://vite.dev/guide/build
- W3C Trace Context — https://www.w3.org/TR/trace-context/; OpenTelemetry JS — https://opentelemetry.io/docs/languages/js/
- Source maps в мониторинге — https://docs.sentry.io/platforms/javascript/sourcemaps/
- Implementing SLOs, Google SRE Workbook — https://sre.google/workbook/implementing-slo/
- Documenting Architecture Decisions, Майкл Найгард — https://www.cognitect.com/blog/2011/11/15/documenting-architecture-decisions
- Neal Ford, Rebecca Parsons, Patrick Kua, «Building Evolutionary Architectures» — про фитнес-функции как способ удержать архитектуру
Что дальше
Это последняя статья трека «Frontend-разработка». Вы прошли путь от байтов, которые браузер превращает в пиксели, до артефакта, который атомарно переключается в проде и сам рассказывает о своих ошибках. Дальше глубина набирается в соседних треках — выбирайте по тому, что сейчас мешает больше.
- TypeScript — язык, на котором всё это написано: типы, которые действительно защищают, и архитектура TS-приложений.
- Доступность — интерфейс, которым можно пользоваться без мыши и без зрения; на любом продуктовом проекте это не опция.
- Архитектурные паттерны и DDD — те же границы, но на масштабе всей системы, а не одного клиента.
- DevOps — CI, контейнеры, облака и наблюдаемость: следующий шаг после «умею деплоить статику».
- Тестирование — теория за практикой из пятнадцатой статьи.
- Базы данных и Product-менеджмент — два направления, которые чаще всего расширяют фронтендера в сторону full-stack и в сторону продукта.
Куда двигаться дальше по портфелю навыков в целом и как собрать из треков собственный маршрут — в дорожной карте портала.