Структура проекта React для масштабирования: декомпозиция, слои и иерархия

Первоначально опубликовано на https://www.developerway.com. На сайте есть еще статьи, подобные этой 😉.

Как структурировать React-приложения “правильным образом”, похоже, является горячей темой в последнее время как давно существует React. Официальное мнение React по этому поводу гласит, что у него “нет мнений”. Это здорово, это дает нам полную свободу делать все, что мы хотим. И одновременно это плохо. Это приводит к появлению такого количества принципиально разных и очень сильных мнений о правильной структуре приложений React, что даже самые опытные разработчики иногда чувствуют себя потерянными, подавленными и испытывают потребность поплакать в темном углу из-за этого.

У меня, конечно, тоже есть свое мнение на эту тему 😈. И в этот раз это будет даже не “смотря что” 😅 (почти). Сегодня я хочу поделиться с вами системой, которая, по моим наблюдениям, довольно хорошо работает в..:

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

Просто помните, что, как и в Кодексе Пирата, все это скорее “рекомендации”, чем реальные правила.

Что нам нужно от конвенции о структуре проекта

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

Воспроизводимость

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

Инферрабильность

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

Независимость

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

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

Оптимизировано для рефакторинга

Последний пункт, но в современном мире фронтенда он самый важный. Фронтенд сегодня невероятно подвижен. Паттерны, фреймворки и лучшие практики постоянно меняются. Вдобавок ко всему, от нас ожидают быстрой разработки функций. Нет, БЫСТРО. А затем полностью переписать его через месяц. А потом, возможно, переписать еще раз.

Поэтому становится очень важным, чтобы наша конвенция кодирования не заставляла нас “приклеивать” код в каком-то постоянном месте без возможности его перемещения. Она должна организовать все таким образом, чтобы рефакторинг был чем-то обыденным на ежедневной основе. Худшее, что может сделать конвенция – это сделать рефакторинг настолько сложным и трудоемким, что все будут бояться его. Вместо этого он должен быть простым, как дыхание.

Теперь, когда у нас есть общие требования к конвенции по структуре проекта, пора перейти к деталям. Давайте начнем с общей картины, а затем перейдем к деталям.

Организация самого проекта: декомпозиция

Первой и самой важной частью организации большого проекта, соответствующей принципам, которые мы определили выше, является “декомпозиция”: вместо того чтобы рассматривать проект как монолитный, его можно представить как композицию более или менее независимых функций. Старая добрая дискуссия “монолит” vs “микросервисы”, только в рамках одного React-приложения. При таком подходе каждая функция по сути является “наносервисом”, изолированным от остальных функций и взаимодействующим с ними через внешний “API” (обычно просто React props).

Даже просто следование такому мышлению, по сравнению с более традиционным подходом “React project”, даст вам практически все из нашего списка выше: команды/люди смогут независимо работать над функциями параллельно, если они реализуют их как кучу “черных ящиков”, подключенных друг к другу. При правильной настройке это должно быть очевидно для любого человека, просто потребуется немного практики, чтобы приспособиться к изменению мышления. Если вам нужно удалить какую-то функцию, вы можете просто “отсоединить” ее или заменить другой функцией. Или если вам нужно рефакторить внутреннюю часть функции, вы можете это сделать. И до тех пор, пока публичный “API” этой функции остается функциональным, никто из посторонних этого даже не заметит.

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

Теперь, как организовать это для одного проекта? Особенно учитывая, что по сравнению с микросервисами, это должно быть гораздо менее сложно: в проекте с сотнями функций, извлечение их всех в реальные микросервисы будет почти невозможным. Что мы можем сделать вместо этого, так это использовать многопакетную архитектуру monorepo: она идеально подходит для организации и изоляции независимых функций в виде пакетов. Пакет – это понятие, которое должно быть знакомо каждому, кто устанавливал что-либо из npm. А монорепо – это просто репо, в котором исходный код нескольких пакетов живет вместе в гармонии, обмениваясь инструментами, скриптами, зависимостями, а иногда и друг с другом.

Итак, концепция проста: Проект React → разбиваем его на независимые фичи → размещаем эти фичи в пакетах.

