[Перевод] Организация разработки крупномасштабных React-приложений

Эта публикация основана на серии материалов о модернизации jQuery-фронтенда с использованием React. Для того чтобы лучше разобраться в причинах, по которым написан этот материал, рекомендуется взглянуть на первый материал этой серии.

5xz3hut_vcf_-j4yrfn7tvnw2xq.png

В наши дни очень легко организовать разработку маленького React-приложения, или начать работу над ним с нуля. В особенности — при использовании create-react-app. Некоторым проектам, скорее всего, понадобится лишь несколько зависимостей (например — для управления состоянием приложения и для интернационализации проекта) и папка src, в которой, как минимум, имеется директория components. Я полагаю, что именно с такой структуры начинается работа над большинством React-проектов. Обычно, правда, по мере того, как растёт количество зависимостей проекта, программисты сталкиваются с ростом количества компонентов, редьюсеров и других входящих в его состав механизмов, предназначенных для многократного использования. Иногда всем этим становится очень неудобно и сложно управлять. Что делать, например, если больше неясно то, почему нужны некоторые зависимости, и то, как они сочетаются друг с другом? Или, как быть, если в проекте накопилось так много компонентов, что становится сложно найти среди них именно тот, который нужен? Как поступить в том случае, если программисту нужно найти некий компонент, имя которого забыто?
Это — лишь некоторые примеры тех вопросов, на которые нам пришлось искать ответы, занимаясь переработкой фронтенда в Karify. Мы знали о том, что количество зависимостей и компонентов проекта однажды может выйти из-под контроля. Это означало, что нам нужно было так всё спланировать, чтобы, по мере роста проекта, мы смогли бы уверенно продолжать работу над ним. Это планирование включало в себя выработку соглашений по поводу структуры файлов и папок, по поводу качества кода. Сюда входило и описание общей архитектуры проекта. А самое главное — нужно было сделать так, чтобы всё это легко могли бы воспринять новые программисты, приходящие в проект, чтобы им, для включения в работу, не приходилось бы слишком долго изучать проект, разбираясь во всех его зависимостях и в стиле его кода.

В момент написания этого материала в нашем проекте имеется около 1200 JavaScript-файлов. 350 из них — это компоненты. Код покрыт модульными тестами на 80%. Так как мы по-прежнему придерживаемся установленных нами соглашений и работаем в рамках ранее созданной архитектуры проекта, мы решили, что хорошо было бы обо всём этом рассказать широкой общественности. Так и появилась эта статья. Здесь речь пойдёт об организации разработки крупномасштабного React-приложения, и о том, какие уроки мы извлекли из опыта работы над ним.

Как организовать файлы и папки?


Мы нашли способ удобной организации материалов React-фронтенда только пройдя через несколько стадий работы над проектом. Изначально мы собирались размещать материалы проекта в том же репозитории, где хранился код фронтенда, основанного на jQuery. Однако из-за требований к структуре папок, налагаемых на проект используемым нами бэкенд-фреймворком, нам этот вариант не подошёл. Далее, мы подумали о перемещении кода фронтенда в отдельный репозиторий. Сначала этот подход показал себя хорошо, но со временем мы начали размышлять о создании других клиентских частей проекта, например — о фронтенде, основанном на React Native. Это заставило нас задуматься о библиотеке компонентов. В результате мы разделили новый репозиторий на два отдельных репозитория. Один из них был предназначен для библиотеки компонентов, а второй — для нового React-фронтенда. Даже хотя поначалу нам эта идея показалась удачной, её реализация привела к серьёзному усложнению процедуры проведения код-ревью. Неясными стали взаимоотношения изменений в наших двух репозиториях. В итоге мы решили опять перейти к хранению кода в единственном репозитории, но теперь это был монорепозиторий.

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

Мы настроили наш монорепозитрий с использованием yarn workspaces, используя следующую конфигурацию в корневом файле package.json:

"workspaces": [
    "app/*",
    "lib/*",
    "tool/*"
]


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

  • app: все пакеты, находящиеся в этой папке, имеют отношение к фронтенд-приложениям, к таким, как фронтенд Karify, к некоторым другим внутренним фронтендам. Тут же хранятся и наши Storybook-материалы.
  • lib: все пакеты из этой папки предоставляют утилиты для фронтенд-приложений, предназначенные для многократного использования и созданные так, чтобы они как можно меньше ориентировались бы на конкретное приложение. Эти пакеты, в целом, и формируют нашу библиотеку компонентов. В качестве примеров того, что хранится в этой папке, можно привести пакеты typography, media и primitive.
  • tool: все пакеты, хранящиеся в этой папке, предназначены для Node.js. Они либо представляют собой инструменты, созданные нами, либо настройки и утилиты для инструментов, которыми мы пользуемся. В этой папке, например, хранятся утилиты для webpack, настройки линтера и системы, контролирующей структуру проекта (мы называем её «линтер файловой системы»).


