3190

Застосування принципів 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 може принести додатку не тільки користь, а й шкоду. Тому завжди потрібно відштовхуватися від особливостей та потреб кожного окремого проекту.