Крошечные компоненты: что может пойти не так? Используем принцип единственной ответственности

Автор оригинала: Scott Domes
  • Перевод
Представляем вашему вниманию перевод статьи Scott Domes, которая была опубликована на blog.bitsrc.io. Узнайте под катом, почему компоненты должны быть как можно меньше и как принцип единственной ответственности влияет на качество приложений.


Фото Austin Kirk с Unsplash

Преимущество системы компонентов React (и подобных библиотек) заключается в том, что ваш UI делится на небольшие части, которые легко воспринимаются и могут многократно использоваться.

Эти компоненты компактны (100–200 строк), что позволяет другим разработчикам легко их понимать и видоизменять.

Хотя компоненты, как правило, стараются делать короче, четкого, строгого ограничения их длины нет. React не станет возражать, если вы решите уместить ваше приложение в один пугающе огромный компонент, состоящий из 3 000 строк.

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

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

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

Давайте продемонстрируем, как именно при создании компонентов что-то может пойти не так.

Наше приложение


Представим, что у нас есть стандартное приложение для блогеров. И вот что на главном экране:

class Main extends React.Component {
  render() {
    return (
      <div>
        <header>
          // Header JSX
        </header>
        <aside id="header">
          // Sidebar JSX
        </aside>
        <div id="post-container">
          {this.state.posts.map(post => {
            return (
              <div className="post">
                // Post JSX
              </div>
            );
          })}
        </div>
      </div>
    );
  }
}

(Данный пример, как и многие последующие, следует рассматривать как псевдокод.)

Здесь отражена верхняя панель, боковая панель и список постов. Все просто.

Поскольку нам также необходимо загружать посты, мы можем заняться этим, пока компонент монтируется:

class Main extends React.Component {
  state = { posts: [] };
  componentDidMount() {
    this.loadPosts();
  }
  loadPosts() {
    // Load posts and save to state
  }
  render() {
    // Render code
  }
}

У нас также есть некая логика, по которой вызывается боковая панель. Если пользователь кликает на кнопку в верхней панели, выезжает боковая. Закрыть ее можно как из верхней, так и из собственно боковой панели.

class Main extends React.Component {
  state = { posts: [], isSidebarOpen: false };
  componentDidMount() {
    this.loadPosts();
  }
  loadPosts() {
    // Load posts and save to state
  }
  handleOpenSidebar() {
    // Open sidebar by changing state
  }
  handleCloseSidebar() {
    // Close sidebar by changing state
  }
  render() {
    // Render code
  }
}

Наш компонент стал немного сложнее, но воспринимать его по-прежнему легко.

Можно утверждать, что все его части служат одной цели: отображению главной страницы приложения. Значит, мы следуем принципу единственной ответственности.

Принцип единственной ответственности гласит, что один компонент должен выполнять только одну функцию. Если переформулировать определение, взятое из wikipedia.org, то получается, что каждый компонент должен отвечать только за одну часть функционала [приложения].

Наш компонент Main соответствует этому требованию. В чем же проблема?

Перед вами другая формулировка принципа: у любого [компонента] должна быть только одна причина для изменения.

Это определение взято из книги Роберта Мартина «Быстрая разработка программ. Принципы, примеры, практика», и оно имеет большое значение.

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

Для наглядности — давайте усложним наш компонент.

Усложнение


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

Это нетрудно сделать!

class Main extends React.Component {
  state = { posts: [], isSidebarOpen: false, postsToHide: [] };
  // older methods
  get filteredPosts() {
    // Return posts in state, without the postsToHide
  }
  render() {
    return (
      <div>
        <header>
          // Header JSX
        </header>
        <aside id="header">
          // Sidebar JSX
        </aside>
        <div id="post-container">
          {this.filteredPosts.map(post => {
            return (
              <div className="post">
                // Post JSX
              </div>
            );
          })}
        </div>
      </div>
    );
  }
}

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

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

