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

В настоящее время очень легко настроить небольшое приложение React или запустить его с нуля. Особенно, если вы используете create-react-app. Большинству проектов, вероятно, просто требуется несколько зависимостей (например, для управления состоянием и для интернационализации) и папка src с папкой как минимум components. Думаю, именно так начинается большинство проектов React. Однако, как правило, по мере роста проекта количество зависимостей, компонентов, редукторов и других общих утилит имеет тенденцию увеличиваться, иногда не очень контролируемым образом. Что вы делаете, когда больше не ясно, зачем нужны какие-то зависимости или как они работают вместе? Или когда у вас так много компонентов, становится сложно найти тот, который вам нужен? Что вы делаете, когда хотите найти компонент, но не помните его название?

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

На момент написания у нас есть около 1200 файлов JavaScript, 350 из которых являются компонентами с 80% охвата юнит-тестами. Поскольку мы по-прежнему верим в архитектуру и соглашения, которые мы создали, мы подумали, что было бы неплохо поделиться ими. В следующих разделах я расскажу, как мы создали наш проект, а также некоторые уроки, которые мы извлекли.

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

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

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

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

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

Некоторые из вас могут задаться вопросом, почему мы просто не использовали папку пакетов, как в других монорепозиториях. В основном потому, что мы хотели разделить наши приложения и нашу библиотеку компонентов. Кроме того, мы также знали, что нам нужно создать какие-то собственные инструменты. Итак, мы придумали папки, которые вы видите выше, и вот объяснение каждой из них:

  • приложение: все пакеты в этой папке относятся к интерфейсным приложениям, таким как наш интерфейс Karify, некоторые внутренние интерфейсы, а также к нашей Storybook;
  • lib: все пакеты в этой папке предоставляют общие служебные программы нашим интерфейсным приложениям и максимально не зависят от приложений. Эти пакеты в основном составляют нашу библиотеку компонентов. Некоторыми примерами могут быть наши пакеты typography, media и primitive;
  • инструмент: все пакеты в этой папке относятся к Node.js и либо предоставляют инструменты, которые мы создали сами, либо представляют собой конфигурацию и утилиты для инструментов, от которых мы зависим. Некоторыми примерами могут быть утилиты webpack, конфигурации линтера и линтер файловой системы.

Независимо от того, где мы их размещаем, все наши пакеты всегда имеют папку src и, возможно, папку bin. Папка src наших пакетов app и lib может содержать некоторые из следующих папок:

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

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

Одна из проблем с этой новой структурой папок - убедиться, что мы ее придерживаемся. Заманчиво начать создавать разные папки в разных пакетах и ​​по-разному организовывать файлы между ними. Хотя это не всегда может быть плохой идеей, у нас может возникнуть беспорядок, если мы не будем делать это постоянно. Чтобы помочь с этим, мы создали линтер файловой системы, о котором я расскажу более подробно в следующем разделе.

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

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

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

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

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

Помимо линтинга JavaScript и CSS, у нас также есть собственный линтер файловой системы. Вот как мы гарантируем, что придерживаемся нашей структуры папок. Поскольку этот линтер наш, мы всегда можем изменить его, если решим изменить структуру. Вот несколько примеров правил, которые у нас есть:

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

Какую систему типов использовать?

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

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

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

Направление, в котором развивался TypeScript за последние несколько лет, помогло сделать этот переход намного проще. Прекращение поддержки TSLint по сравнению с ESLint было для нас особенно полезно.

Какой подход к тестированию использовать?

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

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

На наш взгляд, библиотека тестирования отлично подходит для небольших проектов, но тот факт, что она использует рендерер dom, оказывает большое влияние на производительность тестов. Более того, мы думаем, что аргумент против тестирования снимков с неглубокой визуализацией не имеет смысла, когда у вас очень глубокий слой компонентов. Для нас эти снимки очень полезны для проверки всех возможных выходных данных компонента. Однако важно, чтобы они были удобочитаемыми. Этого можно добиться, сохраняя размеры компонентов небольшими и определяя метод toJSON для свойств объекта, которые не имеют отношения к моментальному снимку.

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

Как запустить приложение?

Обычно запустить приложение React так же просто, как написать что-то вроде ReactDOM.render(<App />, document.getElementById(‘#root’));. Однако это становится более сложным, когда вы также хотите поддерживать SSR (рендеринг на стороне сервера). Кроме того, если вам также нужны другие зависимости, помимо React, им могут потребоваться отдельные конфигурации для клиентской и серверной сторон. Например, мы используем react-intl для интернационализации, react-redux для глобального управления состоянием, react-router для маршрутизации и redux-sagas для управления асинхронными действиями. Эти зависимости требуют некоторой настройки, которая может очень легко усложниться.

Наше решение этой проблемы было основано на шаблонах проектирования Стратегия и Абстрактная фабрика. В основном мы создали два разных класса / стратегии: один для конфигурации на стороне клиента и один для конфигурации на стороне сервера. Оба они получают конфигурацию для загружаемого приложения, которая включает его имя, логотип, редукторы, маршруты, язык по умолчанию, саги и т. Д. Редюсеры, маршруты и саги могут поступать из разных пакетов в нашем монорепозитории. Затем эта конфигурация используется для создания хранилища redux, создания промежуточного программного обеспечения sagas, создания объекта истории маршрутизатора, выборки переводов и, наконец, рендеринга приложения. В качестве примера ниже представлена ​​упрощенная версия того, как выглядит сигнатура обеих наших стратегий:

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>(): T;
  abstract protected createIntl(): IntlShape;
  abstract protected createRootSaga(): Task;
  abstract protected createStore(): Store;
}
// Strategy for client-side
class WebBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<ReactNode>(): ReactNode;
}
// Strategy for server-side
class ServerBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<string>(): string;
}

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

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

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

Линтеры, тесты и типы полезны для улучшения качества вашего кода, но легко забыть проверить, все ли они действительны, прежде чем вносить некоторые изменения в вашу основную ветку. Лучшее решение - делать это автоматически. Некоторые предпочитают делать это при каждой фиксации с помощью крючков git, предотвращающих фиксацию, если все не пройдет. Однако мы думаем, что это слишком навязчиво, поскольку вы можете работать над веткой несколько дней, пока она не будет готова. По этой причине мы проверяем наши коммиты с помощью конвейеров CI (непрерывной интеграции), которые запускаются только тогда, когда ветвь связана с запросом на слияние. Таким образом мы избегаем запуска конвейеров, которые, как ожидается, выйдут из строя, поскольку в большинстве случаев мы создаем запрос на слияние только тогда, когда мы думаем, что код готов.

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

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

Заключение

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

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

Если у вас есть вопросы, отзывы или предложения относительно того, что вы здесь читаете, я был бы рад услышать от вас в разделе комментариев ниже.