Фреймворк-независимое браузерное SPA

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

1. Но... зачем?

  1. Существует огромное количество фреймворков для разработки SPA (Single Page Application).
  2. Существует огромное количество документации, иллюстрирующей как создавать приложение на базе конкретного фреймворка.
  3. Однако всякая подобная документация ставит фреймворк во главу угла. Тем самым превращая фреймворк из детали реализации в определяющий фактор. Таким образом значительная часть кода пишется не для удовлетворения нужд бизнеса а для удовлетворения нужд фреймворка.

Учитывая насколько hype-driven является разработка софта в наше время, можно быть уверенным в том что через несколько лет будут существовать новые модные фреймворки для фронтенд разработки. В момент когда фреймворк на базе которого построено приложение выходит из моды — вы вынуждены либо поддерживать устаревшую (legacy) кодовую базу либо стартовать процесс перевода приложения на новый фреймворк.

Оба варианта сопряжены с ущербом для бизнеса. Поддержка устаревшей кодовой базы означает проблемы с наймом новых и мотивацией текущих разработчиков. Перевод приложения на новый фреймворк стоит времени (следственно — денег) но не несет никакой пользы для бизнеса.

Данная статья является примером построения SPA с использованием высокоуровневых принципов дизайна архитектуры. При этом конкретные библиотеки и фреймворки выбираются для удовлетворения ответственностей, определённых желаемой архитектурой.

2. Архитектурные цели и ограничения

Цели:

  1. Новый разработчик может понять назначение приложения при поверхностном взгляде на структуру кода.
  2. Стимулируется разделение ответственностей (separation of concerns) и следовательно модульность кода так что:
    • Модули легко поддаются тестированию
    • Интеграции с внешними сервисами (boundaries) а также грязные хаки и воркэраунды вынесены в отдельные модули и не протянуты через несколько различных файлов. Таким образом смена реализации интеграции с сервисом или отказ от хака становится реалистичной задачей а не «долгосрочным рефакторингом»
  3. Логически связанные вещи размещены недалеко друг от друга в структуре файлов. Вот хорошая статья, которая иллюстрирует разницу между структурированием кода по техническим слоям и по функциональным ответственностям.
  4. Позволяет реализовать слабую связность между модулями. Как следствие изменение реализации модуля (или его замена) не должна приводить к изменению кода, использующего данный модуль.
  5. Механики взаимодействия модулей не приводят к недопустимым проблемам с производительностью.
  6. Зависимости от библиотек не торчат сквозь всю кодовую базу. Они удерживаются внутри ограниченного числа модулей, ответственных за интеграцию с конкретной библиотекой.

Ограничения:

Приложение должно работать в браузере. Следовательно оно должно быть написано с использованием (или скомпилировано в) HTML+CSS для определения статического интерфейса и JavaScript для добавления динамического поведения.

3. Ограничим тему данной статьи

Существует большое количество архитектурных подходов к структурированию кода. Наиболее распространенные на данный момент: слоеная (layered), луковичная (onion) и шестигранная (hexagonal). Беглое сравнение было дано в моей предыдущей статье.

Данная статья ограничивается слоем представления в терминологии слоеной/луковичной архитектур поскольку большинство SPA занимается исключительно отображением данных. Таким образом слои домена (domain) и приложения (application) могут быть проигнорированы. Как следствие, наиболее естественный способ понять назначение такого приложения — получить обзорное представление о слое представления.

Тем не менее данная статья определяет механики взаимодействия со слоями приложения и домена, в случае когда такие слои присутствуют.

Интересно отметить что в случае отсутствия вышеупомянутых слоев приложение напоминает классическую шестигранную структуру (также называемую Ports and Adapters) в которой представление является приложением. Взгляните на интеграцию с localStorage в TodoMVC примере созданном в качестве иллюстрации к данной статье (папка boundaries/local-storage).

4. Структура файлов. Как заставить SPA кричать?

Рассмотрим типичный онлайн магазин. Приблизительно так он мог бы быть нарисован на салфетке владельцем бизнеса:

Рисунок 1: типичный онлайн магазин, нарисованный на салфетке

Каким может быть наиболее кричащий способ структурировать кодовую базу? На рисунке 2 все страницы отражены как папки.

Рисунок 2: структура папок верхнего уровня, отражающая страницы определённые на рисунке 1

Заметим что мы добавили папку ‘shared’ как место где будут определены общие UI блоки, такие как шаблон, панель навигации, корзина.

Наши страницы построены из логических (и видимых) частей. Пока что назовем их ‘блоками’ и положим в папку с именем ‘parts’. Посмотрим что получилось (рисунок 3).

Рисунок 3: размещение вложенных блоков внутри подпапки ‘parts’

Как видно, вложенность выглядит отвратительно уже для второго уровня для страницы ’goods catalogue’. Путь ‘goods-catalogue/parts/goods-list/parts/good-details.js’ уже на границе адекватной длины пути к файлу. При том что в реальных приложениях два уровня вложенности — далеко не предел.

