Частный взгляд на структурирование файлов при разработке SPA

В этом посте я попытаюсь формализовать и систематизировать своё собственное понимание, какой должна быть структура 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

Другими словами, если в приложении используются сторонние ресурсы, загружаемые через CDN, то разработчик (интегратор) не имеет возможности повлиять ни на размещение файлов, ни на их наименование. Что, в принципе, является нормальной ситуацией для внешних ресурсов. Если же через CDN распространяются файлы самого проекта, то тут разработчик волен размещать их по своему усмотрению.

NPM-пакеты

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

Приложение состоит из npm-пакетов

Приложение состоит из npm-пакетов

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

Группировка по npm-пакетам

Группировка по npm-пакетам

Код самого веб-приложения также является npm-пакетом (содержит ./package.json в котором прописаны соответствующие пакеты-зависимости):

./project/
    ./index.html        // точка входа
    ./LICENCE           // лицензия
    ./package.json      // дескриптор npm-пакета
    ./README.md         // описание пакета
    ./RELEASE.md        // история изменений

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/ можно разбить на три группы, по месту его использования:

Области использования JS-кода

Области использования JS-кода

Каталог ./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 с целью формализовать и упорядочить мои текущие практики. Если джентльменам на Хабре есть что сказать по этому поводу, то я с интересом ознакомлюсь с их мнениями.

© Habrahabr.ru