Легко избегайте каталогов компонентов внешнего интерфейса с высокой когнитивной нагрузкой

Следуя давным-давно советам в Интернете, я принял определенную структуру компонентов, которая «просто работает».

Сценарий

Давайте сначала представим упрощенную структуру каталогов внешнего интерфейса, вдохновленную Next.js.

public/
  some-image.jpg
pages/
  index.tsx

components/
  Heading.tsx
  Logo.tsx
  Layout.tsx
  BoxContainer.tsx
  Footer.tsx

Проблема

Приведенная выше простая структура приложения дает мало информации о том, как взаимодействуют эти компоненты.

Например, вы можете предположить, что Layout.tsx импортирует Footer.tsx и Header.tsx, которые, в свою очередь, могут импортировать BoxContainer.tsx. Но это не ясно только из файловой структуры.

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

Наивный подход: структура плоских компонентов

Обычной первой мыслью может быть организация компонентов в семантически правильных каталогах.

Вот типичный результат такого подхода:

public/
  some-image.jpg
pages/
  index.tsx

components/
  layout/
    Layout.tsx
    Heading.tsx
    Footer.tsx
  common/
    Heading.tsx
    BoxContainer.tsx

Проблема №1: хорошие имена сложно масштабировать

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

Проблема в том, что вам нужно придумать еще больше классификаций для каталогов, а не только имена компонентов.

У вас часто будет возникать соблазн сказать: «Знаете что, я просто перемещу это в каталог common». Наличие common каталогов — это антипаттерн к тому, чего вы пытаетесь достичь, но с такой структурой в нее просто слишком легко втянуться.

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

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

Проблема №2: повышенная когнитивная нагрузка на имена каталогов

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

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

Лучший подход: шаблон деревьев компонентов

При таком подходе вместо того, чтобы изо всех сил классифицировать группы компонентов с разными именами, вы сосредоточитесь на том, чтобы иметь хорошо названные компоненты, которые неявно объясняют, из чего они состоят.

Правила импорта компонентов

  • Может импортировать вверх, кроме своего родителя
  • Можно импортировать братьев и сестер
  • Невозможно импортировать компоненты одного уровня
  • Не удается импортировать родительский элемент
public/
  some-image.jpg
pages/
  index.tsx

components/
  Layout/
    components/
      Heading/
        components/
          Logo.tsx
          Menu.tsx
        Heading.tsx
      CopyrightIcon.tsx
      Footer.tsx
    Layout.tsx
  BoxContainer.tsx

Давайте покажем содержимое Footer.tsx и используем его в качестве примера, используя правила, которые я перечислил выше:

// components/Layout/components/Footer.tsx

// Can import upwards, except its own parent
import { BoxContainer } from '../../BoxContainer.tsx';
// Can import siblings
import { CopyrightIcon } from './CopyrightIcon.tsx';

// WRONG: Cannot import sibling's components
// import { Menu } from './Heading/components/Menu.tsx';
// WRONG: Cannot import its parent
// import { Layout } from '../Layout.tsx';

export const Footer = () => (
  <BoxContainer>
    <CopyrightIcon />
    <p>All rights reserved, etc.</p>
  </BoxContainer>
)

Преимущество № 1: очевидные отношения дочерних компонентов

Шаблон деревьев компонентов устраняет догадки; взаимосвязь между компонентами сразу становится очевидной. Например, Menu.tsx аккуратно вложена как внутренняя зависимость Heading.tsx.

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

Преимущество № 2: определение повторного использования более тонкое

При наивном подходе компоненты были либо «обычными», либо «необычными». Имея в виду возможность повторного использования, деревья компонентов помогают избежать такого непродуктивного бинарного мышления.

components/
  Layout/
    components/
      Heading/
        components/
        - Logo.tsx
          Menu.tsx
        Heading.tsx
    + Logo.tsx
      CopyrightIcon.tsx
      Footer.tsx
    Layout.tsx
  BoxContainer.tsx

В приведенном выше примере, если Logo.tsx становится необходимым для большего количества компонентов, чем просто Menu.tsx, мы можем просто переместить его на один уровень выше. Он может быть недостаточно пригодным для повторного использования (или «распространенным») для использования BoxContainer.tsx, но он достаточно пригоден для повторного использования в контексте компонента Layout.tsx.

Преимущество № 3: свести к минимуму необходимость называть вещи

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

Бонус: извлечение кода из компонентов в отдельные файлы, не задумываясь об именах.

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

Хотя вы можете создать каталог utils/, это заставит вас выбрать имя для любого файла, в который вы хотите поместить свои служебные функции.

Вместо этого выберите использование суффиксов файлов, таких как Footer.utils.tsx или Footer.test.tsx.

components/
  Layout/
    components/
      Heading/
        components/
          Logo.tsx
          Menu.tsx
        Heading.tsx
      CopyrightIcon.tsx
    + Footer.utils.tsx
      Footer.tsx
    Layout.tsx
  BoxContainer.tsx

Таким образом, вам не придется придумывать умное имя, например emailFormatters.ts, или что-то очень расплывчатое, например helpers.ts. Избегайте когнитивной нагрузки, связанной с именованием — эти утилиты принадлежат Footer.tsx и могут использоваться Footer.tsx и его внутренними компонентами (опять же, импорт вверх).