Если вы никогда не работали с локальной настройкой monorepo и теперь, после того как я упомянул “package” и “npm”, испытываете беспокойство от мысли о публикации своего частного проекта: не стоит. Ни публикация, ни open-source не являются обязательными условиями для существования monorepo и получения разработчиками выгоды от него. С точки зрения кода, пакет – это просто папка, в которой есть файл package.json с некоторыми свойствами. Эта папка затем связывается с помощью симлинков Node с папкой node_modules, куда устанавливаются “традиционные” пакеты. Это связывание выполняется такими инструментами, как Yarn или Npm: оно называется “рабочими пространствами”, и оба они поддерживают его. И они делают пакеты доступными в вашем локальном коде как любой другой пакет, загруженный из npm.

Это будет выглядеть следующим образом:

/packages
  /my-feature
    /some-folders-in-feature
    index.ts
    package.json // this is what defines the my-feature package
  /another-feature
    /some-folders-in-feature
    index.ts
    package.json // this is what defines the another-feature package
Войти в полноэкранный режим Выйти из полноэкранного режима

а в package.json я бы указал эти два важных поля:

{
  "name": "@project/my-feature",
  "main": "index.ts"
}
Ввести полноэкранный режим Выйти из полноэкранного режима

Где поле “name” – это, очевидно, имя пакета – по сути, псевдоним этой папки, через который она будет доступна для кода в репозитории. А “main” – это основная точка входа в пакет, т.е. какой файл будет импортирован, когда я напишу что-то вроде

import { Something } from '@project/my-feature';
Войти в полноэкранный режим Выйти из полноэкранного режима

Существует довольно много публичных репозиториев известных проектов, которые используют подход монорепо с несколькими пакетами: Babel, React, Jest и другие.

Почему именно пакеты, а не просто папки

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

Сглаживание. С помощью пакетов вы можете ссылаться на свою функцию по ее имени, а не по ее местоположению. Сравните это:

import { Button } from '@project/button';
Войти в полноэкранный режим Выход из полноэкранного режима

с этим более “традиционным” подходом:

import { Button } from '../../components/button';
Войти в полноэкранный режим Выход из полноэкранного режима

В первом импорте все очевидно – я использую общий компонент “кнопка” моего проекта, мою версию дизайн-системы.

Во втором не все так однозначно – что это за кнопка? Это общая кнопка “дизайн-системы”? Или, может быть, часть этой функции? Или функция “выше”? Могу ли я вообще использовать ее здесь, может быть, она была написана для какого-то очень специфического случая использования, который не будет работать в моей новой функции?

Все становится еще хуже, если в вашем репозитории есть несколько папок “utils” или “common”. Мой худший кошмарный код выглядит следующим образом:

import { bla } from '../../../common';
import { blabla } from '../../common';
import { blablabla } from '../common';
Вход в полноэкранный режим Выход из полноэкранного режима

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

import { bla } from '@project/button/common';
import { blabla } from '@project/something/common';
import { blablabla } from '@project/my-feature/common';
Войти в полноэкранный режим Выйти из полноэкранного режима

Мгновенно становится ясно, что откуда берется, и что где находится. И есть шанс, что “общий” код “моей функции” был написан только для внутреннего использования функции, никогда не предназначался для использования вне функции, и повторное использование его где-то еще – плохая идея. С пакетами вы увидите это сразу.

Разделение проблем. Учитывая, что все мы привыкли к пакетам из npm и к тому, что они собой представляют, становится гораздо проще думать о вашей функции как об изолированном модуле с собственным публичным API, если она сразу написана как “пакет”.

Взгляните на это:

import { dateTimeConverter } from '../../../../button/something/common/date-time-converter';
Вход в полноэкранный режим Выход из полноэкранного режима

против этого:

import { dateTimeConverter } from '@project/button';
Вход в полноэкранный режим Выход из полноэкранного режима

Первое, скорее всего, затеряется во всех окружающих его импортах и проскользнет незамеченным, превратив ваш код в большой клубок грязи. Второй мгновенно и естественно поднимет несколько бровей: конвертер даты-времени? Из кнопки? Серьезно? Что, естественно, заставит провести более четкие границы между различными функциями/пакетами.

Встроенная поддержка. Вам не нужно ничего изобретать, большинство современных инструментов, таких как IDE, typescript, linting или bundlers, поддерживают пакеты “из коробки”.