Все наши пакеты, независимо от папки, в которой они хранятся, имеют подпапку src, и, что необязательно, папку bin. В папках src пакетов, хранящихся в директориях app и lib, могут иметься некоторые из следующих подпапок:

  • actions: содержит функции для создания действий, возвращаемые значения которых могут быть переданы функции диспетчеризации из redux или useReducer.
  • components: содержит папки компонентов с их кодом, переводы, модульные тесты, снимки, истории (если это применимо к конкретному компоненту).
  • constants: такая папка хранит неизменные значения, используемые в различных окружениях. Здесь же хранятся утилиты.
  • fetch: тут хранятся определения типов для обработки данных, получаемых от нашего API, а так же — соответствующие асинхронные действия, используемые для получения таких данных.
  • helpers: хранит утилиты, которые не вписываются ни в одну из других категорий.
  • reducers: тут находятся редьюсеры, которые предназначены для использования в хранилище redux или в useReducer.
  • routes: здесь хранятся определения, которые предназначены для использования в компонентах react-router или в функциях history.
  • selectors: здесь содержится код вспомогательных функций, которые выполняют чтение или преобразование данных из redux-состояния, или работают с данными, получаемыми от наших API.


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

Мы, начав применять эту структуру папок, столкнулись с задачей обеспечения единообразного применения такой структуры. При работе с разными пакетами у разработчика может возникнуть желание создавать в папках этих пакетов различные папки, по-разному организовывать файлы в этих папках. Хотя это и не всегда плохо, подобный неорганизованный подход привёл бы к беспорядку. Для того чтобы помочь нам в деле систематического применения вышеописанной структуры, мы создали то, что можно назвать «линтером файловой системы». Об этом мы сейчас и поговорим.

Как обеспечить обязательное применение руководства по стилю?


Мы стремились к единообразию структуры файлов и папок нашего проекта. Того же самого мы хотели добиться и для кода. У нас уже был к тому моменту успешный опыт решения подобной задачи в jQuery-версии проекта, но нам было что улучшать, в особенности — если говорить о CSS. В результате мы решили с нуля создать руководство по стилю и обеспечить его обязательное применение с помощью линтера. Правила, выполнение которых нельзя было обеспечить с помощью линтера, контролировались в ходе код-ревью.

Настройка линтера в монорепозитории выполняется так же, как и в любом другом репозитории. И это хорошо, так как это позволяет проверить весь репозиторий за один запуск линтера. Если вы не знакомы с линтерами — рекомендую взглянуть на ESLint и Stylelint. Мы пользуемся именно ими.

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

  • Обеспечение использования компонентов, построенных с учётом требований к доступности контента, вместо их HTML-аналогов. Мы, создавая руководство по стилю, ввели в него несколько правил, касающихся доступности ссылок, кнопок, изображений и иконок. Затем нам нужно было обеспечить применение этих правил в коде и сделать так, чтобы мы, в будущем, о них не забыли бы. Мы сделали это с помощью правила react/forbid-elements из eslint-plugin-react.


Вот пример того, как это выглядит:

'react/forbid-elements': [
    'error',
    {
        forbid: [
            {
                element: 'img',
                message: 'Use "" instead. This is important for accessibility reasons.',
            },
        ],
    },
],
  • Мы запретили импорт пакетов из пакетов библиотек и запретили импорт пакетов приложений из других приложений. Сделано это было, в основном, чтобы избежать появления в репозитории циклических зависимостей, и чтобы мы не нарушали бы принцип разделения ответственностей, который мы решили применять. Работа этих механизмов обеспечивается с помощью правила import/no-restricted-paths из eslint-plugin-import.


В дополнение к линтингу JavaScript- и CSS-кода у нас, кроме того, имеется собственный «линтер файловой системы». Именно он обеспечивает единообразное использование выбранной нами структуры папок. Так как это — инструмент, который мы создали сами, мы, если решим перейти на другую структуру папок, всегда можем его соответствующим образом изменить. Вот примеры правил, которые мы контролируем при работе с файлами и папками:

  • Проверка структуры папок компонентов: обеспечение того, чтобы там всегда присутствовал бы файл index.ts и .tsx-файл с тем же именем, что и у папки.
  • Проверка файлов package.json: обеспечение обязательного наличия одного такого файла на пакет и того, чтобы свойство private в нём было бы установлено в true для предотвращения случайной публикации пакета.