Контраргументы деревьям компонентов

«Это много каталогов компонентов»

Глядя на эту структуру в первый раз, у большинства людей возникает рефлекторная реакция.

Да, есть много каталогов «компонентов». Но когда я работаю с командами над определением структуры проекта, я всегда подчеркиваю важность ясности, а не элегантности.

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

«Тьфу: импортировать… из ./MyComponent/MyComponent.tsx?»

Хотя import … from ./MyComponent/MyComponent.tsx может выглядеть некрасиво, более важна ясность, которую он приносит, прямо указывая, откуда берется компонент.

Что касается строк импорта, это примеры того, что создает когнитивную нагрузку для разработчиков.

  • Наличие псевдонимов импорта, таких как import ... from 'common/components', утомительно для разработчиков.
  • Повсюду иметь index.ts файлов, чтобы можно было просто написать import ... from './MyComponent'. Поиск нужного файла, скорее всего, займет больше времени у разработчиков, которые выполняют поиск по файлу.

Окончательное сравнение: сложный сценарий

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

После объяснения структур я попросил ChatGPT сгенерировать «плоскую» структуру каталогов в левом столбце и то, что я назвал «деревьями компонентов» в правом.

Flat Structure                      |  Component Trees
------------------------------------+---------------------------------------------------
pages/                              |  pages/
  index.tsx                         |    index.tsx
  shop.tsx                          |    shop.tsx
  product/                          |    product/
    [slug].tsx                      |      [slug].tsx
  cart.tsx                          |    cart.tsx
  checkout.tsx                      |    checkout.tsx
  about.tsx                         |    about.tsx
  contact.tsx                       |    contact.tsx
  login.tsx                         |    login.tsx
  register.tsx                      |    register.tsx
  user/                             |    user/
    dashboard.tsx                   |      dashboard.tsx
    orders.tsx                      |      orders.tsx
    settings.tsx                    |      settings.tsx
                                    |  
components/                         |  components/
  layout/                           |    Layout/
    Layout.tsx                      |      components/
    Header.tsx                      |        Header/
    Footer.tsx                      |          components/
    Sidebar.tsx                     |            Logo.tsx
    Breadcrumb.tsx                  |            NavigationMenu.tsx
  common/                           |            SearchBar.tsx
    Button.tsx                      |            UserIcon.tsx
    Input.tsx                       |            CartIcon.tsx
    Modal.tsx                       |          Header.tsx
    Spinner.tsx                     |        Footer/
    Alert.tsx                       |          components/
  product/                          |            SocialMediaIcons.tsx
    ProductCard.tsx                 |            CopyrightInfo.tsx
    ProductDetails.tsx              |          Footer.tsx
    ProductImage.tsx                |      Layout.tsx
    ProductTitle.tsx                |    BoxContainer.tsx
    ProductPrice.tsx                |    Button.tsx
    AddToCartButton.tsx             |    Input.tsx
  filters/                          |    Modal.tsx
    SearchFilter.tsx                |    Spinner.tsx
    SortFilter.tsx                  |    Alert.tsx
  cart/                             |    ProductCard/
    Cart.tsx                        |      components/
    CartItem.tsx                    |        ProductImage.tsx
    CartSummary.tsx                 |        ProductTitle.tsx
  checkout/                         |        ProductPrice.tsx
    CheckoutForm.tsx                |        AddToCartButton.tsx
    PaymentOptions.tsx              |      ProductCard.tsx
    OrderSummary.tsx                |    ProductDetails/
  user/                             |      components/
    UserProfile.tsx                 |        ProductSpecifications.tsx
    UserOrders.tsx                  |        ProductReviews.tsx
    LoginBox.tsx                    |        ProductReviewForm.tsx
    RegisterBox.tsx                 |      ProductDetails.tsx
  about/                            |    SearchFilter.tsx
    AboutContent.tsx                |    SortFilter.tsx
  contact/                          |    Cart/
    ContactForm.tsx                 |      components/
  review/                           |        CartItemList.tsx
    ProductReview.tsx               |        CartItem.tsx
    ProductReviewForm.tsx           |        CartSummary.tsx
  address/                          |      Cart.tsx
    ShippingAddress.tsx             |    CheckoutForm/
    BillingAddress.tsx              |      components/
  productInfo/                      |        PaymentDetails.tsx
    ProductSpecifications.tsx       |        BillingAddress.tsx
  cartInfo/                         |        ShippingAddress.tsx
    CartItemList.tsx                |      CheckoutForm.tsx
  userDetail/                       |    PaymentOptions.tsx
    UserSettings.tsx                |    OrderSummary.tsx
  icons/                            |    UserProfile/
    Logo.tsx                        |      components/
    SocialMediaIcons.tsx            |        UserOrders.tsx
    CartIcon.tsx                    |        UserSettings.tsx
    UserIcon.tsx                    |      UserProfile.tsx
                                    |    LoginBox.tsx
                                    |    RegisterBox.tsx
                                    |    AboutContent.tsx
                                    |    ContactForm.tsx