Давайте избавимся от папок «parts» в файловой структуре. Посмотрим на рисунок 4.

Рисунок 4: вложенные блоки вынесены из папок ‘parts’

Теперь внутри пути ‘goods-catalogue/goods-list’ находится три файла. goods-list.js (родительский) — расположен между файлами, определяющими вложенные в него блоки. В реальных проектах, учитывая кол-во разнородных файлов (js, html, css) это приводит к невозможности разделить файлы, определяющие текущий блок и файлы, отвечающими за вложенные в него блоки.

Решение:

  1. Если конкретный блок определяется несколькими файлам — создаем для него папку.
    • goods-list является блоком и состоит из более чем одного файла, потому для него создана папка.
    • filters является блоком состоящим из одного файла, потому для него не создана отдельная папка.
  2. Если конкретный блок (неважно из одного файла или из нескольких) является вложенным блоком — добавим к названию файла префикс «. Таким образом все вложенные блоки будут подняты к верху папки в файловом обозревателе.
    • _goods-list folder является вложенным блоком относительно goods-catalogue соответственно к названию папки добавлен префикс.
    • goods-list.js является частью определения блока _goods-list соответственно префикс не добавлен.
    • _good-details.js является вложенным блоком относительно _goods-list соответственно префикс добавлен.

Рисунок 5: использование префикса «_» для разделения вложенных блоков от их родителей

Готово! Теперь открывая папку с блоком мы можем сразу же увидеть и открыть основной файл, определяющий данный блок. После чего при необходимости перейти к вложенному блоку. Обратите внимание что папка pages была переименована в components на рисунке 5. Так сделано поскольку страницы и блоки логически являются разными вещами но в терминологии HTML и то и другое может бы представлено как component. С этого момента папка components является основной папкой нашего приложения, «домом» для слоя представления.

5. Язык разработки. JavaScript?

Единственный язык который может быть выполнен в браузере это JavaScript. Существует множество статей посвященных его несуразности. Вы можете посмеяться о нем (тайм код 1-20), но это только веселая часть...

Важнее то, что новые возможности постоянно добавляются к языку. Спецификация обновляется каждый год. Новый фичи проходят 4-этапный процесс ревью перед тем как попасть в спецификацию. Однако зачастую, они реализуются браузерами ещё до прохождения через все 4 этапа. И довольно часто сообщество и авторы библиотек начинают использовать определенные фичи до того как они попадают в спецификацию. К примеру, декораторы стали широко применяться в 2015 году, но до сих пор не являются частью спецификации. С другой стороны, зачастую бизнес требует работоспособности приложения в устаревших браузерах, которые априори не поддерживают новых языковых возможностей.

Потому даже при использовании чистого JavaScript разработчик вынужден использовать транспилятор (babel) с тем чтобы получить JavaScript, совместимый с браузерами из «современного и модного» JavaScript. Поскольку использование транспилятора и соответствующее замедление сборки приложения неизбежно — нет причин игнорировать другие, более предсказуемые и более функциональные языки программирования.

Глубокий анализ возможных опций лежит вне границ данной статьи, но мой персональный выбор — TypeScript поскольку он:

  • Обеспечивает проверку типов на этапе компиляции
  • Будучи над-множеством JavaScript, может выполнять JavaScript код без дополнительного интеграционного кода
  • Определения типов (typings) могут быть добавлены поверх существующего JavaScript кода без его изменения. Благодаря простоте этой возможности, большинство существующих npm пакетов уже покрыты тайпингами. Таким образом вы можете использовать эти пакеты так, как будто бы они являются TypeScript пакетами. Соответственно их использование также является типо-безопасным.

Хинт: рекомендую посмотреть в сторону asm.js, blazor и elm если вы заинтересованы в других опциях

6. Требования к дизайну приложения

Давайте вспомним ограничения, накладываемые браузерами: HTML, CSS, JavaScript. Также вспомним структуру файлов, определенную в разделе 4: дерево директорий, отражающее дерево визуальных элементов.

Таким образом первой целью [6.1] будет возможность определения компонентов средствами HTML и CSS и их последующее переиспользование другими компонентами.

Существенный недостаток чистого HTML состоит в том что он не типизирован. Существует достаточное количество движков шаблонизации, таких как underscore.js, handlebars.js. Однако все они принимают на вход строки, что ограничивает нас в проверке корректности используемых в шаблоне данных на этапе компиляции приложения.

Таким образом второй целью [6.2] является возможность определить TypeScript интерфейсы отражающие все свойства, используемые в шаблоне (компоненте). После чего на этапе компиляции выбросить исключение в случае если в разметке компонента происходит обращение к неопределенному свойству.

Каждый UI элемент может выглядеть по разному в зависимости от переданных ему данных. HTML элементы принимают данные в виде HTML атрибутов. Этого достаточно для статической разметки. Для динамически изменяемой разметки нам необходимы некоторые хранилища для данных. В этих хранилищах данные будут изменяться в зависимости от действий пользователя на странице. В то же время, мы не должны потерять возможность передавать данные в компоненты в виде атрибутов.

Таким образом третьей целью [6.3] является возможность компонентов принимать данные из атрибутов и из хранилищ одновременно. Компоненты должны быть перерисованы при изменении любой части принимаемых данных.

Четвертой целью [6.4] станет определение требований к таким хранилищам:

  • Должна существовать возможность использовать хранилище несколькими компонентами, каждый из которых отображает лишь часть общего набора данных.
  • Должна существовать возможность создавать хранилища вместе с конкретным компонентом. Это необходимо для случая когда различные экземпляры компонента автономны друг от друга и должны иметь различные наборы данных.
  • Хранилища должны иметь возможность использовать сервисы и функции слоев Domain и Application. Во избежание сильной связности между хранилищем и границами приложения, сервисы должны быть использованы с помощью механизма Dependency Injection. Хранилища должны ссылаться только на интерфейсы.

И последнее — мы не хотим чтобы данные внутри хранилищ были публичными во избежание нежелательного изменения данных в процессе рендеринга. Хранилища должны быть ответственны за свою целостность. Компоненты же, в свою очередь, должны быть ничем большим чем строго-типизированные-и-оптимизированные-html-шаблоны. Для достижения подобного разделения хранилища должны инкапсулировать данные внутри себя и предоставлять методы для работы с этими данными. Другими словами, хранилища должны быть классическими экземплярами классов.

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

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

Таким образом, пятая цель [6.5] — позволить хранилищам данных быть определенными как классические TypeScript классы. Обозначить механику определения среза данных, используемого конкретным компонентом.

Держа эти цели в голове, давайте перечислим необходимые логические блоки кода:

  • Компоненты (Components) — строго типизированные HTML шаблоны + CSS стили
  • Модели вида (ViewModels) — классы, инкапсулирующие состояние данных, используемое компонентом (и всей иерархией компонентов под ним).
  • Фасады моделей вида (ViewModel facades) — ограничивают видимость свойств модели вида теми, которые используются в конкретном компоненте.

Рисунок 6: желаемая структура кода в слое представления

  • Не-пунктирные стрелки отражают рендеринг компонентов родительскими компонентами. Направление стрелки отражает направление передачи атрибутов.
  • Пунктирные линии отражают зависимости одних логических кусков кода от других (ссылки).
  • Блоки с зеленой рамкой — границы модуля. Каждый модуль/подмодуль отражен выделенной под него папкой. Общие модули лежат в папке «shared».
  • Голубые блоки — модели вида. Модели вида определены по штуке на модуль/подмодуль.

Что упущено? Заметьте как модели вида на рисунке 6 не имеют никаких параметров. Это всегда справедливо для модулей верхнего уровня (страниц) и глобальных моделей вида. Но подмодули зачастую зависят от параметров, определённых в процессе работы с приложением.

Обозначим шестую цель [6.6] — позволить атрибутам подмодуля быть использованными моделью вида этого подмодуля.

Рисунок 7: атрибуты передаются не только в корневой компонент модуля но и в его модель вида

7. Техническая реализация

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

7.1. Компоненты

Для отрисовки строго-типизированной разметки можно использовать синтаксис tsx (типизированный jsx). Рендеринг tsx поддерживается различными библиотеками, такими как React, Preact and Inferno. Tsx НЕ является чистым HTML, тем не менее он может быть автоматически сконвертирован в/из HTML. Потому зависимость от tsx мне кажется допустимой т.к. в случае миграции на чистый HTML, значительная часть работы может быть выполнена автоматически.

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

Хинт: в последние годы функциональные компоненты в виде чистых функций вышли из моды в сообществе React. Использование react hooks наделяет функциональные реакт компоненты сайд-еффектами и поощряет смешивание рендера с логикой управления состоянием. Хуки являются специфическим API для React и не должны использоваться при разработке в подходе, описанном в данной статье.

Другими словами, компоненты лишены состояния. Представим их через выражение UI=F(S) где

  • UI — видимая разметка
  • F — определение компонента
  • S — текущее значение данных внутри модели вида (здесь и далее — вьюмодели)

Пример компонента может выглядет так:

interface ITodoItemAttributes {
  name: string;
  status: TodoStatus;
  toggleStatus: () => void;
  removeTodo: () => void;
}

const TodoItemDisconnected = (props: ITodoItemAttributes) => {
  const className = props.status === TodoStatus.Completed ? 'completed' : '';
  return (
    <li className={className}>
      <div className="view">
        <input className="toggle" type="checkbox" onChange={props.toggleStatus} checked={props.status === TodoStatus.Completed} />
        <label>{props.name}</label>
        <button className="destroy" onClick={props.removeTodo} />
      </div>
    </li>
  )
}

Этот компонент отвечает за отрисовку одного todo элемента внутри TodoMVC приложения.

Единственная зависимость в этом коде — это зависимость от синтаксиса JSX. Соответственно этот компонент может быть отрисован различными библиотеками. С таким подходом замена библиотеки для отрисовки все еще не является бесплатной, но является «реалистичной».