class Main extends React.Component {
  state = {
    posts: [],
    isSidebarOpen: false,
    postsToHide: [],
    isMobileSidebarOpen: false
  };
  // older methods
  handleOpenSidebar() {
    if (this.isMobile()) {
      this.openMobileSidebar();
    } else {
      this.openSidebar();
    }
  }
  openSidebar() {
    // Open regular sidebar
  }
  openMobileSidebar() {
    // Open mobile sidebar
  }
  isMobile() {
    // Check if mobile device
  }
  render() {
    // Render method
  }
}

Еще одно небольшое изменение. Пара новых удачно названных методов и новое свойство.

И тут у нас возникает проблема. Main по-прежнему выполняет лишь одну функцию (рендеринг главного экрана), но вы посмотрите на все эти методы, с которыми мы теперь имеем дело:

class Main extends React.Component {
  state = {
    posts: [],
    isSidebarOpen: false,
    postsToHide: [],
    isMobileSidebarOpen: false
  };
  componentDidMount() {
    this.loadPosts();
  }
  loadPosts() {
    // Load posts and save to state
  }
  handleOpenSidebar() {
    // Check if mobile then open relevant sidebar
  }
  handleCloseSidebar() {
    // Close both sidebars
  }
  openSidebar() {
    // Open regular sidebar
  }
  openMobileSidebar() {
    // Open mobile sidebar
  }
  isMobile() {
    // Check if mobile device
  }
  get filteredPosts() {
    // Return posts in state, without the postsToHide
  }
  render() {
    // Render method
  }
}

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

Что же пошло не так?

Единственная причина


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

Ранее мы изменили способ отображения постов, поэтому пришлось изменить и наш компонент Main. Далее мы изменили способ открытия боковой панели — и вновь изменили компонент Main.

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

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

Более эффективное разделение


Решение проблемы простое: необходимо разделить компонент Main на несколько частей. Как это сделать?

Начнем сначала. Рендеринг главного экрана остается ответственностью компонента Main, но мы сокращаем ее только до отображения связанных компонентов:

class Main extends React.Component {
  render() {
    return (
      <Layout>
        <PostList />
      </Layout>
    );
  }
}

Замечательно.

Если мы вдруг изменим способ компоновки главного экрана (например, добавим дополнительные разделы), то изменится и Main. В остальных случаях у нас не будет причин его трогать. Прекрасно.

Давайте перейдем к Layout:

class Layout extends React.Component {
  render() {
    return (
      <SidebarDisplay>
        {(isSidebarOpen, toggleSidebar) => (
          <div>
            <Header openSidebar={toggleSidebar} />
            <Sidebar isOpen={isSidebarOpen} close={toggleSidebar} />
          </div>
        )}
      </SidebarDisplay>
    );
  }
}

Тут немного сложнее. На Layout лежит ответственность за рендеринг компонентов разметки (боковая панель / верхняя панель). Но мы не поддадимся соблазну и не наделим Layout ответственностью определять, открыта боковая панель или нет.

Мы назначаем эту функцию компоненту SidebarDisplay, который передает необходимые методы или состояние компонентам Header и Sidebar.

(Выше представлен пример паттерна Render Props via Children в React. Если вы не знакомы с ним, не переживайте. Тут важно существование отдельного компонента, управляющего состоянием «открыто/закрыто» боковой панели.)

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

class Sidebar extends React.Component {
  isMobile() {
    // Check if mobile
  }
  render() {
    if (this.isMobile()) {
      return <MobileSidebar />;
    } else {
      return <DesktopSidebar />;
    }
  }
}

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

Посмотрим на еще один компонент:

class PostList extends React.Component {
  state = { postsToHide: [] }
  filterPosts(posts) {
    // Show posts, minus hidden ones
  }
  hidePost(post) {
    // Save hidden post to state
  }
  render() {
    return (
      <PostLoader>
        {
          posts => this.filterPosts(posts).map(post => <Post />)
        }
      </PostLoader>
    )
  }
}