Какую систему типов выбрать?


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

К сожалению, в те времена, когда мы начали работу над проектом, всё ещё весьма широко использовалась система prop-types. В начале работы нам этого было достаточно, но по мере роста проекта нам стало очень не хватать возможности объявления типов для сущностей, не являющихся компонентами. Мы видели, что это поможет нам улучшить, например, редьюсеры и селекторы. Но внедрение в проект другой системы поддержки типов потребовало бы серьёзного рефакторинга кода, необходимого для того чтобы типизировать всю кодовую базу.

В итоге мы всё равно оснастили свой проект поддержкой типов, но сделали ошибку, попробовав сначала Flow. Нам казалось, что Flow легче интегрировать в проект. Хотя так оно и было, у нас регулярно возникали всяческие проблемы с Flow. Эта система не очень хорошо интегрировалась с нашей IDE, иногда она по непонятным причинам не выявляла некоторые ошибки, а создание универсальных типов было настоящим кошмаром. По этим причинам мы, в итоге, перевели всё на TypeScript. Если бы мы знали тогда то, что знаем сейчас, мы сразу выбрали бы TypeScript.

Благодаря тому направлению, в котором TypeScript развивался в последние годы, этот переход удался нам достаточно просто. Особенно полезным для нас оказался переход с TSLint на ESLint.

Как тестировать код?


Когда мы начали работу над проектом, нам было не очень понятно то, какие инструменты тестирования стоит выбрать. Если бы я размышлял об этом сейчас, то сказал бы, что, для модульного и интеграционного тестирования лучше всего, соответственно, использовать jest и cypress. Эти инструменты хорошо документированы, работать с ними несложно. Жаль только, что cypress не поддерживает API Fetch, плохо то, что API этого инструмента не рассчитано на применение конструкции async/await. Мы, после начала использования cypress, поняли это не сразу. Но хочется надеяться, что в ближайшем будущем ситуация улучшится.

Поначалу нам было достаточно сложно подобрать наилучший способ написания модульных тестов. Со временем мы испытали такие подходы, как тестирование с использованием снимков, тестовый рендерер, поверхностный рендерер. Мы опробовали Testing Library. В итоге мы остановились на поверхностном рендеринге, используемом для проверки результатов вывода компонента и использовали тестовый рендеринг для проверки внутренней логики компонентов.

Мы полагаем, что Testing Library — это хорошее решение для небольших проектов. Но тот факт, что эта система полагается на рендеринг DOM, сильно влияет на производительность тестов. Более того, мы полагаем, что критика снепшот-тестирования с использованием поверхностного рендеринга не имеет отношения к делу в том случае, если речь идёт об очень «глубоких» компонентах. Для нас снепшоты оказались весьма полезными в деле проверки всех возможных вариантов вывода компонентов. Правда, код компонентов не стоит переусложнять, нужно стремиться к тому, чтобы его удобно было бы читать. Добиться этого можно, делая компоненты небольшими и определяя метод toJSON для входных параметров компонента, не имеющих отношения к снепшоту.

Мы, чтобы не забывать о модульных тестах, настроили порог покрытия кода тестами. При использовании jest сделать это очень легко, да и размышлять тут особо не о чем. Достаточно просто задать показатель глобального покрытия кода тестами. Так, в начале работы мы установили этот показатель в 60%. Со временем, по мере того, как росло покрытие нашей кодовой базы тестами, мы увеличили его до 80%. Нас этот показатель устраивает, так как мы не думаем, что необходимо стремиться к 100% покрытию кода тестами. Достижение такого уровня покрытия кода тестами не кажется нам чем-то реалистичным.

Как упростить создание новых проектов?


