Frontend-разработка Архитектура фронтенда: слои, монорепо, микрофронтенды, деплой и мониторинг
0%

Архитектура фронтенда: слои, монорепо, микрофронтенды, деплой и мониторинг

Архитектура фронтенда: слои, монорепо, микрофронтенды, деплой и мониторинг

В предыдущей статье мы выбирали фреймворк. Это решение кажется главным ровно до момента, когда проект переваливает за сотню файлов и трёх разработчиков. Дальше выясняется неприятное: скорость команды определяется не тем, React у вас или Svelte, а тем, сколько файлов приходится открыть, чтобы добавить одно поле в форму, и сколько людей нужно предупредить, чтобы выкатить это в прод.

Архитектура — это набор решений, которые дорого менять потом. Выбор библиотеки для дат меняется за вечер. Направление зависимостей между модулями, схема репозитория, формат артефакта деплоя и способ узнавать об ошибках — не меняются годами и определяют, будет ли через два года «легко добавить фичу» или «страшно трогать».

Ключевая мысль всей статьи: фронтенд-архитектура — это в первую очередь про границы, а не про технологии. Границы внутри кода (что от чего может зависеть), границы между командами (кто что релизит), границы между версиями (что уже в проде, а что ещё нет). Всё остальное — инструменты, которые эти границы либо поддерживают, либо тихо размывают.

Три вопроса, на которые отвечает архитектура фронтенда

Любое архитектурное обсуждение фронтенда сводится к трём вопросам. Полезно держать их отдельно — их часто смешивают и получают спор ни о чём.

  1. Где живёт код и что от чего зависит. Слои, модули, публичные API, правило зависимостей. Область ответственности — читаемость и стоимость изменения.
  2. Как код превращается в то, что видит пользователь. Репозиторий, сборка, артефакт, деплой, откат. Область ответственности — скорость и безопасность релиза.
  3. Как мы узнаём, что оно работает. Ошибки, метрики, трассировка, 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).

Смысл слоёв — не бюрократия, а предсказуемость радиуса поражения. Изменение в shared/ui/Button может задеть весь проект — поэтому туда кладут только то, что действительно универсально и стабильно. Изменение в features/checkout физически не может задеть ничего, кроме страниц, которые её подключают, — и это гарантируется линтером, а не совестью.

Честно про минусы FSD. Он даёт словарь, который экономит часы споров на код-ревью, но у него реальная цена: новичок первые две недели мучительно решает, entity это или feature; на маленьком проекте шесть слоёв — оверинжиниринг; граница feature против widget объективно размыта. Рабочий компромисс: начните с трёх слоёвsharedfeaturespages (+ 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:*"
  }
}

Ключевое решение здесь — как публикуются внутренние пакеты. Два подхода:

  1. Собирать каждый пакет (packages/ui/dist) и подключать через main/exports. Плюс: приложение потребляет пакет ровно так, как внешний потребитель. Минус: нужна пересборка перед стартом, HMR через границу пакета работает хуже.
  2. Отдавать исходники ("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 }
  }
}

Что тут важно понять про кэш. 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 со симлинками и tsconfig project references лечат основное; на десятках тысяч файлов помогает git sparse-checkout.

Хороший обзор компромиссов — monorepo.tools и статья Фаулера о monorepo-подходе.

Микрофронтенды: организационное решение, которое выглядит техническим

Определение по Мартину Фаулеру: микрофронтенды — это разрезание веб-приложения на части, которые независимые команды владеют и деплоят целиком, от UI до данных.

Ключевые слова — «независимые команды» и «деплоят». Если у вас одна команда, микрофронтенды дают вам всю сложность распределённой системы и ноль её преимуществ. Полезный тест из трёх вопросов, все ответы должны быть «да»:

  1. Есть ли несколько команд, которые сегодня блокируют друг друга на релизе общего фронтенда?
  2. Готовы ли вы эксплуатировать платформу: реестр ремоутов, контракты, сквозная отладка, версионирование shared-зависимостей?
  3. Разные ли у частей темпы релиза? Если всё едет одним поездом раз в неделю, независимый деплой нечего оптимизировать.

Четыре способа интеграции микрофронтендов и уровень, на котором проходит шов между командами

Четыре шва — и что каждый стоит

  • Сборкой. Команды публикуют 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' },
  },
})

Эта диаграмма — краткий список всего, что пойдёт не так. Минимальная защита на стороне хоста:

// 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.htmlmanifest.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 и отправляет его с запросом, бэкенд продолжает ту же трассу.

Без клиентского 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.

Источники

Что дальше

Это последняя статья трека «Frontend-разработка». Вы прошли путь от байтов, которые браузер превращает в пиксели, до артефакта, который атомарно переключается в проде и сам рассказывает о своих ошибках. Дальше глубина набирается в соседних треках — выбирайте по тому, что сейчас мешает больше.

Куда двигаться дальше по портфелю навыков в целом и как собрать из треков собственный маршрут — в дорожной карте портала.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов