В этом посте я попытаюсь формализовать и систематизировать своё собственное понимание, какой должна быть структура SPA-приложений. Это очень субъективное изложение, отражающее мой собственный опыт. Оно относится к определённому классу веб-приложений (SPA, PWA) и не претендует на универсальность.
Какие веб-приложения не относятся к рассматриваемому мной классу:
headless-приложения (у которых нет UI)
микросервисы и микрофронтенды
высоконагруженные приложения
статические страницы с использованием внешних библиотек
SSR сайты
В контексте данной статьи SPA-приложение - это классическое клиент-серверное приложение, где клиент существует в браузере (как правило, в пределах одной страницы) и взаимодействует с сервером посредством HTTP-запросов. Приложение разрабатывается в виде набора npm-пакетов в стиле “модульный монолит”. Серверная часть реализована на движке Node.js.
Непреодолимые ограничения
SPA - это прежде всего браузерное приложение. Все браузерные приложения “живут” в браузере и общаются с внешним миром через набор протоколов (http(s)://, ftp://, ws(s)://, data://, file://, …). Чтобы приложение попало в браузер, оно должно быть загружено из внешнего источника по одному из трёх протоколов (http, https, file) в виде базового HTML-файла, в котором содержится код всего приложения либо описываются ресурсы, которые браузер должен будет загрузить дополнительно.

Поэтому, как бы мы ни крутились, в браузерном приложении должен быть хотя бы один HTML-файл, который и является точкой входа. Лично я придерживаюсь традиции, по которой этот файл носит имя ./index.html.
CDN
Существует множество CDN, которые распространяют различные статические ресурсы:
cdnjs.cloudflare.com
cdn.jsdelivr.net
fonts.googleapis.com
…
Если наше приложение в точке входа загружает нужные ему ресурсы через CDN, то мы вынуждены придерживаться тех правил, которые нам диктует соответствующий CDN. Исторически сложилось так, что для повышения производительности отдельные ресурсы объединяли в файлы (бандлы, спрайты), и конечный разработчик приложения (интегратор) уже имел дело с ними.

Другими словами, если в приложении используются сторонние ресурсы, загружаемые через CDN, то разработчик (интегратор) не имеет возможности повлиять ни на размещение файлов, ни на их наименование. Что, в принципе, является нормальной ситуацией для внешних ресурсов. Если же через CDN распространяются файлы самого проекта, то тут разработчик волен размещать их по своему усмотрению.
NPM-пакеты
До появления Node.js в JS-разработке вопрос пакетов остро не стоял. В браузер можно было загрузить любой бандл, доступный через интернет. Централизованные реестры по учёту существующих JS-библиотек, можно сказать, отсутствовали. NPM изменил правила игры, и теперь хорошим тоном является публикация свободных библиотек в виде npm-пакетов в реестре. Таким образом, самым верхним уровнем группировки кода в веб-приложении является npm-пакет.

Данную группировку можно видеть у CDN jsDelivr:

Код самого веб-приложения также является npm-пакетом (содержит ./package.json в котором прописаны соответствующие пакеты-зависимости):
./project/ ./index.html // точка входа ./LICENCE // лицензия ./package.json // дескриптор npm-пакета ./README.md // описание пакета ./RELEASE.md // история изменений
Static assets
Всю информацию, касающуюся веб-приложения, можно разделить на три большие группы:
данные
код
статические ресурсы (static assets)
В принципе, код тоже в массе своей является статическим ресурсом, и даже данные иногда (например, начальная конфигурация приложения), но термин "static assets" закрепился за файлами стилей, медиа-файлами, шрифтами, шаблонами и т.п. Статические ресурсы в проекте помещают в каталоги с названиями ./public/, ./static/, ./assets/. Я в своих проектах размещаю статические ресурсы в каталоге ./web/:
./project/ ./src/ // исходный JS-код ./web/ // статические ресурсы ./index.html // точка входа ./LICENCE // лицензия ./package.json // дескриптор npm-пакета ./README.md // описание пакета ./RELEASE.md // история изменений
Сборка
Сборка дистрибутива (или дистрибутивов - esm, umd, min, prod, dev) является общепринятой практикой при разработке публичных библиотек или при использовании TypeScript. Для сборки, как правило, используют имена каталогов ./build/ или ./dist/. С учётом конфигурационных файлов для сборщиков наша структура приобретает вот такой вид:
./project/ ./dist/ // результаты сборки ./src/ // исходный JS-код ./web/ // статические ресурсы ./LICENCE // лицензия ./package.json // дескриптор npm-пакета ./README.md // описание пакета ./RELEASE.md // история изменений ./rollup.config.js // Rollup-конфигурация ./tsconfig.json // TS-конфигурация
Дополнительное окружение
При разработке в стиле “модульный монолит”, как правило, есть основной npm-пакет - само веб-приложение, и набор npm-пакетов, являющихся плагинами к нему (а зачастую, параллельно, и к другим приложениям). Есть некоторая разница между npm-пакетом, содержащим код приложения, и npm-пакетом, являющимся плагином. Пакет-приложение подразумевает запуск приложения в виде веб-сервера (для загрузки кода в браузер и обработки запросов к бэку) или в виде консольной команды (например, для выполнения сервисных функций). Пакет-плагин самостоятельно не используется и входит в состав других приложений в виде зависимости.
Поэтому есть, например, такие каталоги, как ./doc/ и ./test/, которые могут относиться как к приложениям, так и к плагинам, а есть такие, которые скорее будут относиться только к приложениям - ./bin/, ./cfg/, ./var/.
На этом этапе можно отобразить структуру каталогов таким образом:
./project/ ./bin/ // уровень приложения, исполняемые скрипты ./cfg/ // уровень приложения, локальная конфигурация (подключение к БД и т.п.) ./dist/ // уровень приложения, результаты сборки ./doc/ // уровень плагина, документация ./etc/ // уровень плагина, дополнительная информация (например, DDL таблиц плагина для формирования общей БД) ./log/ // уровень приложения, логирование работы приложения ./src/ // уровень плагина, исходный JS-код ./test/ // уровень плагина, тесты ./var/ // уровень приложения, временные результаты работы приложения (например, выгрузка данных по-умолчанию) ./web/ // статические ресурсы ...
Front & Back
Раз уж JavaScript можно применять для создания кода, работающего и в браузере (фронт), и на сервере (бэк), а в качестве “модуля” в “монолите” выбран npm-пакет, то есть смысл разделять исходный JS-код на браузерный и node’овский на уровне каталогов. Хотя бы просто потому, что он работает в разном окружении, которое предоставляет коду различный функционал (Web API - в браузере и node-модули на сервере). Так как возможен ещё JS-код, который может работать и в браузере, и на сервере, то в каталоге ./src/ появляются три соответствующих области:
./src/ ./Back/ ./Front/ ./Shared/
Разделение “монолита” на “модули” должно происходить таким образом, чтобы код в отдельном npm-пакете (плагине, модуле) был сильно связанным (high cohesion), а сами пакеты обладали слабым зацеплением друг с другом (loose coupling).
Распределённый характер веб-приложения, где множество клиентов (браузеров) используют единый источник данных (БД на сервере), определяет "сильную связь" между полем на форме в браузере и колонкой в БД для хранения данных этого поля. Поэтому вполне рационально объединять код формы и код операции сохранения данных этой формы в одном npm-пакете, несмотря на то, что первый код работает в одном окружении (Web API), а второй - в другом (nodejs). В конце концов, и то, и другое (и третье - ./Shared/) - это обычный JS.
Разумеется, что эти соображения неприменимы, если фронт написан на JS, а бэк - “на другом хорошем ЯП” (Java, PHP, C#, Go, Python, …). Но если всё приложение целиком написано на JS, то исходный код из каталога ./src/ можно разбить на три группы, по месту его использования:

Каталог ./web/
Каталог для размещения статики может быть в каждом плагине (npm-пакете). Так как приложение само является npm-пакетом, то при сборке все зависимости попадают в каталог ./node_modules/, а статические ресурсы каждого плагина становятся доступными относительно корня приложения по пути ./node_modules/@vendor/plugin/web/. Далее делом техники является создание обработчика для веб-сервера (express, fastify, koa, ...), который может выдавать статику соответствующего плагина из его web-подкаталога.
Если же npm-пакет опубликован в реестре npmjs, то можно обойтись и без обработчика - все файлы пакета, включая содержимое web-каталога, становятся доступными через CDN (например, jsDelivr - https://cdn.jsdelivr.net/npm/@vendor/plugin@latest/web/…).
Группировка ресурсов в каталоге ./web/, как правило, идёт по типу ресурса:
./web/ ./css/ ./font/ ./img/ ./js/ ./media/ ./favicon.ico ./index.html ./manifest.json ./styles.css ./sw.json
Разумеется, что статика используется в основном на фронте, но раздача статики, в силу особенностей веб-приложений (см. “Непреодолимые ограничения”) идёт с сервера (или с CDN).
Каталог ./test/
Есть масса различных типов тестов (юнит-тесты, функциональные, интеграционные и т.д.) и при определенном размере приложения (или даже скорее, при определённом размере команды разработчиков) они все становятся нужными. Я, как правило, разрабатываю свои приложения в 1-2 лица, поэтому основным типом “тестов” у меня является девелоперское окружение для разработки какого-либо класса. Так как в своих приложениях я использую IoC (Constructor Dependency Injection), то вместо того, чтобы поднимать всё приложение для проверки очередной порции правок, я делаю тестовый скрипт, который конструирует экземпляр нужного мне класса и реализует вызов его методов в тестовом окружении (часть зависимостей может быть замокирована или быть реальными - например, соединение с девелоперской версией БД).
В общем, я для себя вижу смысл в трёх тестовых подкаталогах:
./test/ ./dev/ // уровня плагина, тестовое окружение для разработки отдельных объектов (запускаются вручную) ./e2e/ // уровня приложения, для автоматизированной проверки отдельных функций всего приложения в сборе ./unit/ // уровня плагина, для автоматизированной проверки реализации отдельных объектов кода со сложной логикой
Каталог ./src/Shared/
В этом каталоге размещаются JS-исходники, которые могут быть использованы как в браузере, так и в nodejs. На этом уровне разбиение файлов по подкаталогам идёт по типу кода, который содержится в файле. Так у себя я используют примерно такие подкаталоги (типы JS-кода):
./src/Shared/ ./Api/ // содержит описание интерфейсов, которые используются в работе данного плагина и которые должны быть имплементированы в других плагинах или в приложении. ./Di/ // имплементация интерфейсов, заданных в других плагинах (замена интерфейсов их имплементациями происходит на уровне контейнера объектов при внедрении зависимостей) ./Dto/ // описание структур данных, используемых данным плагином или приложением. ./Enum/ //описание кодификаторов, используемых данным плагином или приложением. ./Util/ //утилиты. ./Web/ // описание структур данных, которые используются для общения фронта и бэка. ./Api/ // синхронных POST-запросов от фронта к бэку и ответов бэка на них. ./Event/ // сообщений, передаваемых по SSE-каналу. ./Rtc/ // сообщений, передаваемых по WebRTC-каналу. ./Socket/ // сообщений, передаваемых через WebSocket’ы.
Каталог ./src/Front/
Этот каталог содержит файлы, которые используются только на фронте и завязаны на Web API, предоставляемый браузером. В чём-то он повторяет структуру Shared-каталога, но также добавляет и свои собственные типы, специфичные именно для фронта:
./src/Front/ ./Api/ // содержит описание интерфейсов, которые используются в работе данного плагина и которые должны быть имплементированы в других плагинах или в приложении. ./Convert/ // содержит код для конвертации Shared DTO во Front DTO и обратно. Этот слой кода позволяет уменьшить зацепление (coupling) между структурами данных на фронте и на бэке. ./Di/ // имплементация интерфейсов, заданных в других плагинах (замена интерфейсов их имплементациями происходит на уровне контейнера объектов при внедрении зависимостей) ./Dto/ // описание структур данных, используемых данным плагином или приложением. ./Enum/ //описание кодификаторов, используемых данным плагином или приложением. ./Ext/ // содержит код для оборачивания внешних библиотек (например, UMD-модулей, подключаемых в index.html). ./Mod/ // модели, в которых реализована логика обработки данных (DTO). ./Store/ // код для сохранения данных в различных хранилищах браузера. ./IDb/ // IndexedDB ./Local/ // localStorage ./Mem/ // in-memory cache ./Session/ // sessionStorage ./Ui/ // код, относящийся к построению пользовательского интерфейса. ./Layout/ // компоненты разметки (навигаторы, панели и т.п.). ./Lib/ // библиотека общих компонентов, разделяемых другими компонентами (субформы, диалоги, композиционные контролы и т.п.). ./Route/ // компоненты для построения интерфейсов маршрутов в приложении (“страниц” в SPA). ./Widget/ // компоненты-одиночки, которые могут быть использованы другими компонентами или моделями (например, индикатор выполнения сетевого запроса: он нужен в одном экземпляре на фронт-приложение, но может включаться/выключаться из различных его частей - SSE, WS, RTC, REST). ./Util/ // утилиты. ./Web/ // обработчики сообщений, поступающих на фронт из сети: ./Event/ // SSE сообщения. ./Rtc/ // сообщения WebRTC. ./Socket/ // сообщения через web socket’ы.
Каталог ./src/Back/
Этот каталог содержит JS-код, который выполняется на сервере в среде nodejs и обеспечивает работу различных экземпляров фронтальной части приложения в браузерах пользователей. Структура каталогов перекликается со структурами в каталогах ./Front/ & ./Shared/, но есть и свои особенности:
./src/Back/ ./Act/ // действия (actions), отдельные операции над данными, выполненные в функциональном стиле (const {out1, out2, …} = act({in1, in2, …})). ./Api/ // содержит описание интерфейсов, которые используются в работе данного плагина и которые должны быть имплементированы в других плагинах или в приложении. ./Cli/ // сервисные команды для их выполнения через консоль (например, запуск/останов бэкэнд приложения в режиме веб-сервера). ./Convert/ // содержит код для конвертации Shared DTO в Back DTO и обратно. Этот слой кода позволяет уменьшить зацепление (coupling) между структурами данных на фронте и на бэке. ./Di/ // имплементация интерфейсов, заданных в других плагинах (замена интерфейсов их имплементациями происходит на уровне контейнера объектов при внедрении зависимостей) ./Dto/ // описание структур данных, используемых данным плагином или приложением. ./Enum/ //описание кодификаторов, используемых данным плагином или приложением. ./Mod/ // модели, в которых реализована логика обработки данных (DTO). ./Plugin/ // код для подключения плагина к приложению (структуры локальных конфигурационных данных, дескрипторы конфигурации функционала плагина). ./Store/ // код для сохранения данных в различных хранилищах на стороне сервера. ./Mem/ // in-memory cache ./RDb/ // основная БД (реляционная) ./Util/ // утилиты. ./Web/ // обработчики сообщений, поступающих на бэк из сети: ./Api/ // синхронные запросы через HTTP POST. ./Event/ // SSE сообщения. ./Handler/ // подключение дополнительных обработчиков для web-запросов (например, files upload processing). ./Socket/ // сообщения через web socket’ы.
Заключение
У кодовой базы, написанной на одном языке программирования, есть определённые преимущества перед кодовой базой, написанной на двух и более языках. Как минимум, это снижает требования к количеству и квалификации разработчиков.
У монолитной архитектуры есть определённые преимущества перед раздельной разработкой. Например, это облегчает поиск использования элементов кода при его рефакторинге (Find Usages).
Модульный п��дход имеет преимущества перед полностью монолитной архитектурой. Он, как минимум, предоставляет возможность переиспользования модулей в разных приложениях.
Модульный подход на уровне npm-пакетов позволяет разделить код приложения по его функциональному назначению (например, аутентификация, контакты пользователей, оформление заказов, складской учёт и т.д.), описав их интеграцию друг с другом в головном npm-пакете веб-приложения. При удачном разбиении можно повысить переиспользуемость пакетов в разных приложениях.
Структура каталогов в npm-пакете должна упорядочивать не только исходный код, но также и сопутствующие артефакты (документацию, тесты, инструкции по сборке, интеграции, развёртыванию и т.п.).
Правила структурирования статических ресурсов в пакетах могут базироваться на традициях разработки статических сайтов (img, css, font и т.д.). Особенно учитывая, что в SPA HTML-фрагменты пользовательского интерфейса зачастую интегрированы в JS-код компонентов.
Правила структурирования статических ресурсов в пакетах могут базироваться на традициях разработки статических сайтов (img, css, font, …). Особенно, учитывая, что в SPA HTML-фрагменты пользовательского интерфейса зачастую интегрированы в JS-код компонентов.
Исходный JS-код изначально делится на три части по области его применения (Front, Back, Shared).
Дальнейшее деление исходного JS-кода производится относительно типа элемента кода (с учётом области применения и без учёта реализуемой бизнес-функции: DTO, утилита, действие).
Деление кода по реализуемым бизнес-функциям происходит уже внутри каждого типа (
./Dto/User.js,./Dto/Sale.js,./Dto/Address.js).
Таким образом, декомпозиция и структурирование кода идёт по спирали:
по бизнес-функциям на уровне пакетов
по типам кода внутри отдельного пакета
опять по бизнес-функциям внутри отдельного типа кода
опять по типам кода внутри реализации отдельной бизнес-функции (AZ-order).
Я изложил свой подход к структурированию программного кода при разработке SPA/PWA с целью формализовать и упорядочить мои текущие практики. Если джентльменам на Хабре есть что сказать по этому поводу, то я с интересом ознакомлюсь с их мнениями.
