Структура папок для React приложений
Какой должна быть структура папок на проекте React— это тема, которая часто вызывает жаркие споры. Мне и самому было непросто писать об этом, поскольку не существует универсального «правильного» подхода. Однако меня часто спрашивают, как я структурирую свои React-проекты — от малых до крупных, — и я с радостью готов поделиться своим подходами.
За время работы с React я реализовал несколько приложений и хочу подробно разобрать свой подход к этому вопросу: как я это делаю в своих личных проектах, SaaS-продуктах и других. В статье опишу основные 6 подходов, которыми пользуюсь я. Итак, давайте начнём.
Один файл React
Первый подход: «Один файл, чтобы управлять всеми». Большинство React-проектов начинаются с папки src/ и одного файла src/(имя).(js|ts|jsx|tsx), в котором вы найдете какой-то компонент, например, App. По крайней мере, именно такую структуру вы получите, если используете Vite для создания клиентского React-приложения. Если же вы используете фреймворк вроде Next.js для серверного React, вы начнете с файла src/app/page.js.
Здесь мы видим пример функционального компонента, который просто возвращает JSX в одном файле:
const App = () => {
const title = 'React';
return (
<div>
<h1>Hello {title}</h1>
</div>
);
}
export default App;
По мере развития этого компонента и добавления нового функционала, его объём закономерно растёт, что вынуждает разбивать его на более мелкие, независимые React-компоненты. В данном случае мы выносим компонент списка React и ещё один дочерний компонент из компонента App, куда необходимо передавать данные через пропсы.
const list = [
{
id: '1',
name: 'Robin Wieruch',
},
{
id: '2',
name: 'Dave Davidds',
},
];
const ListItem = ({ item }) => (
<li>
<span>{item.id}</span>
<span>{item.name}</span>
</li>
);
const List = ({ list }) => (
<ul>
{list.map(item => (
<ListItem key={item.id} item={item} />
))}
</ul>
);
const App = () => <List list={list} />;
При запуске нового React-проекта вполне допустимо размещать несколько компонентов в одном файле. В крупных приложениях такой подход также может быть вполне приемлем, если компоненты тесно связаны между собой.
Однако по мере роста вашего проекта одного файла станет недостаточно. В этот момент потребуется рефакторинг и разделять компонеты на несколько файлов.
Несколько React-файлов
Второй подход продолжает мысль: Множество файлов. Возьмем, к примеру, наши компоненты List и ListItem: вместо того чтобы хранить всё в одном файле, мы можем разнести эти компоненты по отдельным файлам. Здесь уже вы сами решаете, насколько далеко хотите зайти. Скажем, я бы остановился на следующей структуре папок:
- src/
--- app.js
--- list.js
Хотя файл list.js содержит реализацию компонентов List и ListItem, в качестве публичного API из файла экспортируется только компонент List.
const ListItem = ({ item }) => (
<li>
<span>{item.id}</span>
<span>{item.name}</span>
</li>
);
const List = ({ list }) => (
<ul>
{list.map(item => (
<ListItem key={item.id} item={item} />
))}
</ul>
);
export { List };
Затем файл app.js может импортировать компонент List и использовать его дальше:
import { List } from './list';
const list = [ ... ];
const App = () => <List list={list} />;
Вы можете попрактиковаться и пойти дальше, Вы можете вынести компонент ListItem в отдельный файл и импортировать его в компонент List, у Вас получится структура:
- src/
--- app.js
--- list.js
--- list-item.js
Однако, как уже писал, это может быть излишним, потому что на данный момент компонент ListItem тесно связан с компонентом List и больше нигде не используется повторно. Поэтому будет лучше оставить его в файле src/list.js.
Я придерживаюсь эмпирического правила: как только React-компонент становится переиспользуемым, я выношу его в отдельный файл — как мы поступили с компонентом List — чтобы сделать его доступным для других компонентов.
Также обратите внимание, что для простоты я использовал расширение .js (JavaScript). Если же вы используете явное указание JSX-файлов и/или TypeScript, то вам следует использовать расширения .jsx, .ts или .tsx соответственно.
От файлов к папкам в React
С этого момента всё становится интереснее, но и более субъективным. Со временем любой React-компонент неизбежно обрастает сложностью. Не только из-за добавления новой логики (например, больше JSX с условным рендерингом или логики с использованием хуков и обработчиков событий), но и из-за появления технических аспектов, таких как стили, тесты, константы, вспомогательные функции и типы. Все эти сущности в потенциале можно вынести в отдельные файлы.
Наивный подход заключается в том, чтобы просто добавлять новые файлы рядом с каждым React-компонентом. Например, представим, что у каждого компонента есть свой файл с тестами и файл со стилями:
Также обратите внимание, что я использовал расширение .js для простоты примера. Если вы работаете с явным расширением для JSX и/или TypeScript, то вам следует использовать расширения .jsx, .ts или .tsx соответственно.
- src/
--- app.js
--- app.test.js
--- app.css
--- list.js
--- list.test.js
--- list.css
Уже сейчас прекрасно видно, что такое решение плохо масштабируется — с каждым новым React-компонентом в папке src/ мы будем всё хуже ориентироваться среди них. Именно поэтому я предпочитаю создавать отдельную папку для каждого компонента.
- src/
--- app/
----- index.js
----- component.js
----- test.js
----- style.css
--- list/
----- index.js
----- component.js
----- test.js
----- style.css
Новые файлы со стилями и тестами реализуют стилизацию и тестирование для каждого отдельного компонента соответственно, файл component.js содержит непосредственно саму логику компонента. Этот подход отлично соответствует принципу разделения ответственности (Separation of Concerns), что делает код чище и проще в поддержке.
* * *
Чего не хватает в объяснениях, так это упоминания нового (пока что опционального) файла index.js, который представляет публичный интерфейс (то есть public API) папки (или модуля). В него экспортируется всё, что должно быть доступно извне. Многие знают этот файл под термином "barrel-файл" (баррель), использование которого обычно не рекомендуется в JavaScript, поскольку он усложняет деревосжатие (tree shaking) для сборщиков.
Однако, если вы не просто реэкспортируете всё подряд из папки, а только то, что входит в публичный API, то это может быть хорошей практикой. Так вы не раскрываете детали реализации (например, стили) внешнему миру. Другими словами, разрешённый импорт должен происходить только из файла index.js, но не напрямую из component.js или style.css.
Например, для компонента List файл src/list/index.js будет выглядеть так:
export * from './list';
Если требуется большая конкретика, чтобы избежать утечки деталей реализации, можно также экспортировать компонент List напрямую.
import { List } from './list';
export { List };
Компонент App в своем файле component.js по-прежнему может импортировать компонент List следующим образом:
import { List } from '../list/index.js';
В импорие можем использовать без /index.js, так как это поведение по умолчанию для большинства JavaScript-сборщиков.
import { List } from '../list';
Так или иначе, barrel-файлы (index.js) выходят из моды в экосистеме JavaScript из-за того, что они усложняют tree shaking для сборщиков (bundlers), таких как Webpack или Vite.
Поэтому вы можете импортировать компонент List напрямую из src/list/list.js и полностью отказаться от использования файла src/list/index.js.
* * *
С другой стороны, соглашение об именовании представленных файлов также является субъективным: например, test.js может быть изменен на spec.js, а style.css — на styles.css, если вы предпочитаете использовать множественное число для имен файлов. Более того, если вы используете не чистый CSS, а, скажем, CSS-модули, ваше расширение файла может поменяться с style.css на style.module.css.
Как только вы привыкнете к этому соглашению по именованию папок и файлов, вы сможете просто использовать нечёткий поиск в вашей IDE по запросу «list component» или «app test», чтобы быстро открыть любой файл.
Но здесь я признаю, в противовес моему личному вкусу в пользу кратких имён, что разработчики зачастую предпочитают быть более избыточными в именах своих папок и файлов:
- src/
--- app/
----- index.js
----- app.js
----- app.test.js
----- app.style.css
--- list/
----- index.js
----- list.js
----- list.test.js
----- list.style.css
Так или иначе, если свернуть все папки компонентов, то, независимо от названий файлов, вы получите лаконичную структуру папок, в которой все детали реализации ваших компонентов скрыты:
- src/
--- app/
--- list/
Если компонент требует более сложной технической организации — например, когда есть необходимость вынести кастомные хуки, типы (например, определения TypeScript), стори-кейсы (для Storybook), утилиты (вспомогательные функции) или константы в отдельные файлы — вы можете масштабировать этот подход горизонтально прямо внутри папки компонента.
- src/
--- app/
----- index.ts
----- component.ts
----- test.ts
----- style.css
----- type.ts
--- list/
----- index.ts
----- component.ts
----- test.ts
----- style.css
----- hooks.ts
----- story.ts
----- type.ts
----- utils.ts
----- constants.ts
Если ты решишь сделать свой компонент List более компактным, вынеся компонент ListItem в отдельный файл, то тебе может подойти следующая структура папок:
- src/
--- app/
----- index.js
----- component.js
----- test.js
----- style.css
--- list/
----- index.js
----- component.js
----- test.js
----- style.css
----- list-item.js
Когда компонент ListItem увеличивается в размере и сложности, можно пойти на следующий шаг и выделить для него собственную вложенную папку, куда помещаются все связанные с ним технические аспекты:
- src/
--- app/
----- index.js
----- component.js
----- test.js
----- style.css
--- list/
----- index.js
----- component.js
----- test.js
----- style.css
----- list-item/
------- index.js
------- component.js
------- test.js
------- style.css
С этого момента важно с осторожностью относиться к излишней вложенности ваших компонентов. Моё эмпирическое правило — избегать вложенности более двух уровней. Например, папки list и list-item в данном примере — это нормально, но внутри папки list-item не должно быть ещё одной вложенной папки. Впрочем, как и любое правило, это может иметь исключения.
В конечном счёте, если вы работаете с небольшими React-проектами, то это, по моему мнению, — оптимальный способ организации структуры ваших React-компонентов.
Технические папки
Рассмотрим еще одну структуру React-приложения среднего размера, поскольку она отделяет React-компоненты от переиспользуемых возможностей React, таких как кастомные хуки и контекст, а также от функций, не связанных с React, — например, вспомогательных функций.
Возьмите за основу следующую структуру папок с еще одной разделяющей директорией:
- src/
--- components/
----- app/
------- index.js
------- component.js
------- test.js
------- style.css
----- list/
------- index.js
------- component.js
------- test.js
------- style.css
Все предыдущие React-компоненты были сгруппированы в новой папке components/. Это создает еще один вертикальный уровень организации, позволяя создавать директории для других технических категорий.
Например, в какой-то момент у вас могут появиться переиспользуемые хуки, которые могут использоваться более чем одним компонентом. Поэтому вместо того, чтобы жестко привязывать хук к конкретному компоненту, вы можете вынести его реализацию в отдельную dedicated-папку, чтобы сделать его доступным для всех компонентов.
- src/
--- components/
----- app/
------- index.js
------- component.js
------- test.js
------- style.css
----- list/
------- index.js
------- component.js
------- test.js
------- style.css
--- hooks/
----- use-click-outside.js
----- use-scroll-detect.js
Это не означает, что все хуки должны находиться именно в этой папке. React-хуки, которые по-прежнему используются только одним компонентом, должны оставаться либо в файле самого компонента, либо в файле hooks.js рядом с компонентом внутри его папки. В новую папку hooks/ попадают только переиспользуемые хуки.
Если для работы одного хука требуется несколько файлов (например, кастомная утилита или конфигурация), его можно снова оформить в виде отдельной папки. Ничто не мешает комбинировать разные подходы к организации структуры (это применимо не только к папке с хуками), поскольку один хук может ограничиться единственным файлом, в то время как другому потребуется целая папка.
- src/
--- components/
----- app/
------- index.js
------- component.js
------- test.js
------- style.css
----- list/
------- index.js
------- component.js
------- test.js
------- style.css
--- hooks/
----- use-click-outside/
------- index.js
------- hook.js
------- test.js
----- use-scroll-detect.js
Такой же подход применим и при использовании React Context в вашем проекте. Поскольку контекст необходимо где-то инициализировать, лучшей практикой будет выделить для него отдельную папку или файл. Это связано с тем, что в конечном итоге он должен быть доступен для множества компонентов.
- src/
--- components/
----- app/
------- index.js
------- component.js
------- test.js
------- style.css
----- list/
------- index.js
------- component.js
------- test.js
------- style.css
--- hooks/
----- use-click-outside.js
----- use-scroll-detect.js
--- context/
----- session.js
Отсюда вытекает необходимость в других вспомогательных функциях, которые должны быть доступны не только из папки components/, но и из других новых директорий, таких как hooks/ и context/.
Для разного рода вспомогательной логики я обычно создаю папку services/. Выбор названия — это дело вкуса (например, utils/, lib/ или misc/ — другие популярные варианты, которые я часто встречаю). Здесь мы снова руководствуемся принципом доступности логики для других частей проекта — именно это и является причиной для такого технического разделения.
- src/
--- components/
----- app/
----- list/
--- hooks/
----- use-click-outside.js
----- use-scroll-detect.js
--- context/
----- session.js
--- services/
----- error-tracking/
------- index.js
------- service.js
------- test.js
----- format/
------- date-time/
--------- index.js
--------- service.js
--------- test.js
------- currency/
--------- index.js
--------- service.js
--------- test.js
Возьмем, к примеру, детали реализации файла date-time/index.js:
export const formatDateTime = (date) =>
new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false,
}).format(date);
export const formatMonth = (date) =>
new Intl.DateTimeFormat('en-US', {
month: 'long',
}).format(date);
К счастью, JavaScript API предоставляет нам методы для конвертации дат. Однако, вместо того чтобы использовать этот API напрямую в React-компонентах, я предпочитаю выносить эту логику в отдельный сервис. Только так я могу гарантировать, что в моих компонентах будет использоваться лишь небольшой, заранее определённый набор вариантов форматирования дат, реально востребованных в приложении.
Теперь каждую функцию для форматирования дат можно импортировать по отдельности:
import { formatMonth } from '../../services/format/date-time';
const month = formatMonth(new Date());
Но я предпочитаю рассматривать его как «сервис» — другими словами, как инкапсулированный модуль с публичным API, который следует следующей стратегии импорта:
import * as dateTimeService from '../../services/format/date-time';
const month = dateTimeService.formatMonth(new Date());
Использование относительных путей может стать проблемой при импорте модулей. Поэтому я всегда рекомендую использовать алиасы для абсолютных импортов. После их настройки ваши импорты будут выглядеть так:
import * as dateTimeService from '@/services/format/date-time';
const month = dateTimeService.formatMonth(new Date());
barrel-файлов могут утверждать, что это и есть тот самый barrel-файл, поскольку он реэкспортирует всё из папки. Однако я рассматриваю его как публичный API для этой папки, потому что он экспортирует только публичный интерфейс, а не все внутренние детали реализации.
В конечном счете, мне нравится такое техническое разделение ответственности, поскольку оно задает каждой папке четкую цель и поощряет повторное использование функциональности across the React-приложения. Вы всегда можете адаптировать эту структуру под свои нужды — например, сделать структуру сервисов более детализированной (fine-grained) по сравнению с приведенной выше:
--- services/
----- error-tracking/
------- index.js
------- service.js
------- test.js
----- format/
------- date-time/
--------- date-time/
----------- index.js
----------- service.js
----------- test.js
--------- date/
----------- index.js
----------- service.js
----------- test.js
--------- time/
----------- index.js
----------- service.js
----------- test.js
------- currency/
--------- index.js
--------- service.js
--------- test.js
Данную структуру рассматривайте, как руководство к организации разделения кода, а не как строгий стандарт именования. Именование самих папок и файлов остается на ваше усмотрение.
Функциональные папки
Последний подход поможет вам структурировать крупные React-приложения, так как оно отделяет компоненты, относящиеся к конкретным фичам, от универсальных UI-компонентов. В то время как первые часто используются в проекте лишь единожды, вторые представляют собой компоненты интерфейса, которые используются более чем одним компонентом.
Сосредоточусь на компонентах, чтобы сделать пример лаконичным, но те же принципы можно применить ко всем техническим папкам, упомянутым ранее. Рассмотрим следующую структуру папок в качестве примера. Пусть она не в полной мере отражает масштаб проблемы, но я уверен, что основная мысль будет понятна:
- src/
--- components/
----- list/
----- input/
----- button/
----- checkbox/
----- radio-button/
----- dropdown/
----- profile/
----- avatar/
----- post-item/
----- post-list/
----- payment-form/
----- payment-wizard/
----- error-message/
----- error-boundary/
Суть в следующем: Со временем в вашей папке components/ (или любой другой технической папке) окажется слишком много компонентов. Часть из них будет переиспользуемой (например, Button), а другие будут относиться к конкретной функциональности (например, Message).
Исходя из этого, я бы рекомендовал использовать папку components/ только для переиспользуемых компонентов (таких как UI-компоненты). Все остальные компоненты следует переместить в соответствующие папки фич (функциональностей). Названия папок — на ваше усмотрение, но мне нравится использовать для этого имя самой функциональности:
- src/
--- feature/
----- user/
------- profile/
------- avatar/
----- post/
------- post-item/
------- post-list/
----- payment/
------- payment-form/
------- payment-wizard/
----- error/
------- error-message/
------- error-boundary/
--- components/
----- list/
----- input/
----- button/
----- checkbox/
----- radio-button/
----- dropdown/
Если одному из функциональных компонентов (например, PostItem или PaymentForm) требуется доступ к общему компоненту вроде Checkbox, Radio или Dropdown, он импортирует его из папки переиспользуемых UI-компонентов. Точно так же, если специфичному для предметной области компоненту PostList требуется абстрактный компонент List, он также импортирует его оттуда.
Более того, если сервис из предыдущего раздела тесно связан с определенной функциональностью, его следует переместить в папку этой функциональности. Это же правило может применяться и к другим каталогам (например, hooks, context), которые изначально были разделены по техническому признаку.
- src/
--- feature/
----- user/
------- profile/
------- avatar/
----- post/
------- post-item/
------- post-list/
----- payment/
------- payment-form/
------- payment-wizard/
------- services/
--------- currency/
----------- index.js
----------- service.js
----------- test.js
----- error/
------- error-message/
------- error-boundary/
------- services/
--------- error-tracking/
----------- index.js
----------- service.js
----------- test.js
--- components/
--- hooks/
--- context/
--- services/
----- format/
------- date-time/
--------- index.js
--------- service.js
--------- test.js
Создавать ли промежуточную папку services/ внутри каждой feature-папки — остаётся на ваше усмотрение. Вы можете отказаться от этой идеи и поместить папку error-tracking/ прямо в error/. Однако это может сбивать с толку, поскольку error-tracking должен быть каким-то образом обозначен как сервис, а не как React-компонент. Так что можно пойти дальше с этой структурой для отдельных feature-папок, добавив в них технические папки:
----- error/
------- components/
--------- error-message/
--------- error-boundary/
------- services/
--------- error-tracking/
----------- index.js
----------- service.js
Здесь остается много простора для вашего или вашей команды индивидуального подхода. В конце концов, главная задача на этом этапе — это собрать функциональность вместе, что позволит командам в вашей компании работать над конкретными features (фичами/функциями), не затрагивая файлы по всему проекту. То, насколько глубоко вы вкладываете папки и где вы разделяете технические concerns (аспекты/зоны ответственности), остается на ваше усмотрение.
- src/
--- feature/
----- feature-one/
------- technical-concern-one/
------- technical-concern-two/
------- ... // <--- maybe more technical concerns
----- feature-two/
------- technical-concern-one/
------- technical-concern-two/
------- ... // <--- maybe more technical concerns
--- components/
--- hooks/
--- context/
--- services/
... // <--- maybe more globally shared technical folders
Общая идея данного подхода заключается в том, чтобы отделить компоненты, связанные с фичами (функциональностью), от переиспользуемых компонентов, а технические аспекты — от компонентов, связанных с фичами.
Бонус: Page-Driven подход
Рано или поздно в вашем React-приложении появится несколько страниц. Если вы используете фреймворк вроде Next.js, у вас будет папка app/, куда вы помещаете свои page.tsx файлы для организации файловой маршрутизации.
Однако если вы работаете с клиентским React-приложением (например, на связке React + Vite), вам также может быть полезно организовать проект вокруг папки pages/. Это оправдано тем, что страницы являются точками входа, с помощью которых пользователи взаимодействуют с вашим приложением.
- src/
--- pages/
--- feature/
--- components/
--- hooks/
--- context/
--- services/
В проекте Next.js папка app выполняет роль папки pages. Следующая структура — это пример того, как её можно организовать для CRUD-приложения, сфокусированного на функциональности постов:
- src/
--- app/
----- page.tsx
----- posts/
------- page.tsx
------- [postId]
--------- page.tsx
--- feature/
----- post/
------- post-list/
----- comment/
------- comment-list/
--- components/
----- list/
--- hooks/
--- context/
--- services/
В этом примере пользователь может перейти на страницу /posts, чтобы увидеть ленту постов, и на страницу /posts/[postId] — для просмотра конкретного поста с комментариями. Если компонент PostList будет использоваться на странице /posts, то CommentList — как раз для страницы /posts/[postId]. Оба этих компонента-списка будут повторно использовать базовый компонент List из папки components/.
Данную структуру папок можно обсуждать очень долго, ведь здесь есть множество аспектов для рассмотрения. Не стоит воспринимать следующие моменты как абсолютную истину — скорее, как отправную точку для дискуссии.
-
Должна ли папка функциональности комментариев быть вложенной в папку функциональности постов?
Да, это возможно, если комментарии используются исключительно в рамках функциональности постов
-
Нет, не должна, если функциональность комментариев используется несколькими features (функциональностями/модулями).
- Например, если в приложении есть другие features, такие как goals/ (цели) или achievements/ (достижения), где пользователи также могут оставлять комментарии, то функциональность комментариев должна быть общей (shared feature) для всех этих модулей.
-
Должен ли компонент списка (List) быть вложенным в папку функциональности постов?
Да, может быть, если функциональность постов — единственная, где используется этот компонент списка.
-
Нет, не должен, если компонент списка используется несколькими features.
- В нашем примере выше компонент списка используется как для постов, так и для комментариев. Следовательно, он должен быть общим (shared component) для этих функциональностей.
-
Существуют React-фреймворки (например, Next.js), которые позволяют использовать приватные папки (private folders) внутри pages/ (или app/ в Next.js). Если такая возможность есть, следует ли переносить функциональность постов в виде приватной папки в pages/posts/?
-
Да, технически это возможно, если функциональность постов используется только на странице /posts.
- Однако я всегда рекомендую против такого подхода, потому что: 1) это нарушает единообразие структуры папок с функциональностями. Если вы выбрали структуру, основанную на features, стоит соблюдать её последовательно throughout the project. И 2) это снижает гибкость архитектуры. Если позже вы захотите повторно использовать функциональность постов на другой странице, вам придётся выносить её из приватной папки обратно.
-
Надеюсь, это руководство поможет разработчикам или командам в организации их React-проектов. Важно понимать: ни один из представленных подходов не является догмой. Напротив, я призываю вас творчески адаптировать их под свои нужды.
Любой React-проект со временем растет, и его структура папок обычно evolves естественным образом. Предложенные подходы — это всего лишь ориентир, который поможет навести порядок, если всё выйдет из-под контроля.