Раздел 7 · Браузер и Frontend

Архитектура фронта: автоматы, FSD и Atomic

middle-senior~50 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Архитектура фронта: автоматы, FSD и Atomic

У тебя на руках тройка Echo: React плюс Redux, React плюс Reatom, Vue плюс Pinia. Этого достаточно, чтобы пощупать модели реактивности, но мало, чтобы такой проект пережил год работы команды из четырёх человек. Дальше начинается архитектура: не про библиотеки, а про то, как разложить код, чтобы он не превратился в кашу через шесть месяцев. В этом уроке три ответа на этот вопрос. Первый, на уровне поведения: моделируй UI state не флагами, а конечным автоматом. Второй, на уровне жизненного цикла: понимай, как приложение разворачивается во времени. Третий, на уровне раскладки кода: слои Feature-Sliced Design плюс Atomic Design для UI-кита.

1. UI state как конечный автомат

Открой MessageInput любого Echo и выпиши, что в нём бывает. Не печатает. Печатает черновик. Отправляет. Отправил, чисто. Не отправил, ошибка. Жмёт повтор, снова отправляет. Это пять состояний и переходы между ними. Если описать их тремя независимыми флагами (isTyping, isSending, isError), то ничего не мешает им включиться разом и нарисовать невозможное. Автомат запрещает такое по построению: состояние одно из пяти, переходы заранее заданы.

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

MessageInput как конечный автоматтекущее: idle
событие:
TYPECLEARSUBMITSUCCESSERRORRETRYTYPECLEARidletypingsendingsentfailed