Рефакторинг – это просто. С функциями, разделенными на пакеты, рефакторинг становится приятным занятием. Хотите рефакторить содержимое вашего пакета? Вперед, вы можете полностью переписать его, при условии, что вы сохраните API записи прежним, остальная часть репозитория даже не заметит этого. Хотите переместить пакет в другое место? Это просто перетаскивание папки, если вы не переименовываете ее, остальная часть репозитория не пострадает. Хотите переименовать пакет? Просто найдите & замените строку в проекте, не более того.

Явные точки входа. Если вы хотите действительно придерживаться принципа “только публичный API для потребителей”, вы можете четко определить, что именно из пакета доступно внешним потребителям. Например, вы можете ограничить весь “глубокий” импорт, сделать такие вещи, как @project/button/some/deep/path невозможными и заставить всех использовать только явно определенные публичные API в файле index.ts. Посмотрите в документации Package entry points и Package exports примеры того, как это работает.

Как разделить код на пакеты

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

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

Вот некоторые границы, которые я обычно использую:

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

Также, как и в предыдущей статье о том, как разделить код на компоненты, очень важно, чтобы пакет отвечал только за одну концептуальную вещь. Пакет, экспортирующий Button, CreateIssueDialog и DateTimeConverter делает слишком много вещей одновременно и должен быть разделен.

Как организовать пакеты

Хотя можно просто создать плоский список всех пакетов, и для определенных типов проектов это будет работать, для больших продуктов с тяжелым пользовательским интерфейсом этого, скорее всего, будет недостаточно. Видя такие пакеты, как “всплывающая подсказка” и “страница настроек”, сидящие вместе, я начинаю содрогаться. Или еще хуже – если у вас есть пакеты “backend” и “frontend” вместе. Это не только беспорядочно, но и опасно: последнее, чего вы хотите, это случайно вытащить код “backend” в пакет “frontend”.

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

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

/packages
  /button
  ...
  /footer
  /settings
  ...
Войти в полноэкранный режим Выход из полноэкранного режима

и со временем превратить его в нечто подобное:

/packages
  /core
    /button
    /modal
    /tooltip
    ...
  /product-one
    /footer
    /settings
    ...
  /product-two
    ...
Войти в полноэкранный режим Выход из полноэкранного режима

Или, если у вас есть бэкенд, это может быть что-то вроде этого:

/packages
  /frontend
    ... // the same as above
  /backend
    ... // some backend-specific packages
  /common
    ... // some packages that are shared between frontend and backend
Вход в полноэкранный режим Выход из полноэкранного режима

Где в “common” вы бы поместили некоторый код, который является общим для фронтенда и бэкенда. Обычно это некоторые конфигурации, константы, утилиты типа lodash, общие типы.

Как структурировать сам пакет

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

Соглашение об именовании

Все любят называть вещи и спорить о том, как плохо другие называют вещи, не так ли? Чтобы сократить время, потраченное на бесконечные комментарии на GitHub, и успокоить бедных гиков с кодовым ОКР, вроде меня, лучше просто договориться о соглашении об именовании один раз для всех.

На мой взгляд, неважно, какой из них использовать, главное, чтобы он последовательно соблюдался во всем проекте. Если у вас есть ReactFeatureHere.ts и react-feature-here.ts в одном репозитории, то где-то плачет котенок 😿. Я обычно использую этот:

/my-feature-name
  /assets     // if I have some images, then they go into their own folder
    logo.svg
  index.tsx   // main feature code
  test.tsx    // tests for the feature if needed
  stories.tsx // stories for storybooks if I use them
  styles.(tsx|scss) // I like to separate styles from component's logic
  types.ts    // if types are shared between different files within the feature
  utils.ts    // very simple utils that are used *only* in this feature
  hooks.tsx   // small hooks that I use *only* in this feature
Войти в полноэкранный режим Выйти из полноэкранного режима

Если у функции есть несколько небольших компонентов, которые импортируются непосредственно в index.tsx, они будут выглядеть следующим образом:

/my-feature-name
  ... // the same as before
  header.tsx
  header.test.tsx
  header.styles.tsx
  ... // etc
Вход в полноэкранный режим Выход из полноэкранного режима

Или, что более вероятно, я сразу же извлеку их в папки, и они будут выглядеть следующим образом:

