⚛️ Лучшие практики применения принципов SOLID в React

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

Данная статья является переводом. Ссылка на оригинал.

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

SOLID — это аббревиатура, каждая буква которой представляет один из пяти принципов проектирования, а именно:

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

Прежде чем мы начнем, хочу предостеречь. Принципы SOLID были задуманы и изложены с учетом объектно-ориентированного языка программирования. Данные принципы и их объяснение сильно зависят от концепций классов и интерфейсов, тогда как в JS их нет. То, что мы часто называем «классами» в JS, — это просто двойники классов, смоделированные с использованием системы прототипов, а интерфейсы вообще не являются частью языка (хотя добавление TypeScript немного помогает). Более того, способ, которым мы пишем современный код React, далек от объектно-ориентированного — во всяком случае, он кажется более функциональным.

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

Так что давайте позволим себе некоторые вольности.

Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека фронтендера»

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

В определении говорится, что «каждый класс должен иметь только одну ответственность», т. е. делать ровно одну вещь. Этот принцип легче всего интерпретировать, так как мы можем просто экстраполировать определение: каждая функция/модуль/компонент должны делать ровно одну вещь.

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

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

Теперь давайте посмотрим, как мы можем применить этот принцип. Мы начнем с рассмотрения следующего примера компонента, отображающего список активных пользователей (файл active-users-list.tsx):

active-users-list.tsx
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>    
  )
}

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

Прежде всего, всякий раз, когда мы подключаем хуки useState и useEffect, то у нас появляется хорошая возможность извлечь их в пользовательский хук (файл active-users-list-2.tsx):

active-users-list-2.tsx
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, которую он создает для отдельных элементов массива. Если это однострочный код, к которому не подключены никакие обработчики событий, совершенно нормально оставить его встроенным, но для более сложной разметки может быть хорошей идеей извлечь его в отдельный компонент (файл active-users-list-3.tsx):

active-users-list-3.tsx
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. Эта логика относительно изолирована, и ее можно повторно использовать в других частях приложения, поэтому мы можем легко извлечь ее в служебную функцию (файл active-users-list-4.tsx):

active-users-list-4.tsx
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>    
  )
}

На данный момент наш основной компонент является коротким и достаточно простым, чтобы мы могли перестать разбивать его на части и остановиться на этом. Однако, если мы присмотримся немного внимательнее, то заметим, что он все еще делает больше, чем должен. В настоящее время наш компонент извлекает данные, а затем применяет к ним фильтрацию, но в идеале мы хотели бы просто получить данные и отобразить их без каких-либо дополнительных манипуляций. Итак, в качестве последнего улучшения мы можем инкапсулировать эту логику в новый пользовательский хук (файл active-users-list-5.tsx):

active-users-list-5.tsx
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 являются программными сущностями, нам вообще не нужно изменять определение.

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

header.tsx
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 компонент хрупким и тесно связанным с контекстом, в котором он используется, и противоречит принципу открытости-закрытости.

Чтобы решить эту проблему, мы можем использовать компонентную композицию. Нашему компоненту Header не нужно заботиться о том, что он будет отображать внутри, и вместо этого он может делегировать эту ответственность компонентам, которые будут его использовать, используя пропс children (файл header-2.tsx):

header-2.tsx
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, и теперь можем использовать композицию, чтобы поместить туда буквально все, что захотим, не изменяя сам компонент. Проще всего представить это так: мы передаем плейсхолдер в компонент, к которому мы можем подключиться. И мы также не ограничены одним плейсхолдером для каждого компонента — если нам нужно иметь несколько точек расширения (или если пропс children уже используется для другой цели), мы можем вместо этого использовать любое количество пропсов. Если нам нужно передать некоторый контекст из Header компонентам, которые его используют, мы можем использовать паттерн render props. Как видите, композиция может быть очень мощной.

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

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

В чрезмерно упрощенном виде LSP можно определить как тип отношений между объектами, где «объекты подтипа должны замещаться объектами супертипа». Этот принцип в значительной степени зависит от наследования классов для определения отношений супертипа и подтипа, но он не очень применим в React, поскольку мы почти никогда не имеем дело с классами, не говоря уже о наследовании классов. Хотя отказ от наследования классов неизбежно превратит этот принцип во что-то совершенно иное, написание кода React с использованием наследования будет преднамеренным созданием плохого кода (что команда React крайне не одобряет), поэтому вместо этого мы просто пропустим этот принцип.

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

Согласно ISP, «клиенты не должны зависеть от интерфейсов, которые они не используют». В случае React: «компоненты не должны зависеть от пропсов, которые они не используют».

Здесь мы незначительно расширяем определение ISP — и пропсы, и интерфейсы могут быть определены как контракты между объектом (компонентом) и внешним миром (контекстом, в котором он используется), поэтому мы можем нарисовать параллели между ними. В конце концов, речь идет не о строгости и непреклонности в определениях, а о применении общих принципов для решения проблемы.

Чтобы лучше проиллюстрировать проблему, которую решает принцип ISP, мы будем использовать TypeScript для следующего примера. Рассмотрим приложение, отображающее список видео (файл rawvideo-list.tsx):

rawvideo-list.tsx
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 компонент, который он использует для каждого элемента, может выглядеть примерно так (файл thumbnail.tsx):

thumbnail.tsx
type Props = {
  video: Video
}

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

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

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

Мы введем новый тип, определяющий объект прямой трансляции (файл live-stream.tsx):

live-stream.tsx
type LiveStream = {
  name: string
  previewUrl: string
}

А это наш обновленный VideoList компонент (файл video-list-2.tsx):

video-list-2.tsx
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 компонента, чтобы убедиться, что он полагается только на те реквизиты, которые ему необходимы (файл thumbnail-2.tsx):

thumbnail-2.tsx
type Props = {
  coverUrl: string
}

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

С этим изменением теперь мы можем использовать его для рендеринга миниатюр как видео, так и прямых трансляций (файл video-list-3.tsx):

video-list-3.tsx
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>
  )
}

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

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

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

Ниже у нас есть LoginForm компонент, который отправляет учетные данные пользователя в некоторый API при отправке формы (файл login-form.tsx):

login-form.tsx
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-модуль, поэтому между ними существует тесная связь. Это плохо, потому что такая зависимость усложняет внесение изменений в наш код, поскольку изменение одного компонента повлияет на другие компоненты. Принцип инверсии зависимостей выступает за разрыв такой связи, так что давайте посмотрим, как мы можем этого добиться.

Во-первых, мы собираемся удалить прямую ссылку на api-модуль внутри LoginForm, и вместо этого разрешим внедрение необходимой функциональности через пропсы (файл login-form-2.tsx):

login-form-2.tsx
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-модулю (файл rawconnected-login-form.tsx):

rawconnected-login-form.tsx
import api from '~/common/api'

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

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

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

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

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

Заключение

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

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

Первоначально оригинал статьи был опубликован на https://konstantinlebedev.com.

***

Материалы по теме

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