Теперь имейте в виду, что это пример без каких-либо тестовых файлов, служебных файлов или чего-либо подобного.

Для древовидной структуры компонентов вы можете добавить служебные или тестовые файлы с суффиксами в каталогах компонентов.

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

Не используете файловую маршрутизацию? Даже лучше!

До работы с Next.js я выступал за эту структуру, но с усовершенствованием: организация компонентов также по представлениям.

public/
  some-image.jpg
components/
  BoxContainer.tsx
views/
  components/
    Layout/
      components/
        [...]
      Layout.tsx
  about-us/
    components/
      EmailForm.tsx
    about-us.tsx
  index.tsx (<- the start page in this example)

Положительным моментом этого является то, что теперь вы видите не только зависимости между компонентами, но и то, какие компоненты используются страницами (или представлениями).

Используете Next.js?

Вы все еще можете добиться этого с помощью Next.js несколькими различными способами, с компромиссами.

Вариант 1. Использование pageExtensions в next.config.js

public/
  some-image.jpg
components/
  BoxContainer.tsx
pages/
  components/
    Layout/
      components/
        [...]
      Layout.tsx
  about-us/
    components/
      EmailForm.tsx
    index.page.tsx
  index.page.tsx (<- the start page in this example)

Как: Используйте pageExtensions (ссылка на документы) и добавляйте ко всем файлам страниц что-то вроде page.tsx. Это позволяет вам иметь файлы, которые не являются страницами в вашем каталоге pages/.

Потенциальный компромисс: поскольку Next.js не позволит вам создать страницу, подобную about-us/about-us.page.tsx (что создаст URL-адрес, подобный /about-us/about-us), вам придется иметь много файлов index.page.tsx, которые создают когнитивную нагрузку для разработчиков, определяющих, какой индекс файл какой.

Вариант 2. Использование компонентов «уровня страницы» для ваших страниц

public/
  some-image.jpg
components/
  Layout/
    components/
      [...]
    Layout.tsx
  AboutUsPage/
    components/
      EmailForm.tsx
    AboutUsPage.tsx
pages/
  about-us.tsx
  index.tsx

Как: Для каждой страницы, которую вы создаете, вы также создаете компонент для этой страницы в вашем каталоге components/, который будет единственным компонентом, отображаемым на вашей странице.

Потенциальный компромисс: наличие практически пустых файлов pages (за исключением getStaticProps и других хуков) может рассматриваться как раздражающий дополнительный шаг всякий раз, когда вы собираетесь работать на какой-либо странице, и вам нужно немедленно перейти к компоненту страницы.

«Мне это нравится, но как мне реализовать подобную структуру?»

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

Это может привести к тому, что разработчики случайно нарушат любое из правил, которые мы перечислили ранее.

Именно здесь вступает в игру eslint, по-настоящему недооцененный инструмент для создания собственных стандартов для вашего репозитория.

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

Будучи интегрированным в ваш процесс разработки, ESLint может обеспечить раннюю обратную связь по вашему коду, позволяя вам выявлять ошибки и неверные шаблоны непосредственно в текстовом редакторе, прежде чем они станут более серьезными проблемами. Инструмент полностью подключаемый, что дает вам возможность гибко настраивать стандарты кодирования в зависимости от требований вашего конкретного проекта или команды.

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

Поскольку создание собственного плагина ESLint может быть немного привередливым, лучше использовать существующий. Я поделюсь двумя правилами, которые я создал с помощью eslint-plugin-import.

Создание правил lint для принудительного применения деревьев компонентов

  1. yarn install eslint-plugin-import
  2. Измените файл .eslintrc.json, чтобы он содержал следующее:
// .eslintrc.json
{
  "plugins": [
    // [existing plugins]
    "eslint-plugin-import"
  ],
  "rules": {
    // [existing rules]
    "no-restricted-imports": [
      "error",
      {
        "patterns": [
          {
            "group": ["../**/components/**/*"],
            "message": "Do not import from a higher level component's internal components, move that component further up the directory tree instead."
          },
          {
            "group": ["**/components/**/components/**/*"],
            "message": "Do not import internal components used by a sub-component, move that component further up the directory tree instead."
          }
        ]
      }
    ]
  }
}

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

// .eslintrc.json
{
  "plugins": [
    // [existing plugins]
    "eslint-plugin-import"
  ],
  "rules": {
    // [existing rules]
  },
  "overrides": [
    {
      "files": ["components/**/*.{tsx,jsx,ts,js}"],
      "rules": {
        "no-restricted-imports": [
          "error",
          {
            "patterns": [
              {
                "group": ["../**/components/**/*"],
                "message": "Do not import from a higher level component's internal components, move that component further up the directory tree instead."
              },
              {
                "group": ["**/components/**/components/**/*"],
                "message": "Do not import internal components used by a sub-component, move that component further up the directory tree instead."
              }
            ]
          }
        ]
      }
    }
  ]
}

Нижняя граница

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

Я также хотел бы поблагодарить вас за совет, который я нашел в Интернете много лет назад и который привел меня к принятию этого шаблона.

Спасибо за прочтение.