/my-feature-name
  ... // index the same as before
  /header
    index.tsx
    ... // etc, exactly the same naming here
  /footer
    index.tsx
    ... // etc, exactly the same naming here
Войти в полноэкранный режим Выход из полноэкранного режима

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

Слои внутри пакета

Типичный пакет со сложной функцией будет иметь несколько отдельных “слоев”: как минимум, слой пользовательского интерфейса и слой данных. Хотя, вероятно, можно смешать все вместе, я бы все же не рекомендовал этого делать: рендеринг кнопок и получение данных из бэкенда – это совершенно разные задачи. Их разделение придаст пакету больше структуры и предсказуемости.

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

Если бы я сегодня реализовывал проект React с нуля, с Graphql для работы с данными и чистым React state для управления состоянием (то есть без Redux или любой другой библиотеки), у меня были бы следующие слои:

  • слой “данные” – запросы, мутации и прочее, что отвечает за подключение к внешним источникам данных и их преобразование. Используется только слоем UI, не зависит ни от каких других слоев.
  • “Общий” слой – различные утилиты, функции, хуки, мини-компоненты, типы и константы, которые используются во всем пакете всеми остальными слоями. Не зависит ни от каких других слоев.
  • слой “ui” – собственно реализация функций. Зависит от слоев “data” и “shared”, ни от кого не зависит.

Вот и все!

Если бы я использовал какую-то внешнюю библиотеку управления состояниями, я бы, вероятно, добавил еще и слой “state”. Он, скорее всего, был бы мостом между “data” и “ui”, и поэтому использовал бы слои “shared” и “data”, а “UI” использовал бы “state” вместо “data”.

А с точки зрения деталей реализации, все слои – это папки верхнего уровня в пакете:

/my-feature-package
  /shared
  /ui
  /data
  index.ts
  package.json
Вход в полноэкранный режим Выход из полноэкранного режима

Каждый “слой” использует одно и то же соглашение об именовании, описанное выше. Таким образом, ваш слой “данные” будет выглядеть примерно так:

/data
  index.ts
  get-some-data.ts
  get-some-data.test.ts
  update-some-data.ts
  update-some-data.test.ts
Вход в полноэкранный режим Выход из полноэкранного режима

Для более сложных пакетов я мог бы разделить эти слои, сохранив при этом их назначение и характеристики. Например, слой “данные” можно разделить на “запросы” (“геттеры”) и “мутации” (“сеттеры”), и они могут либо оставаться в папке “данные”, либо перемещаться вверх:

/my-feature-package
  /shared
  /ui
  /queries
  /mutations
  index.ts
  package.json
Войти в полноэкранный режим Выйти из полноэкранного режима

Или вы можете извлечь несколько подслоев из слоя “shared”, например “types” и “shared UI components” (что мгновенно превратит этот подслой в тип “UI”, поскольку никто, кроме “UI”, не может использовать компоненты UI).

/my-feature-package
  /shared-ui
  /ui
  /queries
  /mutations
  /types
  index.ts
  package.json
Вход в полноэкранный режим Выход из полноэкранного режима

До тех пор, пока вы можете четко определить назначение каждого “подслоя”, понять, какой “подслой” к какому “слою” относится, и можете визуализировать и объяснить это всем членам команды – все работает!

Строгая иерархия внутри слоев

Последний кусочек головоломки, который делает эту архитектуру предсказуемой и поддерживаемой, – это строгая иерархия внутри слоев. Это будет особенно заметно на слое UI, поскольку в приложениях React он обычно самый сложный.

Начнем, например, с создания простой страницы с заголовком и нижним колонтитулом. У нас будет файл “index.ts” – основной файл, в котором собирается страница, и компоненты “header.ts” и “footer.ts”.

/my-page
  index.ts
  header.ts
  footer.ts
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь все они будут иметь свои собственные компоненты, которые я захочу поместить в свои собственные файлы. “Header”, например, будет иметь компоненты “Search bar” и “Send feedback”. При “традиционном” плоском способе организации приложений мы бы разместили их рядом друг с другом, не так ли? Было бы примерно так:

/my-page
  index.ts
  header.ts
  footer.ts
  search-bar.ts
  send-feedback.ts
Вход в полноэкранный режим Выход из полноэкранного режима

И затем, если я захочу добавить ту же кнопку “send-feedback” в компонент footer, я снова просто импортирую ее в “footer.ts” из “send-feedback.ts”, верно? В конце концов, это рядом и кажется естественным.