Обычно начало работы над React-приложением выглядит очень просто: ReactDOM.render(, document.getElementById(‘#root’));. Но в том случае, когда нужно поддерживать SSR (Server-Side Rendering, серверный рендеринг), эта задача усложняется. Кроме того, если в состав зависимостей приложения входит не только React, то в коде, предназначенном для клиента и сервера, может понадобиться применение различных параметров. Например, мы, для интернационализации, используем react-intl, для управления глобальным состоянием — react-redux, для маршрутизации применяем react-router, а для управления асинхронными действиями — redux-saga. Эти зависимости нуждаются в определённой настройке. Процесс настройки таких зависимостей может оказаться достаточно сложным.

Наше решение этой проблемы было основано на паттернах проектирования «Стратегия» и «Абстрактная фабрика». Мы обычно создавали два разных класса (две разных стратегии): один — для клиентской конфигурации, второй — для серверной. Оба эти класса получали параметры создаваемого приложения, в состав которых входили название, логотип, редьюсеры, маршруты, используемый по умолчанию язык, саги (для redux-saga) и прочее. Редьюсеры, маршруты и саги могут браться из разных пакетов нашего монорепозитория. После этого данная конфигурация используется для создания хранилища redux, промежуточного ПО саг, объекта истории маршрутизатора. Она же применяется для загрузки переводов и для рендеринга приложения. Вот, для примера, сигнатуры клиентской и серверной стратегий:

type BootstrapConfiguration = {
  logo: string,
  name: string,
  reducers: ReducersMapObject,
  routes: Route[],
  sagas: Saga[],
};
class AbstractBootstrap {
  configuration: BootstrapConfiguration;
  intl: IntlShape;
  store: Store;
  rootSaga: Task;
abstract public run(): void;
  abstract public render(): T;
  abstract protected createIntl(): IntlShape;
  abstract protected createRootSaga(): Task;
  abstract protected createStore(): Store;
}
// Стратегия для клиента
class WebBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render(): ReactNode;
}
// Стратегия для сервера
class ServerBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render(): string;
}


Нам такое разделение стратегий показалось полезным, так как в настройке хранилища, саг, объектов интернационализации и истории есть некоторые различия, зависящие от окружения, в котором выполняется код. Например, хранилище redux на клиенте создаётся с использованием данных, предварительно загруженных с сервера, и с применением redux-devtools-extension. А на сервере ничего этого не нужно. Ещё один пример — это объект интернационализации, который, на клиенте, получает текущий язык из navigator.languages, а на сервере — из HTTP-заголовка Accept-Language.

Важно отметить то, что к этому решению мы пришли уже давно. В то время в React-приложениях всё ещё широко использовались классы, тогда не было простых средств для выполнения серверного рендеринга приложений. Со временем библиотека React сделала шаг в сторону функционального стиля и возникли проекты вроде Next.js. Учитывая это, если вы ищете решение для похожей задачи, мы рекомендуем вам исследовать современные технологии. Это, вполне возможно, позволит нам найти что-то такое, что будет проще и функциональнее того, что применяем мы.

Как поддерживать качество кода на высоком уровне?


Линтеры, тесты, контроль типов — всё это благотворно сказывается на качестве кода. Но программист легко может забыть запустить соответствующие проверки перед включением кода в ветку master. Лучше всего сделать так, чтобы подобные проверки запускались бы автоматически. Некоторые предпочитают делать это при каждом коммите, пользуясь хуками Git, что не позволяет сделать коммит до тех пор, пока код не пройдёт все проверки. Но мы считаем, что при таком подходе система слишком сильно вмешивается в работу программиста. Ведь, например, работа над некоей веткой может занять несколько дней, и все эти дни она не будет признана подходящей для отправки в репозиторий. Поэтому мы проверяем коммиты, используя систему непрерывной интеграции. Проверкам подвергается только код веток, которые связаны с merge-запросами. Это позволяет нам избежать запуска проверок, которые гарантированно не будут пройдены, так как мы чаще всего делаем запросы на включение результатов своей работы в основной код проекта тогда, когда уверены в том, что эти результаты способны пройти все проверки.

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

e2a5e9d02278c832cb6fe1e8c40885c7.png


Автоматическая проверка кода

Главная сложность, с которой мы столкнулись при настройке этой системы, заключалась в ускорении выполнения проверок. Эта задача всё ещё актуальна. Мы выполнили множество оптимизаций и сейчас все эти проверки стабильно выполняются примерно за 20 минут. Возможно, этот показатель можно улучшить, распараллелив выполнение некоторых cypress-тестов, но пока нас это устраивает.

Итоги


Организация разработки крупномасштабного React-приложения — это нелёгкая задача. Для её решения программисту нужно принять множество решений, нужно настроить массу инструментов. При этом на вопрос о том, как заниматься разработкой подобных приложений, нет единственного правильного ответа.

Наша система нас пока устраивает. Мы надеемся, что рассказ о ней поможет другим программистам, которые сталкиваются с теми же задачами, с которыми столкнулись мы. Если вы решите последовать нашему примеру — сначала убедитесь в том, что то, о чём тут шла речь, подходит для вас и вашей компании. А самое важное — стремитесь к минимализму. Не переусложняйте свои приложения и наборы инструментов, используемые при их создании.

Как вы подошли бы к задаче организации разработки крупномасштабного React-проекта?

oug5kh6sjydt9llengsiebnp40w.png

© Habrahabr.ru