Итого мы достигли целей [6.1] и [6.2].

Хинт: я использую react для TodoMVC приложения приведенного в качестве примера.

7.2. Модели Вида (вьюмодели)

Как было сказано ранее, мы хотим чтобы вьюмодели были написаны в виде TypeScript классов с тем что-бы:

  • Обеспечивать инкапсуляцию данных.
  • Предоставлять возможность взаимодействия со слоями domain/application посредством механизма dependency injection.

Однако, классы не предоставляют встроенных механик перерисовки компонентов использующих данные, инкапсулированные экземпляром класса.

Применим принципы реактивного интерфейса (reactive UI). Подробное описание этих принципов приведено в этом документе. Данный подход был впервые представлен в WPF (C#) и назван Model-View-ViewModel. В JavaScript сообществе, объекты предоставляющие доступ к обозреваемым (observable) данным чаще называются хранилищами (stores) следуя терминологии flux. Отмечу что хранилище это очень абстрактный термин, он может определять:

  • Глобальное хранилище данных для всего приложения.
  • Доменный объект, инкапсулирующий логику логику бизнеса и не привязанный к конкретному компоненту но и не являющийся глобальным.
  • Локальное хранилище данных для конкретного компонента или иерархии компонентов.

Таким образом любая вьюмодель является хранилищем, но не каждое хранилище является вьюмоделью.

Определим ограничения к реализации вьюмоделей:

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

Я использую mobx декораторы для того, чтобы сделать поля класса обозреваемыми. Пример вьюмодели:

class TodosVM {
    @mobx.observable
    private todoList: ITodoItem[];

    // use "poor man DI", but in the real applications todoDao will be initialized by the call to IoC container 
    constructor(props: { status: TodoStatus }, private readonly todoDao: ITodoDAO = new TodoDAO()) {
        this.todoList = [];
    }

    public initialize() {
        this.todoList = this.todoDao.getList();
    }

    @mobx.action
    public removeTodo = (id: number) => {
        const targetItemIndex = this.todoList.findIndex(x => x.id === id);
        this.todoList.splice(targetItemIndex, 1);
        this.todoDao.delete(id);
    }

    public getTodoItems = (filter?: TodoStatus) => {
        return this.todoList.filter(x => !filter || x.status === filter) as ReadonlyArray<Readonly<ITodoItem>>;
    }
	
/// ... other methods such as creation and status toggling of todo items ...
}

Обратите внимание что мы ссылаемся на mobx напрямую, однако декораторы не присутствуют в теле методов.

Я продемонстрирую как абстрагировать реактивность и убрать зависимость от mobx в следующей статье. Пока что посчитаем достаточным обращаться к декораторам через пространство имен mobx. При таком ограничении заменить декораторы на декораторы из другой библиотеки можно будет с помощью автоматизированного скрипта.

Также обратите внимание что конструктор вьюмодели принимает первый аргумент типа {status: TodoStatus}. Это позволяет удовлетворить цели [6.6]. Тип должен совпадать с типом определяющим атрибуты корневого компонента модуля. Ниже обобщенный интерфейс вьюмодели:

interface IVMConstructor<TProps, TVM extends IViewModel<TProps>> {
    new (props: TProps, ...dependencies: any[]) : TVM;
}

interface IViewModel<IProps = Record<string, unknown>> {
    initialize?: () => Promise<void> | void;
    cleanup?: () => void;
    onPropsChanged?: (props: IProps) => void;
}

Все методы вьюмодели необязательны. Они могут быть определены для:

  • Выполнения кода при создании вьюмодели
  • Выполнения кода при удалении вьюмодели
  • Выполнения кода при изменении атрибутов (под-)модуля.

В отличии от компонентов, вьюмодель хранит свое состояние (является statefull). Она должна быть создана когда модуль появляется на странице и удалена как только модуль исчезает со страницы.

Как показано на рисунке 7, точкой входа для модуля является его корневой компонент. Таким образом вьюмодель должна быть создана когда корневой компонент модуля добавлен в структуру DOM(mounted) и удалена когда он удаляется со страницы(unmounted). Решить эту задачу можно с помощью техники компонентов высшего порядка (higher order components).

Определим тип функции:

 type TWithViewModel = <TAttributes, TViewModelProps, TViewModel>
  (
    moduleRootComponent: Component<TAttributes & TViewModelProps>,
    vmConstructor: IVMConstructor<TAttributes, TViewModel>,
  ) => Component<TAttributes>

Эта функция возвращает компонент высшего порядка над moduleRootComponent, который:

  • Должен обеспечить создание вьюмодели перед созданием и монтированием (mount) компонента.
  • Должен обеспечить зачистку(удаление) вьюмодели при демонтировании (unmount).

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

Пример использования данной функции:

const TodoMVCDisconnected = (props: { status: TodoStatus }) => {
    return <section className="todoapp">
        <Header />
        <TodoList status={props.status} />
        <Footer selectedStatus={props.status} />
    </section>
};

const TodoMVC = withVM(TodoMVCDisconnected, TodosVM);

В разметку корневой страницы приложения (либо роутера, зависит от того что как построено ваше приложение), результирующий компонент будет вставлен как <TodoMVC status={statusReceivedFromRouteParameters} />. После чего, экземпляр TodosVM становится доступным для всех под-компонентов внутри компонента TodoMVC.

Важно что реализация создания вьюмодели, а также доступности вьюмодели для дочерних компонентов, скрыта внутри withVM.

  • TodoMVCDisconnected компонент не зависит от библиотеки рендера
  • TodoMVC компонент может быть прорендерен в компоненте, не зависящем от библиотеки рендера
  • TodosVM ссылается только на декораторы. Потому, как описано выше, её отвязка от mobx реальна.

Хинт: в реализации из примера, функция withVM зависит от react context API. Вы можете попробовать реализовать аналогичное поведение в обход контекст апи. Важно, что реализация должна быть синхронизирована с реализацией доступа к вьюмодели из фасадов вьюмоделей — смотрите описание функции connectFn в следующем разделе.

7.3. Фасады вьюмоделей

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

Попробуем вместо классических «фасадов» использовать функции, принимающие вьюмодель (или несколько вьюмоделей) и возвращающие набор функций/данных, необходимых конкретному компоненту. Назовем их функциями среза (slicing function). Что если такая функция будет получать атрибуты компонента, который она обслуживает, в качестве последнего аргумента?

Рисунок 8: передача атрибутов компонента фасаду вьюмодели (функции среза/slicing function)

Посмотрим на синтаксис (в случае одной вьюмодели):

type TViewModelFacade = <TViewModel, TOwnProps, TVMProps>(vm: TViewModel, ownProps?: TOwnProps) => TVMProps

Выглядит очень похоже на функцию connect из библиотеки Redux. С той лишь разницей что вместо аргументов mapStateToProps, mapDispatchToActions и mergeProps мы имеем один аргумент — функцию среза, которая должна вернуть данные и методы одним объектом. Ниже пример функции среза для компонента TodoItemDisconnected и вьюмодели TodosVM.

const sliceTodosVMProps = (vm: TodosVM, ownProps: {id: string, name: string, status: TodoStatus; }) => {
    return {
        toggleStatus: () => vm.toggleStatus(ownProps.id),
        removeTodo: () => vm.removeTodo(ownProps.id),
    }
}

Заметка: Я назвал аргумент функции, содержащий атрибуты компонента ‘OwnProps’ что-бы приблизить его к терминологии применяемой в react/redux.

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

type connectFn = <TViewModel, TVMProps, TOwnProps = {}>
(
    ComponentToConnect: Component<TVMProps & TOwnProps>,
    mapVMToProps: TViewModelFacade<TViewModel, TOwnProps, TVMProps>,
) => Component<TOwnProps>

const TodoItem = connectFn(TodoItemDisconnected, sliceTodosVMProps);

Отрисовка такового компонента в списке todo элементов: <TodoItem id={itemId} name={itemName} status={itemStatus} />

Заметьте что connectFn скрывает детали реализации реактивности:

  • Она берёт компонент TodoItemDisconnected и функцию среза sliceTodosVMProps — обе не знающие ничего о реактивности и о библиотеке для рендеринга JSX.
  • Она возвращает компонент, который будет перерисован реактивно как только данные, инкапсулированные вьюмоделью, изменяться.

Смотрите на реализацию функции connectFn для TodoMVC приложения, сделанного в качестве примера.

8. Заключение

Итого весь код, относящийся к конкретным бизнес задачам приложения, независим от фреймворков. TypeScript объекты, функции, TSX — это все к чему мы привязаны.

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

Все же, может ли слой представления быть полностью независим от фреймворков в реальном приложении?

Для того что-бы убрать ссылки на mobx, react и mobx-react из слоя представления, нужно сделать немного больше:

  • Абстрагироваться от mobx декораторов
  • Абстрагировать все фреймворко-зависимые библиотеки, используемые слоем представления. К примеру TodoMVC зависит от библиотек react-router и react-router-dom.
  • Абстрагироваться от синтетических событий, специфичных для конкретной библиотеки, отрисовывающей JSX.

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

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

P.S. Сравнение рассмотренной структуры и ее реализации с популярными фреймворками для разработки SPA:

  • В сравнении со связкой React/Redux: вьюмодели заменяют reducers, action creators и middlewares. Вьюмодели содержат состояние (являются stateful). Нет time-travel. Множество хранилищ. Отсутствие просадки производительности вызванной наличием большого числа использований функции connect с какой то логикой внутри. Redux-dirven приложения становятся все медленнее и медленнее с течением времени из за добавления новых connected компонентов в приложение. При этом не существует какого то конкретного ботлнека, устранением которого можно было бы исправить ситуацию.
  • В сравнении с vue: строго типизированные представления благодаря TSX. Вьюмодели являются обычными классами и не требуют использования функций сторонних библиотек, равно как не обязаны удовлетворять интерфейсу, определенному сторонними фреймворками. Vue.js заставляет определять состояние внутри определенной структуры имеющей свойства ‘data’,’methods’, и т.д. Отсутствие vue-специфических директив и синтаксиса привязки к модели.
  • В сравнении с angular: строго типизированные представления благодаря TSX. Отсутствие angular-специфических директив и синтаксиса привязки к модели. Инкапсуляция данных внутри вьюмоделей в отсутствие двусторонней привязки данных (two-way data binding). Хинт: для определенных сценариев, таких как формы, двусторонняя привязка данных удобна и полезна.
  • В сравнении с чистым react где управление состоянием выполняется с помощью хуков (hooks, такие как useState/useContext):
    Лучшее разделение ответственностей. Вьюмодели могут восприниматься в терминологии реакта как контейнер компоненты, которые лишены возможность рендерить что-либо и являются ответственными исключительно за работу с данными. Нет необходимости:
    • следить за последовательностью вызова хуков.
    • отслеживать зависимости хуков useEffect внутри ‘deps’ массива.
    • проверять смонтирован ли все еще компонент после каждого асинхронного действия.
    • следить что замыкания из предыдущих рендеров не используются внутри обработчика хука эффекта.
    Как любая технология, хуки (и в частности — useEffect) требует разработчика следовать некоторым рекомендациям. Эти рекомендации не являются частью интерфейсов, но приняты как «подход», «модель мышления (mental model)» или «стандартные практики (best practices)». Прекрасная статья про использование хуков от члена команды разработки react. Прочитайте ее и ответьте себе на два вопроса:
    • Что вы получаете используя хуки?
    • Как много правил, не контролируемых компилятором/линтером и сложно отслеживаемых на ручном код ревью, нужно соблюдать чтобы использование хуков оставалось предсказуемым?
      Если второй список получился больше первого — это хороший сигнал что относится к хукам стоит с большой осторожностью. Хинт: пример фрустрации от хуков
  • В сравнении с react-mobx интеграцией. Структура кода не определяется пакетом react-mobx и не предлагается документацией к нему. Разработчик должен придумать подход к структурированию кода сам. Рассмотренную в статье структуру можно считать таким подходом.
  • В сравнении с mobx-state-tree: Вьюмодели являются обычными классами и не требуют использования функций сторонних библиотек, равно как не обязаны удовлетворять интерфейсу, определенному сторонними фреймворками. Определение типа внутри mobx-state-tree опирается на специфические функции этого пакета. Использование mobx-state-tree в связке с TypeScript провоцирует дублирование информации — поля типа объявляются как отдельный TypeScript интерфейс но при этом обязаны быть перечислены в объекте, используемом для определения типа.

Оригинал статьи на английском языке в блоге автора

👍НравитсяПонравилось5
В избранноеВ избранном7
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Не знаю кто придумал «запихивать HTML» в код, но моя внутренней жаба меня от этого давит. Для меня это тоже самое что запихивать весь JavaScript код в теги script и вставлять в HTML. И то и то выглядит коряво. Бекендовые фреймверки в массе своей все пытаются по максимуму отделить вёрстку от логики. В фронтэндовых диаметрально разные подходы, и вообще впечатление хаоса и анархии.

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

Где, например, в Razor компонентах (asp.net mvc) отделяется логика и верстка? Там точно также перемешивается. Blazor этот же подход перенял для фронэнда. И вот тут здравствуй JSX. Но это другое, нежели чем специальный синтаксис в шаблонах компонентов (ngFor или v-for).

Учитывая насколько hype-driven является разработка софта в наше время, можно быть уверенным в том что через несколько лет будут существовать новые модные фреймворки для фронтенд разработки.

А может быть не надо гоняться за каждым новым фреймворком? Я в 2014 выучил реакт — этого оказалось достаточно, чтобы и в 2021м делать новые фронтенды. Реакт можно заменить на ангуляр2 и 2015 — ничего бы не изменилось.

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

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

Совершенно не надо гоняться за новыми фреймворками.

Однако если бы вы попробовали найти в 2020 году человека на саппорт проекта написанного на backbone или условном knockout вы бы ощутили большущую боль. Это одна из причин переписывания — тупо нет возможности найти людей на поддержку.

Равно как через 2-3 года вы едва ли найдёте могикан на саппорт предложений на редаксе. А уже сейчас новички плюются желчью от ангуларжс (который первый).

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

Есть и другие причины для смены фреймворка: уязвимости, недостаточная производительность.

Конкретно по вашему кейсу: реакт не фреймворк и что-бы написать «приложение на реакте» вам нужно подрубить к нему как минимум что то для управления состоянием и роутинга, а также определить желаемую структуру(расположение) кода. Эти задачи, за исключением роутинга, и решаются в статье. Я не придумывал и не пытался определить новый фреймворк. И мне жаль если статья создаёт ложное впечатление попытки увелечения ентропии в среде ФЕ разработки.

А если подходить наподобие как в той статье к вопросу SPA — приложения без фреймворка, то надо настолько хорошо продумать структуру, механизмы, чтобы потом это не превратилось в кучу говнокода, в которой разбираться сможет только «создатель». Так случается в шарашкиных продуктовых конторах. Примеры видел и это печально. Там еще сложнее разработчика найти сейчас, чем на knockout.js А еще ж проблема № 1 — документация. Если на проекте, где есть фреймворк знакомый еще можно что-то осилить. А если есть и свой «фреймворк» и куча незадокументированной логики — это дно.

Все верно, любой переиспользуемый код должен быть хорошо продуман. Любой подход, определяющий структуру приложения, должен быть задокументирован. Так и живём :)

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

Колосальна стаття, може б краще було її поділити. Весь текст я не посилив, може тому не побачив розділ про роутинг, про те як запити будуть звязані з view, або про ізоляцію стилів, або про компіляцію, або про дев флоу, соурс мапи, чи кодогенерацію, або ... сучасні популярні фреймворки вирішують багато питань. Дякую за старання, в наших мізках щось зашевелилось)

Честно — я сокращал и урезал как мог.
В итоге разделить просто не получалось — без первых разделов не понятна мотивация разделов 5/6/7. Без разделов 5/6/7 не понятно зачем лить воду в предидущих.

Роутинг не упомянут и не является частью статьи. Вы вольны использовать whatever suites your needs. В приведённом примере используется декларативный роутинг средствами react-router т.к. ради двух ссылок большего не надо. На последнем коммерческом проекте написанном в данном подходе роутинг реализовывался через интерфейс с методами navigate/buildNavigationLink для обеспечения строгой типизации роутов. Под капотом использовалась одна из популярных либ для того что-бы не изобретать велосипед для работы с history и для сериализации параметров запроса.

Запросы связываются с вью через вьюмодели. Т.е. любые данные, необходимые для вью, должны быть выставлены во вьюмодель (либо вьюмодель их должна запросить). В приведённом примере данные беруться из DAO обьекта (интерфейса), который реализован через localStorage. Если вместо локал стореджа хотим использовать апи — пишем новую реализацию интерфейса ITodoDAO, работающую с апи.
Нюанс который меня немного бесит: для того что-бы 1 в 1 приложение матчилось с классическим TodoMVC приложением я определил в ITodoDAO наличие синхронных методов. Они все, конечно же, должны быть асинхронными. Но повторюсь — в классическом TodoMVC асинхронности нет, а целью было сделать именно его, что-б можно было лоб в лоб сравнивать с другими реализациями.