К сожалению, произошло то, что произошло – мы нарушили границы между нашими слоями (“UI” и “shared”), даже не заметив этого. Если я продолжу добавлять все больше и больше компонентов в эту плоскую структуру, а я, вероятно, так и сделаю, реальные приложения обычно довольно сложны, я, вероятно, нарушу их еще несколько раз. Это превратит папку в собственный маленький “клубок грязи”, где совершенно непредсказуемо, какой компонент от какого зависит. И в результате распутывание всего этого и извлечение чего-либо из этой папки, когда придет время рефакторинга, может превратиться в очень муторное занятие.

Вместо этого мы можем структурировать этот слой иерархическим образом. Правила таковы:

  • только главные файлы (например, “index.ts”) в папке могут иметь подкомпоненты (подмодули) и могут их импортировать
  • импортировать можно только из “дочерних”, но не из “соседних”.
  • вы не можете пропустить уровень и можете импортировать только из прямых дочерних компонентов.

Или, если вы предпочитаете визуальное представление, это просто дерево:

И если вам нужно разделить какой-то код между разными уровнями этой иерархии (например, наш компонент send-feedback), вы сразу же увидите, что нарушаете правила иерархии, поскольку куда бы вы его ни поместили, вам придется импортировать его либо из родителей, либо из соседей. Поэтому вместо этого он будет извлечен в “общий” слой и импортирован оттуда.

Это будет выглядеть следующим образом:

/my-page
  /shared
    send-feedback.ts
  /ui
    index.ts
    /header
      index.ts
      search-bar.ts
    /footer
      index.ts
Вход в полноэкранный режим Выход из полноэкранного режима

Таким образом, слой UI (или любой слой, где применяется это правило) просто превращается в древовидную структуру, где каждая ветвь не зависит от любой другой ветви. Извлечь что-либо из этого пакета теперь проще простого: все, что вам нужно сделать, это перетащить папку в новое место. И вы точно знаете, что ни один компонент в дереве пользовательского интерфейса не будет затронут этим, кроме того, который действительно его использует. Единственное, с чем вам, возможно, придется иметь дело дополнительно, – это “общий” слой.

Полное приложение со слоем данных будет выглядеть следующим образом:

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

/my-page
  /shared
    send-feedback.ts
  /data
    get-something.ts
    send-something.ts
  /ui
    index.ts
    /header
      index.ts
      search-bar.ts
    /footer
      index.ts
Вход в полноэкранный режим Выход из полноэкранного режима

React рекомендует воздержаться от вложенности

Если вы прочитаете документацию React о рекомендуемой структуре проекта, вы увидите, что React на самом деле не рекомендует слишком много вложенности. Официальная рекомендация гласит: “Ограничьтесь максимум тремя или четырьмя вложенными папками в рамках одного проекта”. И эта рекомендация очень актуальна и для данного подхода: если ваш пакет становится слишком вложенным, это явный признак того, что вам стоит подумать о разделении его на более мелкие пакеты. 3-4 уровня вложенности, по моему опыту, достаточно даже для очень сложных функций.

Однако прелесть архитектуры пакетов в том, что вы можете организовывать свои пакеты с такой вложенностью, какая вам нужна, не будучи связанными этим ограничением – вы никогда не ссылаетесь на другой пакет по его относительному пути, только по его имени. Пакет с именем @project/change-settting-dialog, который находится по пути packages/change-settings-dialog или спрятан внутри /packages/product/features/settings-page/change-settting-dialog, будет называться @project/change-settting-dialog независимо от его физического расположения.

Инструмент управления монорепом

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

/my-feature-one
  package.json // this one uses lodash@3.4.5
/my-other-feature
  package.json // this one uses lodash@3.4.5
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь lodash выпускает новую версию, lodash@4.0.0, и вы хотите перевести свой проект на нее. Вам нужно будет обновить его везде одновременно: последнее, чего вы хотите, это чтобы некоторые пакеты оставались на старой версии, а некоторые использовали новую. Если вы используете npm или старый yarn, это будет катастрофой: они установят несколько копий (не две, а несколько) lodash в вашей системе, что приведет к увеличению времени установки и сборки, а также к увеличению размера пакетов. Не говоря уже о том, как весело разрабатывать новую функцию, когда вы используете две разные версии одной и той же библиотеки по всему проекту.

