Материал, перевод которого мы сегодня публикуем, раскрывает подходы, применяемые его автором при структурировании React-приложений. В частности, речь здесь пойдёт об используемой структуре папок, об именовании сущностей, о местах, где располагаются файлы тестов, и о других подобных вещах.
Одна из наиболее приятных возможностей React заключается в том, что эта библиотека не принуждает разработчика к строгому соблюдению неких соглашений, касающихся структуры проекта. Многое в этом плане остаётся на усмотрение программиста. Этот подход отличается от того, который, скажем, принят во фреймворках Ember.js или Angular. Они дают разработчикам больше стандартных возможностей. В этих фреймворках предусмотрены и соглашения, касающиеся структуры проектов, и правила именования файлов и компонентов.
Лично мне нравится подход, принятый в React. Дело в том, что я предпочитаю контролировать что-либо сам, не полагаясь на некие «соглашения». Однако много плюсов есть и у того подхода к структурированию проектов, который предлагает тот же Angular. Выбор между свободой и более или менее жёсткими правилами сводится к тому, что именно ближе вам и вашей команде.
За годы работы с React я испробовал множество различных способов структурирования приложений. Некоторые из применённых мной идей оказались более удачными, чем другие. Поэтому здесь я собираюсь рассказать обо всём том, что хорошо показало себя на практике. Я надеюсь, что вы найдёте здесь что-то такое, что пригодится и вам.
Я не стремлюсь показать здесь некий «единственно правильный» способ структурирования приложений. Вы можете взять какие-то мои идеи и изменить их под свои нужды. Вы вполне можете со мной и не согласиться, продолжив работать так, как работали раньше. Разные команды создают разные приложения и используют разные средства для достижения своих целей.
Важно отметить, что если вы заглянете на сайт Thread, в разработке которого я участвую, и посмотрите на устройство его интерфейса, то вы обнаружите там места, в которых те правила, о которых я буду говорить, не соблюдаются. Дело в том, что любые «правила» в программировании стоит воспринимать лишь как рекомендации, а не как всеохватывающие нормативы, которые справедливы в любых ситуациях. И если вы думаете, что какие-то «правила» вам не подходят — вы, ради повышения качества того, над чем работаете, должны находить в себе силу отступать от этих «правил».
Собственно говоря, теперь, без лишних слов, предлагаю вам мой рассказ о структурировании React-приложений.
Не стоит слишком сильно беспокоиться о правилах
Возможно, вы решите, что рекомендация о том, что не стоит слишком волноваться о правилах, выглядит в начале нашего разговора странно. Но я имею в виду именно это, когда говорю, что главная ошибка, возникающая у программистов в плане соблюдения правил, заключается в том, что программисты придают правилам слишком большое значение. Это особенно справедливо в начале работы над новым проектом. В момент создания первого index.jsx
попросту невозможно знать о том, что лучше всего подойдёт для этого проекта. По мере того, как проект будет развиваться, вы совершенно естественным образом выйдете на некую структуру файлов и папок, которая, вероятно, будет весьма неплохо для этого проекта подходить. Если же в ходе продолжения работы окажется, что сложившаяся структура в чём-то неудачна, её можно будет улучшить.
Если вы читаете это и ловите себя на мысли о том, что в вашем приложении нет ничего такого, о чём тут идёт речь, то это — не проблема. Каждое приложение своеобразно, не существует двух абсолютно одинаковых команд разработчиков. Поэтому каждая команда, работая над проектом, приходит к неким соглашениям относительно его структуры и методики работы над ним. Это помогает членам команды трудиться продуктивно. Не стремитесь к тому, чтобы, узнав о том, как кто-то что-то делает, немедленно вводить подобное и у себя. Не пытайтесь внедрить в свою работу то, что названо в неких материалах, да хотя бы и в этом, «наиболее эффективным способом» решения некоей задачи. Я всегда придерживался и придерживаюсь следующей стратегии относительно подобных рекомендаций. У меня есть собственный набор правил, но, читая о том, как в тех или иных ситуациях поступают другие, я выбираю то, что мне кажется удачным и подходящим мне. Подобное приводит к тому, что со временем мои методы работы совершенствуются. При этом у меня не случается никаких потрясений и не возникает желания переписывать всё с нуля.
Важные компоненты размещаются в отдельных папках
Подход к размещению файлов компонентов по папкам, к которому я пришёл, заключается в том, что те компоненты, которые в контексте приложения можно считать «важными», «базовыми», «основными», размещаются в отдельных папках. Эти папки, в свою очередь, размещаются в папке components
. Например, если речь идёт о приложении для электронного магазина, то подобным компонентом можно признать компонент <Product>
, используемый для описания товара. Вот что я имею в виду:
- src/
- components/
- product/
- product.jsx
- product-price.jsx
- navigation/
- navigation.jsx
- checkout-flow/
- checkout-flow.jsx
При этом «второстепенные» компоненты, которые используются только некими «основными» компонентами, располагаются в той же папке, что и эти «основные» компоненты. Этот подход хорошо показал себя на практике. Дело в том, что благодаря его применению в проекте появляется некая структура, но уровень вложенности папок оказывается не слишком большим. Его применение не приводит к появлению чего-то вроде ../../../
в командах импорта компонентов, он не затрудняет перемещение по проекту. Этот подход позволяет построить чёткую иерархию компонентов. Тот компонент, имя которого совпадает с именем папки, считается «базовым». Другие компоненты, находящиеся в той же папке, служат цели разделения «базового» компонента на части, что упрощает работу с кодом этого компонента и его поддержку.
Хотя я и являюсь сторонником наличия в проекте некоей структуры папок, я считаю, что самое главное — это подбор хороших имён файлов. Папки, сами по себе, менее важны.
Использование вложенных папок для подкомпонентов
Один из минусов вышеописанного подхода заключается в том, что его применение может привести к появлению папок «базовых» компонентов, содержащих очень много файлов. Рассмотрим, например, компонент <Product>
. К нему будут прилагаться CSS-файлы (о них мы ещё поговорим), файлы тестов, множество подкомпонентов, и, возможно, другие ресурсы — вроде изображений и SVG-иконок. Этим список «дополнений» не ограничивается. Всё это попадёт в ту же папку, что и «базовый» компонент.
Я, на самом деле, не особенно об этом беспокоюсь. Меня это устраивает в том случае, если файлы имеют продуманные имена, и если их легко можно найти (с помощью средств поиска файлов в редакторе). Если всё так и есть, то структура папок отходит на второй план. Вот твит на эту тему.
Однако если вы предпочитаете, чтобы ваш проект имел бы более разветвлённую структуру, нет ничего сложного в том, чтобы переместить подкомпоненты в их собственные папки:
- src/
- components/
- product/
- product.jsx
- ...
- product-price/
- product-price.jsx
Файлы тестов располагаются там же, где и файлы проверяемых компонентов
Начнём этот раздел с простой рекомендации, которая заключается в том, что файлы тестов стоит размещать там же, где и файлы с кодом, который с их помощью проверяют. Я ещё расскажу о том, как я предпочитаю структурировать компоненты, стремясь к тому, чтобы они находились бы поблизости друг от друга. Но сейчас я могу сказать, что нахожу удобным размещать файлы тестов в тех же папках, что и файлы компонентов. При этом имена файлов с тестами идентичны именам файлов с кодом. К именам тестов, перед расширением имени файла, лишь добавляется суффикс .test
:
- Имя файла компонента:
auth.js
. - Имя файла теста:
auth.test.js
.
У такого подхода есть несколько сильных сторон:
- Он облегчает поиск файлов тестов. С одного взгляда можно понять то, существует ли тест для компонента, с которым я работаю.
- Все необходимые команды импорта оказываются очень простыми. В тесте, для импорта тестируемого кода, не нужно создавать структуры, описывающие, скажем, выход из папки
__tests__
. Подобные команды выглядят предельно просто. Например — так:import Auth from './auth'
.
Если у нас имеются некие данные, используемые в ходе теста, например — нечто вроде моков запросов к API, мы помещаем и их в ту же папку, где уже лежит компонент и его тест. Когда всё, что может понадобиться, лежит в одной папке, это способствует росту продуктивности работы. Например, если используется разветвлённая структура папок и программист уверен в том, что некий файл существует, но не может вспомнить его имя, программисту придётся выискивать этот файл во множестве вложенных директорий. При предлагаемом подходе достаточно взглянуть на содержимое одной папки и всё станет ясно.
CSS-модули
Я — большой поклонник CSS-модулей. Мы обнаружили, что они отлично подходят для создания модульных CSS-правил для компонентов.
Я, кроме того, очень люблю технологию styled-components. Однако в ходе работы над проектами, в которой участвует много разработчиков, оказалось, что наличие в проекте реальных CSS-файлов повышает удобство работы.
Как вы уже, наверное, догадались, наши CSS-файлы располагаются, как и другие файлы, рядом с файлами компонентов, в тех же самых папках. Это значительно упрощает перемещение между файлами тогда, когда нужно быстро понять смысл того или иного класса.
Более общая рекомендация, суть которой пронизывает весь этот материал, заключается в том, что весь код, имеющий отношение к некоему компоненту, стоит держать в той же папке, в которой находится этот компонент. Прошли те дни, когда отдельные папки использовались для хранения CSS и JS-кода, кода тестов и прочих ресурсов. Использование сложных структур папок усложняет перемещение между файлами и не несёт в себе никакой очевидной пользы за исключением того, что это помогает «организовывать код». Держать в одной и той же папке взаимосвязанные файлы — это значит тратить меньше времени на перемещение между папками в ходе работы.
Мы даже создали Webpack-загрузчик для CSS, возможности которого соответствуют особенностям нашей работы. Он проверяет объявленные имена классов и выдаёт ошибку в консоль в том случае, если мы ссылаемся на несуществующий класс.
Почти всегда в одном файле размещается код лишь одного компонента
Мой опыт показывает, что программисты обычно слишком жёстко придерживаются правила, в соответствии с которым в одном файле должен находиться код одного и только одного React-компонента. При этом я вполне поддерживаю идею, в соответствии с которой не стоит размещать в одном файле слишком много компонентов (только представьте себе сложности именования таких файлов!). Но я полагаю, что нет ничего плохого в том, чтобы поместить в тот же файл, в котором размещён код некоего «большого» компонента, и код «маленького» компонента, связанного с ним. Если подобный ход способствует сохранению чистоты кода, если «маленький» компонент не слишком велик для того, чтобы помещать его в отдельный файл, то это никому не повредит.
Например, если я создаю компонент <Product>
, и мне нужен маленький фрагмент кода для вывода цены, то я могу поступить так:
const Price = ({ price, currency }) => (
<span>
{currency}
{formatPrice(price)}
</span>
)
const Product = props => {
// представьте, что здесь находится большой объём кода!
return (
<div>
<Price price={props.price} currency={props.currency} />
<div>loads more stuff...</div>
</div>
)
}
В этом подходе хорошо то, что мне не пришлось создавать отдельный файл для компонента <Price>
, и то, что этот компонент доступен исключительно компоненту <Product>
. Мы не экспортируем этот компонент, поэтому его нельзя импортировать в других местах приложения. Это означает, что на вопрос о том, надо ли выносить <Price>
в отдельный файл, можно дать чёткий положительный ответ в том случае, если понадобится импортировать его где-нибудь ещё. В противном случае можно обойтись и без выноса кода <Price>
в отдельный файл.
Выделение отдельных папок для универсальных компонентов
Мы в последнее время пользуемся универсальными компонентами. Они, фактически, формируют нашу дизайн-систему (которую мы рассчитываем когда-нибудь опубликовать), но пока мы начали с малого — с компонентов вроде <Button>
и <Logo>
. Некий компонент считается «универсальным» в том случае, если он не привязан к какой-то определённой части сайта, но является одним из строительных блоков пользовательского интерфейса.
Подобные компоненты располагаются в собственной папке (src/components/generic
). Это значительно упрощает работу со всеми универсальными компонентами. Они находятся в одном месте — это очень удобно. Со временем, по мере роста проекта, мы планируем разработать руководство по стилю (мы — большие любители react-styleguidist) для того чтобы ещё больше упростить работу с универсальными компонентами.
Использование псевдонимов для импорта сущностей
Сравнительно плоская структура папок в наших проектах способствует тому, что в командах импорта нет слишком длинных конструкций вроде ../../
. Но совсем без них обойтись сложно. Поэтому мы использовали babel-plugin-module-resolver для настройки псевдонимов, которые упрощают команды импорта.
Сделать то же самое можно и с помощью Webpack, но благодаря использованию плагина Babel такие же команды импорта могут работать и в тестах.
Мы настроили это с помощью пары псевдонимов:
{
components: './src/components',
'^generic/([\w_]+)': './src/components/generic/\1/\1',
}
Первый устроен чрезвычайно просто. Он позволяет импортировать любой компонент, начиная команду со слова components
. При обычном подходе команды импорта выглядят примерно так:
import Product from '../../components/product/product'
Мы вместо этого можем записывать их так:
import Product from 'components/product/product'
Обе команды приводят к импорту одного и того же файла. Это очень удобно, так как позволяет не задумываться о структуре папок.
Второй псевдоним устроен немного сложнее:
'^generic/([\w_]+)': './src/components/generic/\1/\1',
Мы используем здесь регулярное выражение. Оно находит команды импорта, которые начинаются с generic
(знак ^
в начале выражения позволяет отобрать только те команды, которые начинаются с generic
), и захватывает то, что находится после generic/
, в группу. После этого мы используем захваченный фрагмент (\1
) в конструкции ./src/components/generic/\1/\1
.
В результате мы можем пользоваться командами импорта универсальных компонентов такого вида:
import Button from 'generic/button'
Они преобразуются в такие команды:
import Button from 'src/components/generic/button/button'
Эта команда, например, служит для импорта JSX-файла, описывающего универсальную кнопку. Сделали мы всё это из-за того, что подобный подход значительно упрощает импорт универсальных компонентов. Кроме того, он сослужит нам хорошую службу в том случае, если мы решим изменить структуру файлов проекта (это, так как наша дизайн-система растёт, вполне возможно).
Тут мне хотелось бы отметить, что при работе с псевдонимами стоит соблюдать осторожность. Если у вас их всего несколько, и они предназначены для решения стандартных задач импорта — то всё нормально. Но если их у вас будет много — они могут принести больше путаницы, чем пользы.
Универсальная папка lib для утилит
Хотелось бы мне вернуть себе всё то время, которое я потратил на то, чтобы отыскать идеальное место для кода, который не является кодом компонентов. Я разделял это всё по разным принципам, выделяя код утилит, сервисов, вспомогательных функций. У всего этого так много названий, что все их я и не упомню. Теперь же я не пытаюсь выяснить разницу между «утилитой» и «вспомогательной функцией» для того чтобы подобрать подходящее место для некоего файла. Теперь я использую гораздо более простой и понятный подход: всё это попадает в единственную папку lib
.
В долгосрочной перспективе размер этой папки может оказаться настолько большим, что придётся её как-то структурировать, но это совершенно нормально. Всегда легче оснастить что-то некоей структурой, чем избавляться от ошибок чрезмерного структурирования.
В нашем проекте Thread папка lib
содержит около 100 файлов. Они разделены примерно поровну на файлы, содержащие реализацию неких возможностей, и на файлы тестов. Сложностей при поиске нужных файлов это не вызывало. Благодаря интеллектуальным системам поиска, встроенным в большинство редакторов, мне, практически всегда, достаточно ввести нечто вроде lib/name_of_thing
, и то, что мне нужно, оказывается найденным.
Кроме того, у нас имеется псевдоним, который упрощает импорт из папки lib
, позволяя пользоваться командами такого вида:
import formatPrice from 'lib/format_price'
Не пугайтесь плоских структур папок, при использовании которых одна папка может хранить множество файлов. Обычно такая структура — это всё, что нужно для некоего проекта.
Сокрытие библиотек сторонней разработки за собственными API
Мне очень нравится система мониторинга ошибок Sentry. Я часто пользовался ей при разработке серверных и клиентских частей приложений. С её помощью можно захватывать исключения и получать уведомлений об их возникновении. Это — отличный инструмент, который позволяет нам быть в курсе проблем, возникающих на сайте.
Всякий раз, когда я используют в своём проекте библиотеку стороннего разработчика, я думаю о том, как сделать так, чтобы, при необходимости, её можно было бы как можно легче заменить на что-то другое. Часто, как с той же системой Sentry, которая нам очень нравится, в этом необходимости нет. Но, на всякий случай, никогда не помешает продумать путь ухода от использования некого сервиса или путь смены его на что-то другое.
Лучшим решением подобной задачи является разработка собственного API, скрывающего чужие инструменты. Это — нечто вроде создания модуля lib/error-reporting.js
, который экспортирует функцию reportError()
. В недрах этого модуля используется Sentry. Но Sentry напрямую импортируется только в этом модуле и нигде больше. Это означает, что замена Sentry на другой инструмент будет выглядеть очень просто. Для этого достаточно будет поменять один файл в одном месте. До тех пор, пока общедоступный API этого файла остаётся неизменным, остальная часть проекта не будет даже знать о том, что при вызове reportError()
используется не Sentry, а что-то другое.
Обратите внимание на то, что общедоступным API модуля называют экспортируемые им функции и их аргументы. Их ещё называют общедоступным интерфейсом модуля.
Использование PropTypes (либо таких средств, как TypeScript или Flow)
Когда я занимаюсь программированием, я думаю о трёх версиях самого себя:
- Джек из прошлого и код, который он написал (иногда — сомнительный код).
- Сегодняшний Джек, и тот код, который он пишет сейчас.
- Джек из будущего. Когда я думаю об этом будущем себе, я спрашиваю себя настоящего о том, как мне писать такой код, который облегчит мне в будущем жизнь.
Может, это прозвучит странновато, но я обнаружил, что полезно, размышляя о том, как писать код, задаваться следующим вопросом: «Как он будет восприниматься через полгода?».
Один из простых способов сделать себя настоящего и себя будущего продуктивнее заключается в указании типов свойств (PropTypes
), используемых компонентами. Это позволит сэкономить время на поиск возможных опечаток. Это убережёт от ситуаций, когда, пользуясь компонентом, применяют свойства неправильных типов, или вовсе забывают о передаче свойств. В нашем случае хорошим напоминанием о необходимости использования PropTypes
служит правило eslint-react/prop-types.
Если пойти ещё дальше, то рекомендуется описывать свойства как можно точнее. Например, можно поступить так:
blogPost: PropTypes.object.isRequired
Но гораздо лучше будет сделать так:
blogPost: PropTypes.shape({
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
// и так далее
}).isRequired
В первом примере выполняется минимально необходимая проверка. Во втором разработчику даётся гораздо больше полезных сведений. Они придутся очень кстати, например, в том случае, если некто забудет о некоем поле, используемом в объекте.
Сторонние библиотеки используются лишь тогда, когда они по-настоящему нужны
Этот совет сегодня, с появлением хуков React, актуален как никогда. Например, я занимался большой переделкой одной из частей сайта Thread и решил обратить особое внимание на использование сторонних библиотек. Я предположил, что используя хуки и некоторые собственные наработки, я смогу сделать много всего и без использования чужого кода. Моё предположение (что стало приятной неожиданностью), оказалось верным. Об этом можно почитать здесь, в материале про управление состоянием React-приложений. Если вас привлекают подобные идеи — учитывайте то, что в наши дни, благодаря хукам React и API Context, в реализации этих идей можно зайти очень далеко.
Конечно, некоторые вещи, вроде Redux, в определённых обстоятельствах необходимы. Я посоветовался бы не стремиться к тому, чтобы совершенно сторониться подобных решений (и не стоит ставить во главу угла уход от них в том случае, если вы уже ими пользуетесь). Но, если у вас возникает мысль о включении в проект новой библиотеки, вам стоит знать, что это далеко не всегда является единственным вариантом решения некоей задачи.
Неприятные особенности генераторов событий
Генератор событий — это паттерн проектирования, который позволяет наладить взаимодействие между парой компонентов, не связанных напрямую. Раньше я часто пользовался этим паттерном.
// первый компонент генерирует событие
emitter.send('user_add_to_cart')
// второй компонент принимает событие
emitter.on('user_add_to_cart', () => {
// делаем что-то полезное
})
Я объяснял использование этого паттерна тем, что при таком подходе компоненты могут быть полностью отделены друг от друга. Я оправдывал этот подход тем, что компоненты могут обмениваться данными исключительно с помощью механизма отправки и обработки событий. Неприятности мне принесло как раз то, что компоненты «отделены друг от друга». Хотя может показаться, что компоненты и являются самостоятельными сущностями, я сказал бы, что это не так. Они всего лишь имеют неявную зависимость друг от друга. «Неявной» эту зависимость я называю преимущественно из-за того, что я считал сильной стороной этого паттерна. А именно, речь идёт о том, что компоненты не знают о существовании друг друга.
Нечто подобное можно обнаружить в Redux. Компоненты напрямую друг с другом не общаются. Они взаимодействуют с дополнительной структурой, называемой действием. Логика того, что происходит при возникновении события, вроде user_add_to_cart
, находится в редьюсере. В результате всем этим становится легче управлять. Кроме того, инструменты, используемые при разработке приложений с применением Redux, упрощают поиск действий и их источников. В результате те дополнительные структуры, которые используются в Redux при работе с событиями, оказывают положительное влияние на проект.
После того, как мне довелось поработать над многими проектами, полными генераторов событий, я обнаружил, что там регулярно происходит следующее:
- Некий код, ожидающий появления событий, удаляют. После этого генераторы событий отправляют эти события в пустоту.
- Некий код, отправляющий события, тоже могут удалить. В результате прослушиватели событий ожидают возникновения событий, которые ничто не генерирует.
- Некое событие, которое кто-то счёл неважным, удаляют. Это, так как событие было важным, серьёзно нарушает работу проекта.
Всё это плохо из-за того, что ведёт к неопределённости. Это ведёт к тому, что программист теряет уверенность в правильности устройства кода, над которым работает. Когда программист не знает о том, может ли он спокойно удалить некий фрагмент кода, вроде бы ненужный, этот фрагмент кода обычно не удаляется и в проекте накапливается «мёртвый» код.
В современных условиях я решал бы задачи, которые можно решить с помощью генераторов событий, используя API Context или свойства-коллбэки.
Упрощение тестирования с использованием специальных утилит
В итоге мне хотелось бы дать ещё один совет, касающийся тестирования компонентов (я, кстати, написал об этом учебный курс). Этот совет заключается в следующем: создайте набор вспомогательных функций, которые позволят упростить процесс тестирования компонентов.
Например, однажды я написал приложение, в котором статус аутентификации пользователя хранился в небольшом фрагменте контекста, который был нужен множеству компонентов. В каждом тесте я мог бы поступить так:
const wrapper = mount(
<UserAuth.Provider value=>
<ComponentUnderTest />
</UserAuth.Provider>
)
Я же написал небольшой вспомогательный механизм:
const wrapper = mountWithAuth(ComponentUnderTest, {
name: 'Jack',
userId: 1,
})
У такого подхода имеется множество сильных сторон:
- Каждый тест оказывается предельно понятным. При взгляде на тест сразу ясно — работает ли он в условиях, когда пользователь вошёл в систему, или в условиях, когда пользователь в неё не вошёл.
- Если реализация механизма аутентификации изменяется — я могу обновить
mountWithAuth
и все мои тесты продолжат нормально работать. Всё дело в том, что логика аутентификации в моей системе размещается в одном месте.
Не опасайтесь того, что вы создадите слишком много подобных вспомогательных утилит в файле test-utils.js
. То, что их у вас будет много — это вполне нормально. Такие утилиты упростят процесс тестирования ваших проектов.
Итоги
Здесь я поделился многим из своего опыта. Мои советы направлены на то, чтобы улучшить поддерживаемость кодовой базы, и, что важнее, сделать работу над растущими проектами приятнее. Хотя у каждой кодовой базы есть собственные старые проблемы, существуют приёмы, которые позволяют смягчить негативные воздействия этих проблем на проекты и не допустить появления новых. В начале материала я уже об этом говорил, но повторюсь снова: любые советы стоит рассматривать лишь как рекомендации. При применении чужих идей к конкретному проекту они преобразовываются в соответствии со спецификой этого проекта. Ведь все мы имеем разные мнения относительно структуры проектов и по-разному подходим к решению различных задач, касающихся разработки приложений.
Уважаемые читатели! Как вы структурируете ваши React-приложения?
Автор: ru_vds