Компиляция/сорсмапы и дев флоу не относятся напрямую к дизайну приложения и никак не влияют на переиспользуемость кода (ради чего собсно статья писалась).
Тем не менее: если вы посмотрите в пример на который ссылается статья — то увидите что компиляция делается роллапом, сорсмапы генерятся. Ничего необычного, но если будут вопросы — задавайте, буду рад ответить.

Кодогенерация при данным подходе просто не нужна, поскольку boilerplate отсутствует. Разработчик определяет вью и функцию среза в виде чистой функции, вьюмодель в виде обьекта. Для того что-б єто все «уселось» в иерархию компонентов дёргается две функции — withVM и connectXXX. Т.е. именно «кодогенерировать» просто нечего, и слава богу.

апд. вот подумалось что теоретически можно сделать команду для генерации вью и функции среза «одновремэнно» по пред-заданному интерфейсу пропсов. Подумаю об этом.

В целом плюс разработки «в рамках конкретного фреймворка» именно в том, что у разработчика есть много документации, от которой он может начать работать за условных 15 минут. Если нужно написать на коленке что-то что будет «просто работать сейчас» — то нечего парится подбором подходящей структуры, конечно.
Если же клиент заказал приложение которое будет жить дольше чем 3-4 кода — разработка с завязкой на фреймворк это опасная идея.