PostList меняется, только если мы меняем способ отрисовки списка постов. Кажется очевидным, не так ли? Как раз это нам и нужно.

PostLoader меняется, только если мы изменяем способ загрузки постов. И наконец, Post меняется, только если мы меняем способ отрисовки поста.

Заключение


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

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

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

Спасибо за внимание, и ждем ваших комментариев!
Plarium
145,00
Разработчик мобильных и браузерных игр
Поделиться публикацией

Комментарии 12

    +1
    Эти компоненты компактны (100–200 строк), что позволяет другим разработчикам легко их понимать и видоизменять.
    Какое-то странное представление о компактности.
      +1
      То есть вместо одного простого компонента предлагается создать 10 тривиальных, но жёстко зависящих друг от друга? В отсутствии IoC, который в Реакт не завезли, это выливается в груды копипасты, когда в одном месте нужен Main с MobileSidebar, а в другом Main с BetaMobileSidebar.
        0
        Вы путаете принципы IoC с чем-то другим… с DI контейнерами?
        В Реакте часто применяются приницпы инверсии контроля, когда например передаете в компонент функцию для обратного вызова в качестве пропса.
          0
          Не путаю. В Main зашит конкретный Layout, в котором зашит конкретный Sidebar, в котором зашит конкретный MobileSidebar, в котором наверняка ещё много чего так же зашито на 10 уровней вглубь. А когда пытаешься прикрутить к Реакту IoC, код превращается в адскую лапшу.
            0

            А в чем проблема, если мы будем внедрять компоненты-зависимости через props? Например:


            const Layout = ({ Header, Sidebar, ...props }) => (
              <div>
                <Header />
                <Sidebar {...props} />
              </div>
            );

            А где-нибудь потом на этапе бутстрапа приложения:


            Layout.defaultProps.Sidebar = isMobile() ? MobileSidebar : DesktopSidebar

            Или, если надо в каком-то локальном поддереве компонентов:


            const LayoutContainer = props => (
              <Layout Header={MyHeader} Sidebar={MySidebar} {...props} />
            )
              0
              Это даёт переопределение лишь на один уровень. Если надо поменять что-то на 5 уровне — нужно скопипастить все промежуточные, добавив им переопределения.
                0

                vintage, возникает закономерный вопрос: Как описать подобную многоуровневую иерархичность на tree? Насколько я помню в tree нет понятия tree-компонент, которые собирались бы в результирующий tree-объект, но с более высокой степенью иерархии. Для меня компонент это то, что не принадлежит изначально результирующему объекту, но при этом тоже может являться объектом.

                  0
                  Я не понял вопроса. В $mol точно так же создаются компоненты, которые используются внутри компонент следующего уровня. Разве что классы компонент достаются через контекст, через который их можно переопределять.
                    0

                    Тогда еще два вопроса:


                    • Что мешает и в React передавать компоненты через контекст?
                    • Можно какой-нибудь пример из реальной жизни, когда класс одного и того же компонента переопределяется по-разному в двух разных частях DOM-дерева? Я как-то сходу не могу придумать.
                      0
                      1. Бойлерплейта много. Поэтому так никто и не делает.
                      2. Например, компонент рендеринга маркдауна, который внутри себя создаёт параграфы, а нам надо, чтобы у каждого параграфа отображался его номер с пермалинком. Переопределили компонент параграфа и реализовали любую нужную нам логику, не зашивая её в компоненте рендеринга маркдауна.
                0
                А в чем проблема, если мы будем внедрять компоненты-зависимости через props? Например:

                Но ведь в header тоже надо пропсы прокинуть. Откуда вы знаете, какие пропсы надо в header, а какие — в sidebar?

          0
          Мы назначаем эту функцию компоненту SidebarDisplay, который передает необходимые методы или состояние компонентам Header и Sidebar.

          Руки надо отрывать за такое. Худшее, что позволяет сделать реакт — это подобные SidebarDisplay компоненты.

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

          Самое читаемое