Pull to refresh
2885.29
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Структурирование React-приложений

Reading time16 min
Views65K
Original author: Jack Franklin
Материал, перевод которого мы сегодня публикуем, раскрывает подходы, применяемые его автором при структурировании 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-приложения?

Tags:
Hubs:
+28
Comments13

Articles

Information

Website
ruvds.com
Registered
Founded
Employees
11–30 employees
Location
Россия
Representative
ruvds