Я не буду касаться вопроса о том, что использовать, если ваш проект будет опубликован на npm и открыт: вероятно, будет достаточно чего-то вроде Lerna, но это уже совсем другая тема.

Если же ваш репозиторий является закрытым, то все становится еще интереснее. Потому что все, что вам на самом деле нужно для работы этой архитектуры – это пакеты “aliasing”, не более того. Т.е. просто базовая симлинковка, которую Yarn и Npm обеспечивают через идею рабочих пространств. Это выглядит следующим образом. У вас есть “корневой” файл package.json, в котором вы объявляете рабочие пространства (т.е. ваши локальные пакеты):

{
  "private": true,
  "workspaces": ["packages/**"]
}
Войти в полноэкранный режим Выйти из полноэкранного режима

И тогда в следующий раз, когда вы запустите yarn install, все пакеты из папки packages превратятся в “правильные” пакеты и будут доступны в вашем проекте по их имени. Вот и вся настройка monorepo!

Что касается зависимостей. Что произойдет, если у вас есть одна и та же зависимость в нескольких пакетах?

/packages
  /my-feature-one
    package.json // this one uses lodash@3.4.5
  /my-other-feature
    package.json // this one uses lodash@3.4.5
Вход в полноэкранный режим Выход из полноэкранного режима

Когда вы запустите yarn install, он “поднимет” этот пакет в корень node_modules:

/node_modules
  lodash@3.4.5
/packages
  /my-feature-one
    package.json // this one uses lodash@3.4.5
  /my-other-feature
    package.json // this one uses lodash@3.4.5
Вход в полноэкранный режим Выход из полноэкранного режима

Это точно такая же ситуация, как если бы вы просто объявили lodash@3.4.5 только в корневом package.json. Я хочу сказать, и за это меня, вероятно, заживо похоронят пуристы интернета, включая меня самого два года назад: вам не нужно объявлять какие-либо зависимости в ваших локальных пакетах. Все можно просто поместить в корневой package.json. А ваши файлы package.json в локальных пакетах будут просто очень легковесными файлами json, которые определяют только поля “name” и “main”.

Гораздо проще управлять пакетами, особенно если вы только начинаете.

Структура проекта React для масштабирования: окончательный обзор

Ха, это было много текста. И даже это лишь краткий обзор: так много еще можно сказать на эту тему! Давайте вспомним хотя бы то, что уже было сказано:

Декомпозиция – это ключ к успешному масштабированию вашего приложения react. Думайте о своем проекте не как о монолитном “проекте”, а как о комбинации независимых “черных ящиков” типа “функций” с их собственным публичным API для использования потребителями. Это такая же дискуссия, как “монолит” против “микросервисов”.

Архитектура монорепо идеально подходит для этого. Разделите ваши функции на пакеты; организуйте пакеты так, как это лучше всего подходит для вашего проекта.

Слои внутри одного пакета важны для придания ему определенной структуры. Вероятно, у вас будет как минимум слой “данные”, слой “пользовательский интерфейс” и слой “общий”. Можно ввести больше, в зависимости от ваших потребностей, просто нужно иметь четкие границы между ними.

Иерархическая структура пакета – это здорово. Она облегчает рефакторинг, заставляет вас иметь более четкие границы между слоями, и заставляет вас разделить пакет на более мелкие, когда он становится слишком большим.

Управление зависимостями в монорепо – сложная тема, но если ваш проект частный, вам не нужно об этом беспокоиться. Просто объявите все свои зависимости в корневом package.json и держите все локальные пакеты свободными от них.

Вы можете посмотреть на реализацию этой архитектуры в этом примере репозитория: https://github.com/developerway/example-react-project. Это всего лишь базовый пример для демонстрации принципов, описанных в статье, поэтому не пугайтесь крошечных пакетов с одним index.ts: в реальном приложении они будут гораздо больше.

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

Первоначально опубликовано на https://www.developerway.com. На сайте есть еще больше подобных статей 😉.

Подпишитесь на рассылку, подключитесь на LinkedIn или следите в Twitter, чтобы получить уведомление, как только выйдет следующая статья.

Оцените статью
Procodings.ru
Добавить комментарий