Жди событие. Стартуем из idle. Видишь, какие кнопки разрешены, остальные отключены: переход из текущего состояния не определён.

    Теперь то же на TypeScript, и размеченный union делает невозможные состояния невыразимыми. У каждого состояния свой набор полей, у failed есть reason, у typing есть draft, у idle нет ничего:

    type MessageInputState =
      | { status: 'idle' }
      | { status: 'typing'; draft: string }
      | { status: 'sending'; draft: string }
      | { status: 'sent' }
      | { status: 'failed'; draft: string; reason: string };
    
    type InputEvent =
      | { type: 'TYPE'; value: string }
      | { type: 'CLEAR' }
      | { type: 'SUBMIT' }
      | { type: 'SUCCESS' }
      | { type: 'ERROR'; reason: string }
      | { type: 'RETRY' };

    Переход это чистая функция (state, event) => state. Невалидные комбинации просто возвращают текущее состояние:

    import { match, P } from 'ts-pattern';
    
    export function transition(state: MessageInputState, event: InputEvent): MessageInputState {
      return match([state, event] as const)
        .with([{ status: 'idle' }, { type: 'TYPE' }], ([, e]) => ({ status: 'typing', draft: e.value }))
        .with([{ status: 'typing' }, { type: 'TYPE' }], ([, e]) => ({ status: 'typing', draft: e.value }))
        .with([{ status: 'typing' }, { type: 'CLEAR' }], () => ({ status: 'idle' }))
        .with([{ status: 'typing' }, { type: 'SUBMIT' }], ([s]) => ({ status: 'sending', draft: s.draft }))
        .with([{ status: 'sending' }, { type: 'SUCCESS' }], () => ({ status: 'sent' }))
        .with([{ status: 'sending' }, { type: 'ERROR' }], ([s, e]) => ({
          status: 'failed',
          draft: s.draft,
          reason: e.reason,
        }))
        .with([{ status: 'failed' }, { type: 'RETRY' }], ([s]) => ({ status: 'sending', draft: s.draft }))
        .with([{ status: 'failed' }, { type: 'CLEAR' }], () => ({ status: 'idle' }))
        .with([{ status: 'sent' }, { type: 'TYPE' }], ([, e]) => ({ status: 'typing', draft: e.value }))
        .otherwise(([s]) => s); // событие не определено для состояния, остаёмся
    }

    В компоненте подключаем через useReducer. В render по state.status сразу видно, что показывать, и компилятор не даст обратиться к draft в ветке sent, потому что там его нет.

    const [state, dispatch] = useReducer(transition, { status: 'idle' });
    
    return match(state)
      .with({ status: 'idle' }, () => <Composer onType={(value) => dispatch({ type: 'TYPE', value })} />)
      .with({ status: 'typing' }, (s) => (
        <Composer draft={s.draft} onType={...} onSubmit={() => dispatch({ type: 'SUBMIT' })} />
      ))
      .with({ status: 'sending' }, (s) => <Composer draft={s.draft} disabled label="отправляем..." />)
      .with({ status: 'sent' }, () => <ComposerSent onReply={() => dispatch({ type: 'TYPE', value: '' })} />)
      .with({ status: 'failed' }, (s) => (
        <ComposerError draft={s.draft} reason={s.reason} onRetry={() => dispatch({ type: 'RETRY' })} />
      ))
      .exhaustive();

    exhaustive() от ts-pattern это гарантия: добавил новое состояние в union и забыл его обработать, сборка упала. Для машин сложнее простых форм бери XState: он добавляет вложенные состояния, параллельные регионы, отложенные переходы и готовый визуализатор. Но для одной формы хватит того, что выше.

    Что взять с собой

    • UI state это не набор флагов, а конечный автомат: одно состояние из списка.
    • discriminated union на TS делает невозможные состояния невыразимыми, у каждой ветки свои поля.
    • Переход это чистая (state, event) => state плюс match.exhaustive, ловит забытое состояние компилятором.
    • Простые машины руками, сложные через XState.

    2. Жизненный цикл SPA

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

    bootstrap   ->   shell   ->   page   ->   process
    (сессия,         (роутер,        (своя           (диалог,
     флаги, locale)   layout,         загрузка        форма,
                      тема)           данных)          мастер)
    • bootstrap это однократная инициализация перед первым рендером: достать сессию, локаль, feature-flags. Если флаги не пришли, страница не должна рендериться, иначе на полсекунды покажется не то.
    • shell это router и layout, общие на всё приложение: сайдбар, шапка, провайдеры темы и store. Они монтируются один раз и переживают переходы между страницами.
    • page это конкретный маршрут со своей загрузкой. Тут живут лоадеры (loader в React Router, loader в Remix, useLoader в TanStack Router) и ErrorBoundary самой страницы.
    • process это конечный автомат из раздела 1: модалка, форма, мастер.

    Когда что-то не сбрасывается между переходами между каналами, обычно его положили в shell вместо page. Когда сессия мигает на четверть секунды и потом исчезает, обычно её начали читать в page вместо bootstrap. Лекарство одно: явно решай, в каком слое живёт твоя штука.

    Что взять с собой

    • SPA разворачивается слоями: bootstrap, shell, page, process.
    • Состояние, привязанное к процессу, не должно жить в shell, и наоборот.
    • Большинство багов мигания и непогасшего флага это путаница слоёв.

    3. MVC, MVVM, Flux: короткий шаг назад

    Прежде чем смотреть FSD, проговорим, на чьих плечах он стоит. Архитектурных паттернов фронта три семьи:

    • MVC (Model, View, Controller): controller ловит события UI и дёргает model, view рендерится от model. Классика серверного MVC (Rails, Django).
    • MVVM: ViewModel держит реактивное состояние, шаблон автоматически обновляется. Vue, Angular, старый Knockout это MVVM в чистом виде.
    • Flux: однонаправленный поток без двусторонней привязки: Action -> Dispatcher -> Store -> View. Redux это минимальный Flux, который мы ставили в уроке 10.

    Это паттерны связи модели и представления. Они не говорят, как раскладывать сам код по папкам, чтобы при росте проекта не путаться, где править. На эту вторую задачу отвечает FSD.

    Что взять с собой

    • MVC и MVVM это про связь модели с представлением, Flux это однонаправленный вариант MVVM.
    • Redux это минимальный Flux, мы его уже знаем.
    • Как разложить код по папкам, эти три паттерна не отвечают, для этого есть FSD.
    • На большой дистанции живут два архитектурных шаблона: слоистая (зависимости только вниз по ярусам) и гексагональная (домен в центре, порты и адаптеры по краям). FSD это слоистая под фронт, гексагональные идеи прячутся в публичных index.ts срезов.

    4. Слоистая и гексагональная: общая рамка

    FSD и Atomic Design это конкретные методички, у каждой свои названия папок и правила. Чтобы не путать их с собственным значением слова “архитектура”, в начале раздела зайдём с одного уровня выше. На большой дистанции у любого продакшен-фронта живут два архитектурных шаблона, которые мы дальше будем узнавать в обличье FSD и в обличье разделения бэкенда.

    Первый это слоистая архитектура. Код кладут горизонтальными ярусами, и каждый ярус знает только о ярусе непосредственно под собой. Имена ярусов могут быть разными: на бэкенде обычно presentation плюс application плюс domain плюс infrastructure, на фронте мы скоро увидим app плюс pages плюс widgets плюс features плюс entities плюс shared. Принцип один, зависимости текут только вниз, никогда вверх и никогда через ярус. Это снимает связность: переписал UI, домен не дёргается; поменял базу, бизнес-правила не дёргаются.

    Второй это гексагональная архитектура, она же ports and adapters. Чистый домен сидит в центре, по краям порты (интерфейсы, которыми домен общается с миром) и адаптеры (реализации этих портов, специфичные для HTTP, БД, CLI). Домен не знает, кто и как его зовёт, и не знает, куда сам ходит за данными, между ним и внешним миром стоят интерфейсы. На тестах адаптеры подменяют моками, и домен гоняется в изоляции. Гексагональную чаще встретишь на бэкенде в разделе 08, но FSD на фронте заимствует ту же идею: shared/api выставляет порт-функцию, конкретная реализация подменяема (REST, GraphQL, моки в тестах).

    Дальше в этом уроке мы возьмём слоистую модель и инструментируем её под фронт. FSD это слоистая архитектура с фиксированным набором ярусов и жёстким линтером поверх. Atomic Design это вложенная слоистая модель внутри shared/ui. Гексагональные идеи прячутся в правиле “никто не импортит из реализаций, все импортят из портов”, его роль на фронте играют публичные index.ts срезов и алиасы @features/*, @entities/*.

    Что взять с собой

    • Слоистая архитектура раскладывает код горизонтальными ярусами с зависимостями только вниз. На бэкенде это presentation плюс application плюс domain плюс infrastructure, на фронте FSD.
    • Гексагональная архитектура (ports and adapters) ставит домен в центр и общается с миром через интерфейсы. На фронте проявляется как публичный index.ts среза и подменяемые реализации в shared/api.
    • Имена ярусов разные, принцип один: зависимости только вниз и только на соседний слой.

    дальше после входа

    Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

    Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.