Отличная статья, но хотел бы несколько несущественных замечаний высказать (не срачик ради, а сугубо как попытка конструктивной критики)
1. Использование декораторов — в данный момент я бы не стал завязываться на существующую «нестандартизированную» реализацию, так как возможно что всё придётся переписать после того как декораторы в ECMAScript таки пройдут дальше чем stage-2.
2. Mobx 6 — в документации как раз поэтому и рекомендуют использовать mobx функции взамен декораторов.
3. Всё же в приведённой вами реализации мы всё таки в той или иной степени завязываемся на Mobx. Конечно так же можно сказать что мы завязываемся и на реакт, но я имею в виду именно доменный слой (если его можно так назвать)

В целом ещё раз огромное спасибо за не тривиальную статью. И рекомендую почитать вот эту статейку medium.com/...​is-all-about-e1568a9053c4 Видимо расшифровка доклада frontfest.es/...​hitecture_FrontFest20.pdf

Спасибо за диалог!

По поводу завязки на мобх — статья и так получилась сложночитаемой из за обьёма. Мне хочется что-б у людей хватало headroom на чтение. Посчитав отвязку от мобх тривиальной задачей я решил не включать это в данную статью. Оставил на потом если будет интерес (вот вы оставили комментарий — видимо есть смысл сделать это «на потом»:)). Но это лирика и нюансы.

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

