Хабрахабр

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

Материал, перевод которого мы сегодня публикуем, раскрывает подходы, применяемые его автором при структурировании 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-правил для компонентов.

Однако в ходе работы над проектами, в которой участвует много разработчиков, оказалось, что наличие в проекте реальных CSS-файлов повышает удобство работы. Я, кроме того, очень люблю технологию styled-components.

Это значительно упрощает перемещение между файлами тогда, когда нужно быстро понять смысл того или иного класса. Как вы уже, наверное, догадались, наши CSS-файлы располагаются, как и другие файлы, рядом с файлами компонентов, в тех же самых папках.

Прошли те дни, когда отдельные папки использовались для хранения CSS и JS-кода, кода тестов и прочих ресурсов. Более общая рекомендация, суть которой пронизывает весь этот материал, заключается в том, что весь код, имеющий отношение к некоему компоненту, стоит держать в той же папке, в которой находится этот компонент. Держать в одной и той же папке взаимосвязанные файлы — это значит тратить меньше времени на перемещение между папками в ходе работы. Использование сложных структур папок усложняет перемещение между файлами и не несёт в себе никакой очевидной пользы за исключением того, что это помогает «организовывать код».

Он проверяет объявленные имена классов и выдаёт ошибку в консоль в том случае, если мы ссылаемся на несуществующий класс. Мы даже создали Webpack-загрузчик для CSS, возможности которого соответствуют особенностям нашей работы.

Почти всегда в одном файле размещается код лишь одного компонента

Мой опыт показывает, что программисты обычно слишком жёстко придерживаются правила, в соответствии с которым в одном файле должен находиться код одного и только одного React-компонента. При этом я вполне поддерживаю идею, в соответствии с которой не стоит размещать в одном файле слишком много компонентов (только представьте себе сложности именования таких файлов!). Но я полагаю, что нет ничего плохого в том, чтобы поместить в тот же файл, в котором размещён код некоего «большого» компонента, и код «маленького» компонента, связанного с ним. Если подобный ход способствует сохранению чистоты кода, если «маленький» компонент не слишком велик для того, чтобы помещать его в отдельный файл, то это никому не повредит.

Например, если я создаю компонент <Product>, и мне нужен маленький фрагмент кода для вывода цены, то я могу поступить так:

const Price = () => ( <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, которая нам очень нравится, в этом необходимости нет. Всякий раз, когда я используют в своём проекте библиотеку стороннего разработчика, я думаю о том, как сделать так, чтобы, при необходимости, её можно было бы как можно легче заменить на что-то другое. Но, на всякий случай, никогда не помешает продумать путь ухода от использования некого сервиса или путь смены его на что-то другое.

Это — нечто вроде создания модуля lib/error-reporting.js, который экспортирует функцию reportError(). Лучшим решением подобной задачи является разработка собственного API, скрывающего чужие инструменты. Но 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-приложения?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть