3474

Применение принципов SOLID в React

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

SOLID является сокращенным наименованием пяти распространенных принципов проектирования:

  • SRP — принцип единственной ответственности.
  • OCP — принцип открытости-закрытости.
  • LSP — принцип подстановки Барбары Лисков.
  • ISP — принцип разделения интерфейсов.
  • DIP — принцип инверсии зависимостей.

Стоит отметить, что принципы SOLID были созданы с учетом объектно-ориентированной парадигмы программирования и в значительной степени зависят от концепций классов и интерфейсов. 

Однако в JavaScript их просто не существует. То, что мы привыкли называть в JS классами, фактически является двойниками классов, которые были смоделированы с применением его системы прототипов, а сами интерфейсы и вовсе не входят в язык. Кроме этого, современный подход к написанию кода в React в действительности далек от ООП-парадигмы и больше относится к функциональному программированию. 

Тем не менее принципы SOLID обладают довольно высоким уровнем абстракции, и при более предметном рассмотрении, а также с некоторыми вольностями в интерпритации, вполне могут применяться к коду на React.

В этой статье мы подробно разберем все принципы SOLID и рассмотрим, как каждый из них может использоваться в разработке приложений на React.

Принцип единой ответственности (SRP)

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

Из всех пяти принципов SOLID, использовать SRP проще всего, при этом он остается наиболее эффективным, так как существенно повышает качество программного кода. Для его применения можно:

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

 

Чтобы понять, как это работает на практике рассмотрим пример компонента, который отражает список активных пользователей:

const ActiveUsersList = () => {
  const [users, setUsers] = useState([])
  
  useEffect(() => {
    const loadUsers = async () => {  
      const response = await fetch('/some-api')
      const data = await response.json()
      setUsers(data)
    }

    loadUsers()
  }, [])
  
  const weekAgo = new Date();
  weekAgo.setDate(weekAgo.getDate() - 7);

  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <li key={user.id}>
          <img src={user.avatarUrl} />
          <p>{user.fullName}</p>
          <small>{user.role}</small>
        </li>
      )}
    </ul>    
  )
}

Несмотря на то, что этот компонент относительно небольшой, он уже выполняет несколько задач: извлекает данные, фильтрует их, отображает сам компонент и отдельные элементы списка. Разумеется, такая мультифункциональность не соответствует принципу SRP. Рассмотрим, как мы можем это исправить.

В первую очередь стоит заметить, что при каждом подключении useState и useEffect, мы можем извлечь их в отдельный пользовательский хук. 

Для этого делаем следующее:

const useUsers = () => {
  const [users, setUsers] = useState([])
  
  useEffect(() => {
    const loadUsers = async () => {  
      const response = await fetch('/some-api')
      const data = await response.json()
      setUsers(data)
    }

    loadUsers()
  }, [])
  
  return { users }
}


const ActiveUsersList = () => {
  const { users } = useUsers()
  
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)

  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <li key={user.id}>
          <img src={user.avatarUrl} />
          <p>{user.fullName}</p>
          <small>{user.role}</small>
        </li>
      )}
    </ul>    
  )
}

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

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

Выглядеть это будет так:

const UserItem = ({ user }) => {
  return (
    <li>
      <img src={user.avatarUrl} />
      <p>{user.fullName}</p>
      <small>{user.role}</small>
    </li>
  )
}


const ActiveUsersList = () => {
  const { users } = useUsers()
  
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)

  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <UserItem key={user.id} user={user} />
      )}
    </ul>    
  )
}

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

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

Благодаря чему ее можно извлечь в отдельную функцию:

const getOnlyActive = (users) => {
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)
  
  return users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo)
}

const ActiveUsersList = () => {
  const { users } = useUsers()

  return (
    <ul>
      {getOnlyActive(users).map(user => 
        <UserItem key={user.id} user={user} />
      )}
    </ul>    
  )
}

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

Поэтому в последнем апгрейде кода мы инкапсулируем эту логику в новый пользовательский хук:

const useActiveUsers = () => {
  const { users } = useUsers()

  const activeUsers = useMemo(() => {
    return getOnlyActive(users)
  }, [users])

  return { activeUsers }
}

const ActiveUsersList = () => {
  const { activeUsers } = useActiveUsers()

  return (
    <ul>
      {activeUsers.map(user => 
        <UserItem key={user.id} user={user} />
      )}
    </ul>    
  )
}

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

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

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

Принцип открытости-закрытости (OCP)

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

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

 

Пример кода:

const Header = () => {
  const { pathname } = useRouter()
  
  return (
    <header>
      <Logo />
      <Actions>
        {pathname === '/dashboard' && <Link to="/events/new">Create event</Link>}
        {pathname === '/' && <Link to="/dashboard">Go to dashboard</Link>}
      </Actions>
    </header>
  )
}

const HomePage = () => (
  <>
    <Header />
    <OtherHomeStuff />
  </>
)

const DashboardPage = () => (
  <>
    <Header />
    <OtherDashboardStuff />
  </>
)

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

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

Это будет выглядеть так:

const Header = ({ children }) => (
  <header>
    <Logo />
    <Actions>
      {children}
    </Actions>
  </header>
)

const HomePage = () => (
  <>
    <Header>
      <Link to="/dashboard">Go to dashboard</Link>
    </Header>
    <OtherHomeStuff />
  </>
)


const DashboardPage = () => (
  <>
    <Header>
      <Link to="/events/new">Create event</Link>
    </Header>
    <OtherDashboardStuff />
  </>
)

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

В результате новая композиция становится гораздо более стабильной и масштабируемой.

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

 

Принцип подстановки Барбары Лисков (LSP)

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

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

Принцип разделения интерфейсов (ISP)

Согласно принципу разделения интерфейсов в объектно-ориентированном программировании, клиент не должен зависеть от интерфейсов, которые он не использует. В случае с React это можно интерпретировать, как «компоненты не должны зависеть от свойств, которые они не используют».

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

Чтобы лучше продемонстрировать проблему, которую решает принцип ISP, используем язык TypeScript:

type Video = {
  title: string
  duration: number
  coverUrl: string
}

type Props = {
  items: Array<Video>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => 
        <Thumbnail 
          key={item.title} 
          video={item} 
        />
      )}
    </ul>
  )
}

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

type Props = {
  video: Video
}

const Thumbnail = ({ video }: Props) => {
  return <img src={video.coverUrl} />
}

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

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

Для наглядности попробуем сделать это на практике.

Для этого создадим новый объект, в котором будут находиться миниатюры для трансляций:

type LiveStream = {
  name: string
  previewUrl: string
}

При этом сделаем небольшой апгрейд компонента VideoList. Теперь он будет иметь такой вид:

type Props = {
  items: Array<Video | LiveStream>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => {
        if ('coverUrl' in item) {
          // it's a video
          return <Thumbnail video={item} />
        } else {
          // it's a live stream, but what can we do with it?
        }
      })}
    </ul>
  )
}

Как видим, теперь мы столкнулись с проблемой. Да, мы легко можем отличить видео-объекты от миниатюр для прямых трансляций, но передать последние в компонент Thumbnail не получится, поскольку Video и LiveStream несовместимы. Как минимум это связано с тем, что у них разные типы данных, из-за чего TypeScript гарантированно выдаст ошибку, но кроме этого их URL расположен в разных свойствах: для видео это coverUrl, а для объектов трансляций — previewUrl.

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

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

Пример кода:

type Props = {
  coverUrl: string
}

const Thumbnail = ({ coverUrl }: Props) => {
  return <img src={coverUrl} />
}

Это небольшое изменение позволит использовать компонент для работы и с видео, и с миниатюрами для трансляций:

type Props = {
  items: Array<Video | LiveStream>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => {
        if ('coverUrl' in item) {
          // it's a video
          return <Thumbnail coverUrl={item.coverUrl} />
        } else {
          // it's a live stream
          return <Thumbnail coverUrl={item.previewUrl} />
        }
      })}
    </ul>
  )
}

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

Принцип инверсии зависимостей (DIP)

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

Чтобы лучше понять, как работает DIP, рассмотрим его на практическом примере.

Допустим у нас есть компонент LoginForm, который при отправке формы пересылает учетные данные пользователя в некоторый API:

import api from '~/common/api'

const LoginForm = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSubmit = async (evt) => {
    evt.preventDefault()
    await api.login(email, password)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      <button type="submit">Log in</button>
    </form>
  )
}

В этом примере, из-за того что компонент LoginForm напрямую ссылается на модуль api, между ними образовывается тесная связь. В действительности это является недостатком, поскольку любые изменения в один компонент также затронут работу другого. Принцип DIP говорит о том, что таких связей нужно избегать, посмотрим, как это можно сделать. 

В первую очередь избавимся от прямой ссылки на модулю api, и вместо этого позволим добавление функциональности с помощью свойств:

type Props = {
  onSubmit: (email: string, password: string) => Promise<void>
}

const LoginForm = ({ onSubmit }: Props) => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSubmit = async (evt) => {
    evt.preventDefault()
    await onSubmit(email, password)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      <button type="submit">Log in</button>
    </form>
  )
}

Теперь компонент LoginForm не зависит от модуля api. Вместо этого логика отправки данных в интерфейс API абстрагируется с помощью обратного вызова onSubmit, а ответственность за ее реализацию ложится на родительский компонент. 

Следующим шагом создадим подключенную версию LoginForm, которая будет делегировать логику отправки формы модную api. 

В коде это будет выглядеть так:

import api from '~/common/api'

const ConnectedLoginForm = () => {
  const handleSubmit = async (email, password) => {
    await api.login(email, password)
  }

  return (
    <LoginForm onSubmit={handleSubmit} />
  )
}

Теперь между api и LoginForm появилось связующее звено в виде компонента ConnectedLoginForm, при этом сами они стали полностью независимы друг от друга. Благодаря этому у нас появляется возможность их тестировать и повторно использовать изолированно, не беспокоясь о возможных конфликтах и поломках. Пока оба компонента будут придерживаться прописанной абстракции, код будет работать исправно.

Раньше такой подход широко использовался сторонними библиотеками, например такими как Redux, которая привязывает свойства обратного вызова к функциям dispatch, использующим компонент connect более высокого уровня. С появлением хуков это стало менее актуально, тем не менее внедрение логики через HOC (higher-order component) все еще довольно полезно в React.

 

Что нужно учесть при использовании принципов SOLID в React

Появление принципов SOLID стало своеобразным ответом на проблемы, возникающие в мире объектно-ориентированного прогораммирование. Однако их применение не ограничивается ООП. В этой статье мы разобрали, как при правильной интерпретации они делают код React более надежным и удобным для понимания.

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