Использование доменных сервисов (и как следствие — доменных обьектов) подразумевается через внедрение их в конструктор вьюмоделей (DI). Реализация же доменных сервисов и обьектов может быть, а может и не быть реактивной. Это решение уже нужно принимать исходя из специфики приложения. Для TodoMVC создавать доменный слой было бы блажью и запутывало бы читателя («нафига оно здесь, почему так сложно?»).

Развернутый комментарий не для того что-б показать что «я тут самый правый» а потому что
несколько раз встречал проблемы из за того что разработчики ошибочно начинали дизайнить типа-как-слой-домена решая сугубо презентационные задачи. И как следствие — невероятное усложнение кода. Как написано в статье, бОльшая часть ФЕ приложений вообще не требует слоя домена: хватает елементарного хранения данных на вьюмоделях.

Лично мой подход: я начинаю дизайнить доменные обьекты когда в общении с заказчиком начинают всплывать термины и зависимости, не привязанные к конкретному УИ региону. Т.е. когда я вижу что бизнес мыслит некоторыми абстракциями категориями а не «крестик нажал — елемент из списка вышел вон».

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

Понял вашу точку зрения и действительно отвязка от Mobx в рамках данной статьи могла бы лишь навредить, потому что статья и так получилась достаточно хардовая (особенно в разрезе большинства статей о фронте и реакте в частности)

Я сам уже некоторое время нахожусь в поисках оптимальной архитектуры для фронтенд (реакт) приложений, но пока что склоняюсь больше к использованию redux-way в плане использования событий (actions), так как опыт применения Mobx оказался не столь радужным как ожидалось и приложуха получила достаточно большую связанность, хотя я тоже использовал DI (react-ioc). Но , возможно я просто не правильно его приготовил )))

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

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

Это недоработка примера от того что мне не нужна была данная возможность для реализации этого примера. А на продакшн проекте написанном в таком ключе эта возможность была нужна 2-3 раза и хватило просто последовательного использования 2х функций среза вместо одной (сначала вяжем к одной ВМ потом к другой).

Впрочем, мельком я на это ограничение в статье указал Посмотрим на синтаксис (в случае одной вьюмодели):

Но такие комменты стимулируют допилить, да.

Кстати любопытная статья, пойду обсуждать на медиум. Но джикверивский адаптер там просто невалиден и вводит в заблуждение, т.к. в отсутствие реактивности изменение данных никак не приведёт к изменению УИ.
Вуевский (это же вуй?) же в принципе должен быть жизнеспособен с той лиш разницей что на 89 слайде должна быть стрелочка от State к Domain. Стейт же хранит доменные обьекты и именно потому в принципе возможна перерисовка интерфейса. А эта стрелочка сразу же приводит к тому что каждый екшн стартует две ветви кода:
1) мутации стейта
2) изменения в доменных обьектах

Что видно из екшна makeMove (страница 99).
1) Сначала дёргается юзкейс, который изменяет состояние доменных обьектов.
2) А после этого кидается екшн «окончания действия» — фактически мутация. Который, subsequently, может привести к ещё одному изменению состояния доменных обьектов И/ИЛИ изменению стейта, не отрадженному в доменной модели.

Здесь нет принципиальной разницы с mvvm подходом, описанном в моей статье, т.к. метод вьюмодели, равнозначно екшну, может дернуть метод доменного обьекта и после этого дернуть ещё один метод той же вьюмодели.
Но моя статья и не пытается создать иллюзию того что взаимодействие с доменом полностью инкапсулировано в юзкейсах (или аппликейшн сервисах, называть можно как угодно). Не люблю ложные обещания типа «смотрите как можно» -> «вчитался» -> «ой, не можно». Вместо этого я определил механих функций среза, специально созданных что-бы ограничить доступ компонентов к функциям вьюмодели (в случае с наличием доменных обьектом — и к ним в том числе).

Да, представленная мною статья не идеал, я её упомянул лишь как другой подход для расширения кругозора. Но у вас, как я вижу глаз намётан и вы сразу выявили те косяки которых я не увидел или не в читался.

В любом случае благодарю за диалог!

Дякую за змістовну статтю!

Коли прочитав «кричать» і про файли — прямо моментально Uncle Bob в пам’яті виплив.
Тільки як ось коммент писати почав — побачив що посилання на його ж сайт.
Ось запис його докладу де мів красномовно про «screaming files» каже: vimeo.com/43612849

Дякую, не бачив цієї статті. Лише пост в його блозі. Взагалі чудовий підхід. На початку розробки важко зрозуміти наскільки важко-підримуваним стає код структурований за технічною відповідальностю, а наприкінці розробки — вже запізно. Тому must read for everyone

Подписаться на комментарии