Багаторівнева архітектура Front-end застосунків у Vue.js

Усі статті, обговорення, новини про Front-end — в одному місці. Підписуйтеся на телеграм-канал!

Шар (layer) — це відокремлена і переважно незалежна частина функціоналу, яка спрямована на розділення логіки застосунку та розбиття його на уявні частини, які разом складають систему. Простіше кажучи, якщо ми говоримо про будинок, скажімо, один шар — це фундамент, а інший — стіни, кожен з яких є незалежним і може існувати сам по собі, але разом вони створюють цілісну картину.

З мого досвіду роботи з Front-end розробкою, я можу виділити такі шари:

  • Service layer
  • Utility layer
  • State layer
  • Component layer
  • UI layer

Системи часто є складними, заплутаними та постійно зростаючими. Дуже важливо розуміти, що будь-який застосунок повинен легко масштабуватись, навіть якщо на момент розробки проекту, немає плану щодо його подальшого розширення, у світі Agile-розробки та вимог, які постійно змінюються, це може статись в будь-яку мить.

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

Банальним прикладом використання різних шарів, що є частиною практично будь якого вебзастосунку, є надсилання асинхронних AJAX запитів, задля отримання даних, що виконується сервісним шаром, і їх відображення на екрані що є прерогативою UI шару (шару інтерфейсу).

Найбільша перевага використання такої архітектури — це, безумовно, розподіл обов’язків. Це один з моїх улюблених принципів у розробці, яким я керуюся в своїй роботі і на застосування якого я завжди наполягаю працюючи на різних проектах.

Проектуючи певний шар — ми повині забезпечити, щоб його фукнціонал не перевищував межі його відповідальності, простіше кажучи не варто створювати щось, що робитиме багато різних дій одразу, і відхилятиеться від принципу виключної відповідальності.
З мого власного прикладу, коли я лише починав свою карєру розробника, я часто створював функці
,які здавався мені дуже охоплюючими і корисними, однак зараз я б назвав такий код оверінженерією (зайвим).

Практичним прикладом цього може бути сервісна функція (Service layer), єдиною метою якої є відправка HTTP-запиту, а не маніпуляція даними, які вона надсилає чи отримує, й не виконання інших завдань, які не мають нічого спільного з основним завданням такої функції (яким є AJAX-запит).

Забезпечуюи такий підхід в архітектурі, ви можете бути переконаними, що ваш сервісний рівень не робить більше ніж він повинен, і не турбуватися, що він може неочікувано вплинути на ваш застосунок або дані, що ви відправляєте чи отримуєте.

Приклад коду:

export const getAppointments = ( 
 params: Record<string, string> = {}, 
) => { 
 return axios.get(‘/appointments’, params); 
}; 

Вище — ілюстрація прикладу сервісної функції, що є частиною сервісного шару, яка має лише одну відповідальність — надіслати запит і отримати відповідь. Як бачимо, нічого, окрім цього, вона не робить.

Уявіть собі ситуацію, коли застосунок погано структурований, і компоненти та шар стану нашого застосунку (State layer) не взаємодіють із сервісним шаром, а отримують дані самостійно (наприклад, за допомогою вбудованої fetch функції), в цілому тут нема нічого протизаконного, але що, якщо пізніше ми захочемо змінити fetch, скажімо, на axios чи будь яку іншу бібліоетку? У такому разі ми повинні знайти всі місця в нашому коді, де ми використовуємо fetch, і власноруч замінити їх. Це, по-перше, займе багато часу, по-друге, скоріше за все, потягне за собою якісь технічні проблеми та баги.

Однак якщо б ми використовували сервісний шар у всіх місцях, де нам потрібно надсилати AJAX, ми б легко обійшлися просто заміною fetch на axios в одному місці — лише в сервісному шарі безпосередньо.

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

Ми поступово наближаємося до взаємодії між шарами, і в наведеному вище прикладі згадувалося, що один шар використовує інший шар. Повертаючись до аналогії з першого абзацу, ця взаємодія — це саме те, що робить наш «будинок» зі стінами, фундаментом, дахом та іншими невід’ємними його частинами.

На практиці це можна застосувати, коли у вас є UI-шар, який використовує шар стану для маніпуляції даними, який, своєю чергою, використовує сервісний шар для отримання даних й утиліти або допоміжні функції (Utility layer) для їхнього перетворення. А шар інтерфейсу користувача також використовує шар компонентів, оскільки саме вони зрештою створюють картинку на екрані.

Гадаю тепер картинка є більш зрозумілою, і ми можемо побачити, що шари є певною мірою незалежними, але тим не менш взаємопов’язаними. Шари призначені для роботи один з одним, однак, не втручаючись у сферу відповідальності інших.

Зверніть увагу на малюнок вище, що ілюструє взаємодію шарів. Зверніть увагу на напрямок стрілок, якими зазначено використання одним шаром — іншого. Наприклад, сервісний шар використовуватись наступними:

  • шар стану (State layer);
  • шар компонентів (Component layer);
  • шар інтерфейсу (UI);

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

Можемо провести умовний поділ шарів на ті, які використовують, і ті що використовуються.

Простіше кажучи шарова архітектура має такі переваги:

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

Отож час перейти до безпосереднього огляду шарів.

Утилітний (допоміжний) шар

Утілітним цей шар (utility layer) називається, оскільки він служить допоміжною, а не самостійною (на відміну від певних інших шарів) ланкою в нашому застосунку, і головною його метою є надання функціоналу, який може бути використаний в інших частинах проєкту задля виконання якихось маленьких цілей, наприклад, форматування дати, номеру тощо.

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

Простими словами — допоміжний шар може бути використаний в будь-якому іншому шарі, хоча сам навряд чи буде використовувати будь-який інший шар, оскільки за своєю суттю є найжичною ланкою в ієрархії всіх шарів.

Головне призначення утилітного шару — надавати допоміжний функціонал. Цей функціонал може різнитись за своїм призначенням, і не має певної чіткої цілі, однак під час проєктування варто мати на увазі, що утилітні функції не повинні виконувати занадто багато роботи, і краще розділити одну велику функцію на декілька маленьких.

Це буде правильним рішенням як з точки зору принципів програмування, зокрема поділу обовязків, так і з точки зору перевикористання. У цьому, як і в інших подібних випадках, завжди краще дотримуватися принципу композиції, і використати три функції замість однієї, що робитиме все й одразу.

Імплементація та приклади

Утиліти (так звані допоміжні функції) — це переважно функції або класи, які є безконтекстними частинами коду, спрямованими на досягнення певної вузької мети, і можуть бути використані в будь-якому іншому шарі.

Безконтекстними вони є, тому що не залежать від того, де вони використовуються, чи то компонентний рівень, чи рівень інтерфейсу користувача, оскільки їх ціллю є отримання певних вхідних даних і повернення певного результату.

Втілення шару може бути дуже різноманітним. На власному досвіді я натрапляв на декілька різних патернів, що загалом мають схожу структуру.

У директорії під назвою utils або helpers нашого проєкту є файли, які можуть або групувати утиліти за їхнім логічним призначенням. Наприклад, dateUtils.ts файл може зберігати в собі функціонал, який полегшує роботу з датами та часом, або ж окремий файл для єдиної функції.

Уявімо, що в нас є файл foramters.ts, що налічує в собі функції для форматування. Перше, що спадає мені на думку, — це функція форматування номеру телефону.

function formatPhone(rawNumber: string) { 
     rawNumber = rawNumber.replace(/^\d{3}/, (firstThree) => `($ {firstThree})`  
 ) 
} 

Сама функція досить проста, в якості аргументу вона отримує невідформатований номер, що і приводить його до потрібного формату.

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

Її можна викликати як в script нашого компоненту, так і в становому шарі (State layer) і загалом скрізь, де нам потрібно виконати таку «допоміжну» операцію (що ще раз підкреслює безконтестність її та самого шару).

Іншим, трохи складнішим, прикладом імплемпентації може бути допоміжний клас, хоча загалом суть його така сама. Він просто об’єднує функції, що схожі за призначенням. Схожий патерн ми вже розглянули вище, однак клас має в цьому випадку певні переваги, оскільки він служить більш тісним варіантом об’єднання схожого функціоналу.

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

class DateTimeUtil { 
     private static client = dayjs 
     protected static format = ‘MM/DD/YYYY’
     static getToday() { 
          return DateTimeUtil.client() 
     } 
} 

У прикладі вище ми використовуємо бібліотеку dayjs, і всі (або принаймні більшість) функціоналу в нашому класі буде використовувати в своїй імплементації саме цю бібліотеку. Так ми забезпечуємо тісний зв’язок між допоміжними функціями чи методами.

Загалом те місце, де ми використовуватемо цей клас, не знатиме про внутрішнє втілення методів, і буде лише покладатись на те, що виклик DateTimeUtil.getToday() поверне нам сьогоднішню дату.

Ще однією великою перевагою як класового, так і раніше згаданих підходів є те, що ми не прив’язані в жодному разі до використання певних бібліотеки в нашому застосунку.

Допоможний шар гарантує те, що, використовуючи його, функція зробить тільки те, що вона покликана зробити, як у прикладі з getToday. Нас не хвилює, як цей метод вираховує і формату дату, і чи використовує він dayjs, moment,чи збудований функціонал — нас цікавить лише кінцевий результат, а не імплементація.

Сервісний шар

Сервісний шар (або HTTP-шар) відповідальний за асинхронні запити даних або взаємодії з третіми сторонами, наприклад, API.

Він віддалено нагадує допоміжний шар, оскільки в основному має схожу відповідальнісь. Його сфера застосування трохи вужча, оскільки він відповідає за AJAX-запити і піклується лише про те, щоб зробити запит і повернути його результат.

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

Під час використання сервісної функції, за аналогією до допоміжної функції ми не замислюємось про її втілення, а лише знаємо, що функція може приймати в себе певні вхідні дані на кшталт параметрів запиту (query parameters — якщо це GET-запит).

А також повертає результат, що, на відміну від результату допоміжної функції, зазвичай прямо впливає на флоу користувача, окрім випадків, якщо це сервісна функція аналітичного характеру тощо.

function getAllAppointments(params = {}) {  
 return client.get(‘/appointments’, params)  
}

Сервісна функція та шар загалом має вужчий спектр застосування, ніж згаданий утилітний шар, оскільки і призначення його є більш специфічним. У Vue-застосунку зазвичай ми можемо задіяти сервісні функції безпосердьно в комопнентах задля отримання даних, скажімо, користувача, в сховищі.

Загалом використанння сервісного шару у Vue не сильно відрізняється від його використання в інших JavaScript-фрейморках, оскільки призначення його є досить вузьким. Однак з власного досвіду хочу зазначити, що надання переваги використанню саме сервісного шару замість прямого задіяння бібліотек на кшталт axios задля отримання даних к наших компонентах чи деінде, є правильною стратегією, що:

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

Імплементація

Втілення цього шару загалом багато в чому схоже до утилітного шару. І хоча під час використання сервісних функцій зовні (в наших компонентах чи в сховищі) нас хвилює лише результат, то під час безпосереднього втілення сервісного шару ми повинні подбати про забезпечення належного функціоналу.

За своєю природою сервісна функція, на кшталт вищезгаданої getAllAppointments, є функцією вищого порядку, що викликає (приклад нижче) ще одну функцію getRequest, яка, своєю чергою, вже безпосередньо контактує з біліотекою для AJAX-запитів.

Що таке сервісний шар

На відміну від getAllAppointments, getRequest несе винятково технічний характер і служить лише надбудовою над axios.get. Це може бути як проста функція, так і метод класу.

Класова імплементація:

class HttpClient {  
 private client = axios.create(config)  
  
 get(url, params) {  
 return this.client.get(url, params)  
 }  
}  

Функціональна імплементація:

const client = axios.create({ baseURL: process.env.API_URL })  
const getRequest = (url, params, config) => {  
 return client.get(urk, params, config)  
}  

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

Становий шар (State layer)

Насамперед варто зазначити, що під становим рівнем я маю на увазі систему керування глобальним станом нашого застосунку (global state management system, на кшталт Vuex або Pinia, однак заради зручності тут і надалі я називатиму це сховищем (store).

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

Його важливість полягає в тому, що шари UI і компонентів сильно залежать від нього, оскільки вони отримують дані зі сховища за допомогою getter-функцій і впливають на дані в сховищі за action-функції, а це означає, що один компонент може впливати на інші, змінюючи дані в цьому сховищі.

Оскільки ми говоримо про архітектуру в контексті Vue.js, то відповідно ми будемо розглядати на прикладі Pinia 

На мою думку, становий шар (state) слід розглядати як шар, що є джерелом та постачальником основним даних для нашої системи. Тому дуже важливо розуміти, що стан не варто змінювати безпосередньо напряму, а лише через actions (і mutations у випадку Vuex), а доступ до даних слід здійснювати винятково через getters-функції.

Важливість тут полягає в тому, що структура сховища може змінюватися, а гетери — ні.

Не варто зловживати використанням сховищ для простих випадків, наприклад, коли вам просто потрібно поділитися даними між двома або більше компонентами, які по-різному розташовані в ієрархії компонентів.

Я пропоную подумати про інші способи зробити це, оскільки становий шар не повинен виконувати роль передавача даних між неправильно структурованими компонентами, і в жодному разі не є аналогом шини подій (event bus).

Action-функції також використовуються для впливу на перебіг даних у нашій системі. Вони також можуть охоплювати певну логіку, окрім посилання простого запиту на отримання даних користувача, як у прикладі вище (наприклад, збереження мови користувача у локальному сховищі (localStorage), виклик інших action-функцій, мурування стану сховища).

Я б наполягав на тому, щоб маніпуляції зі сховищем відбувалися тільки через action-функції, а отримання даних, збережених в стані сховища, винятково через гетери, оскільки це гарантує безпечний і передбачуваний потік даних у застосунку. Ми знаємо: якщо потрібно змінити дані, ми викликаємо action функції, якщо потрібно отримати дані — getter функції.

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

Я виділяю сховище як окремий шар, оскільки зазвичай воно використовується для зберігання найбільш важливих даних у нашому застосунку. Їх важливість полягає в:

  1. Вони мають найбільше значення з точки зору бізнес-логіки.
  2. Вони можуть бути використані в різних частинах нашого застосунку одночасно.

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

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

{ 
      state: () => { 
      return { 
            user: { 
                 firstName: “Vladyslav”, 
                 lastName: “Nosal” 
                 occupation: “Developer” 
                 }
            } 
       }, 
       getters: { 
            isProgrammer(state) { 
                 return state.occupation === “Developer” 
              | state.occupation=== “Programmer" 
              }, 
              isAuthenticated(state) { 
                   return !!state.user 
            } 
      } 
} 

Так ми підтримуємо наш стан чистим від зайвих даних, а гетери функції грають роль інтерпретатора між тим, що є в стані і що потрібно нашим компонентам, з точки зору бізнес-логіки.

Окрім правильного структурування getter функцій варто приділити увагу правильній організації action-функцій, оскільки вони фактично виконують подвійну роль, а саме — надсилання асинхронних запитів на дані (зазвичай) і зміну стану (аналог мутації).

Я переконаний, що action функції не повинні робити занадто багато, і зазвичай мають відповідати лише за щось одне, наприклад, оновлення даних користувача.

Однак у разі, якщо у вашому застосунку присутня якась складна логіка, наприклад, зберігання певних даних у локальному сховищі (або схожого функціоналу, що має стосунок до відповідальності функції), можете сміливо розмістити цей функціонал в action функції, якщо вам здається, що логіка є пов’язаною між собою.

Компонентний шар

Уся Front-end розробка сьогодні базується на концепції багаторазового використання компонентів, і це цілком логічно, оскільки існує безліч UI-патернів, які можна повторно використовувати в нашому застосунку.

На щастя (чи ні), у цій статті я не збираюся заглиблюватися у визначення компонентів, а натомість хочу розглянути їх з точки зору багатошарової архітектури.

Компонентний рівень є одним з найважливіших у наших застосунках, якщо не найважливішим, і це очевидно, оскільки саме з компонентів складається інтерфейс користувача, який ми й бачимо на наших дисплеях.

Якщо ми звернемо увагу на першу діаграму, то помітимо, що компонент розташовується безпосередньо над рівнем інтерфейсу користувача (UI-шар), і як рівень він є більше споживачем, ніж постачальником.

Я б навіть сказав, що він є майже абсолютним споживачем, і може бути спожитий лише UI-шаром, що фактично стоїть на рівень нижче в ієрархії, але про це пізніше.

Це тому, що компонент — це окремий шар, зі своєю власною логікою, реалізацією, введенням і виведенням, а також відповідальністю.

Компоненти — це окремі частини коду в нашому застосунку, і кожен з них вимагає особливого ставлення до себе залежно від свого призначення.

Розглянемо діаграму:

Тут ми можемо помітити дві найважливіші речі, які роблять компоненти окремим шаром: наявність вхідних даних та результату (input та output). Ці два аспекти є дуже важливими, оскільки вони окреслюють компонент як річ, яка використовується для досягнення певної мети. Це легко порівняти, наприклад, навіть з утилітною функцією, яка отримує об’єкт у camel case і повертає той самий об’єкт, але з ключами у snake case.

Так само наш уявний компонент отримує вхідні дані, зазвичай — це пропси, але також можуть бути ін’єкцією залежностей через Provide/Inject API, зберігати дані або будь-що інше, що передається до нього якимось чином, а потім повертає результат через Emit, або, наприклад, оновлюючи дані у сховищі, або викликаючи надану функцію та оновлюючи щось у компонентах, що перебувають вище в ієрархії.

<appointment-modal 
 :appointment-id="1"
 @appointment-created=“onAppointmentCreated" 
></appointment-modal> 

Наведений вище фрагмент коду демонструє використання компонента модального вікна, який призначений для створення та зміни візитів (appointment).

У ролі вхідних даних, компонент потребує appointment-id задля отримання результату — призначення візиту, і він надсилає подію батьківському компоненту, щоб повідомити йому, що завдання було виконано.

Можна виокремити риси, які роблять компонент окремим шаром (рівнем):

  • модульність — компонент можна імпортувати і використовувати будь-де;
  • наявність вхідних та вихідних даних (результату) — компоненти отримують щось і натомість видають результат (навіть якщо компонент не отримує вхідні дані, що іноді трапляється, він все одно видає деякий інтерфейс);
  • власна сфера відповідальності — компонент, призначений для виконання певних дій, чи то функціональний компонент, який використовується для завантаження файлу, чи то компонент, який винятково візуалізує інтерфейс користувача, кожен з них виконує певну задачу.

Відповідальність компонента

Як ми вже обговорювали, цей пункт є одним з найголовніших аспектів, що роблять компонент окремим шаром. Кожен компонент зрештою щось робить, і це те, що ми називаємо результатом (output). Це може бути вивід інтерфейсу користувача, функціональний вивід або будь-який інший вид кінцевої мети, що досягається компонентом.

Обов’язки різняться від компонента до компонента, і оскільки компоненти можна поділити на безліч різних типів, їх об’єднує те, що вони є частиною інтерфейсу. Усі вони вносять свій вклад в інтерфейс (UI) і користувацький досвід (UX), разом складаючи загальну картину.

Однак якщо ми спробуємо заглибитися, то окрім візуалізації інтерфейсу, кожен компонент має певні функціональні з технічної точки зору завдання, і в цьому вони схожі на утиліти, оскільки кожна утиліта виконує свою функцію, наприклад, форматування телефонного номера або щось подібне. Так само може бути компонент, який просто відображає список номерів телефонів, які він отримує в пропсах. Все залежить від того, за що відповідає компонент і як він вписується в загальну картину програми.

Доцільно, на мою думку, розділити компоненти на два основних типи (підтипів може бути набагато більше):

  1. Базові (функціональні) компоненти.
  2. Бізнес-компоненти.

Я згрупував їх саме так, оскільки вважаю, що так можна краще окреслити завдання компонента як шару. Це означає, що мета базових компонентів — бути максимально придатними для перевикористання, оскільки вони в основному використовуються для функціональності програми. Думайте про них як про гвинтики — різні гвинтики, маленькі, звичайні і величезні, які потрібні для того, щоб зібрати один великий механізм.

Хорошим прикладом може бути компонент Input, розглянемо:

Цей компонент є функціональним і не пов’язаний з бізнес-вимогами, до певної міри він навіть поводиться як підшар компонента, оскільки його мета — допомогти бізнес-компоненту досягти своєї мети. Простіше кажучи, компонент Input може бути використаний будь-де в застосунку для збору даних з клавіатури користувача.

Ми можемо навести стільки прикладів базових компонентів, скільки захочемо (це може бути BaseDialog, який є просто обгорткою для діалогових компонентів, або BasePhoneInput, який є тим самим Input-компонентом, що і вище, але з перевіркою і маскою для номера телефону), але, врешті-решт, вони не є повністю автономними.

Їхгє використання не має сенсу, якщо вони не використовуються в комбінації, яка утворює бізнес-компонент, і в цьому відношенні вони схожі до утилітних функцій.

Деякі інші приклади базових компонентів:

BaseIcon та BaseCard охоплюють певну логіку, в нашому прикладі логіку UI. Їхня сфера або відповідальність полягає в тому, щоб гарантувати, що якщо нам потрібно використовувати компонент іконки в нашій системі або компонент контейнера (в цьому випадку це картка), вони будуть мати однаковий стиль та манеру інтерфейсу, що значно скорочує час розробки та робить наш застосунок послідовним.

Повертаючись до зв’язку між Business і BaseComponents, якщо нам потрібні компоненти AppointmentDialog

І, скажімо, CreateUserDialog, кожен з них, швидше за все, потребує вхідних даних і контейнерних компонентів, щоб їх містити, і в цьому разі наші базові компоненти стануть в нагоды як ніколи, оскільки вони:

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

Бізнес-компоненти, своєю чергою, є чимось на кшталт шару, що стоїть над базовими компонентами. Використовують їх, щоб зробити можливим досягнення певної бізнес-цілі. Завдання бізнес-компонентів різноманітні, зокрема відповідальність, але вони забезпечують досягнення загальної мети застосунку, а саме: його бізнес-реалізації.

На відміну від функціональних компонентів, бізнес-компоненти здебільшого використовуються в макетах і сторінках, оскільки, хоча перші є скоріше помічниками, вони спрямовані на виконання бізнес-вимог, і саме завдяки їм шар компонентів є тим, чим він є.

Приклад коду:

<div class=“appointments-page”> 
    <div> 
        <appointment-card  
            v-for=“appointment in appointments”  
            :key=“appointment.id"  
            :appointment-id=“appointment.id” / > 
    <div> 
        <appointment-dialog v-model=“showDialog” 
        :appointment-id=“appointmentId” / 
        @appointment-created=“onAppointmentCreated” 
 > 
<div> 

У нас є два бізнес-компоненти, вони використовуються на сторінці (компонент сторінки належить до UI-шару, про який ми поговоримо пізніше) — в композиції вони складають певний сценарій (flow) для нашого користувача. У кожного з них є своя сфера відповідальності, яка певним чином впливає на на окремі частини застосунку та на нього загалом.

Реалізація може бути дуже різною, але з точки зору шарів нас цікавить лише те, що компонент отримує і який результат він видає та як він впливає на flow.

AppointmentCard  відповідає лише за візуалізацію інтерфейсу, тоді як AppointmentDialog  використовується для того, щоб дозволити користувачеві створити зустріч, яка є результатом роботи бізнес-компонентів та їхньої сфери відповідальності.

Склад та ієрархія компонентів

У своєму професійному досвіді я іноді стикався з випадками, коли не була дотримана правильна ієрархія компонентів.

Правильна ієрархія нагадує шари, як я вже згадував раніше, BaseComponents можуть бути підкласифіковані як окремий шар, і так само можуть бути різними компонентами. Ми повинні намагатися утримуватися від використання діалогів у діалогах, карток у картках, базових компонентів у базових компонентах, оскільки це порушує правила композиції компонентів.

Компоненти зазвичай разом створюють певну композицію. Рівень інтерфейсу користувача (UI layer) фактично також складається з компонентів, однак ці компоненти не мають прямого стосунку до рівня компонентів і призначені для того, щоб лише містити інші компоненти і, фактично, бути для них контейнером.

Таким чином, компоненти складаються (а отже утворюють) певну ієрархію, гарним свідченням чого може бути компонент діалогу. Розглянемо приклад:

Це типове застосування композиції компонентів. У нас є діалоговий компонент, який в цьому разі є обгорткою для інших компонентів. Він трохи нагадує UI-шар, оскільки по суті є контейнером для інших компонентів і за своєю задачею нагадує компонент сторінки (що я виніс в окремий UI-шар), оскільки просто містить в собі інші компоненти, і у своїй композиції складає певний сценарій для нашого користувача — як-от створення запису (апоінтменту).

Розглянемо ще один приклад композиції.

Хоча структура, подібна до наведеної нижче, суперечить концепції композиції, проте іноді може бути такою.

У цьому прикладі наш діалоговий компонент, подібний до сторінки або макету, містить інший діалоговий компонент, що означає, що компонент того ж шару містить інший компонент, і не створює правильної субординації.

За звичайних обставин компонент сторінки відповідав би за відкриття діалогів та обслуговування їхніх подій, оскільки сторінка може містити багато різних сценаріїв, тоді як діалог зазвичай призначений для одного або максимум двох (наприклад, додавання / редагування користувача).

Тут ми маємо AppointmentDialog, який не тільки слугує посередником для подій та пропсів між AppointmentPage та AddUserDialog, але також піклується про стан AddUserDialog (відкритий він чи ні, за яких умов його показати чи навпаки сховати і т. д.).

Цього не повинно відбуватися, оскільки, по-перше, ми обтяжуємо наш AppointmentDialog тим, чого він не повинен робити, по-друге, ми містимо компонент рівня представлення (діалог) в іншому діалозі.

По суті, це означає, що один флоу міститься в іншому, що не є гарним архітектурним рішенням.

UI-шар

Раніше ми вже згадували про рівень інтерфейсу користувача. В ієрархії шарів він займає перше місце, і це тому, що саме він є тією частиною програми, яка в кінцевому підсумку об’єднує все разом і створює остаточну картину для користувача, тобто — користувальницький інтерфейс (UI).

Цей рівень може складатися з компонентів Layout (макет) і Page (сторінка). Вони відрізняються один від одного і мають різне призначення.

Макети, як правило, відповідають винятково за інтерфейс користувача і їхня основна мета — слугувати обгорткою для сторінки. В той час як компонент сторінки — включає в себе певний флоу, як згадувалося раніше, і окрім того також може легко містити бізнес-логіку, AJAX-запити і різні компоненти.

Розглянемо наступний приклад компонента Layout:

<template> 
 <v-layout> 
 <v-main class="relative text-black-600"> 
 <v-progress-linear 
 v-if="loading" 
 color="secondary" 
 class="fixed top-1 z-50" 
 /> 
 <base-nav></app-nav> 
 <router-view></router-view> 
 <base-footer v-if="false"></app-footer> 
 </v-main> 
 </v-layout> 
</template>

Макет може містити різні сторінки, і його основним обов’язком є консолідація певного інтерфейсу для цих сторінок. Це самий верх шару UI (представлення), і він відповідає за інтерфейс. Про макет не слід думати як про компонент, ми повинні говорити про нього як про підставку для полотна, де полотно — це сторінка, а сама картина — це самий флоу. Отже макет зазвичай не має декларації props або emits.

Зі сторінками, своєю чергою, все трохи інакше. Вони містяться в макетах і відповідають за групування компонентів (компонентів з шару інтерфейсу користувача) для створення певного потоку для кінцевого користувача.

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

Це, безумовно, пов’язано з логікою, але зазвичай не з бізнес-логікою, а з логікою реалізації. Аналогічно до діалогів, сторінки не повинні містити інші сторінки, і хоча діалоги іноді можуть це робити, у випадку компонентів сторінок такий варіант неможливий.

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

Композиція та ієрархія також відіграють важливу роль на рівні користувацького інтерфейсу. Протягом своєї кар’єри я завжди утримувався від використання BaseComponents безпосередньо в шарі UI.

Це створить ситуацію, коли ми пропускаємо кілька рівнів компонентів і безпосередньо взаємодіємо між верхніми і нижніми за ієрархією компонентами. Це також призводить до неправильної композиції, що означає, що компоненти відповідають за те, що є поза сферою їхньої відповдільності. Моя власна рекомендація — уникайте такий ситуацій.

Композиція та невтручання

Вище ми ознайомились з різними шарами та їхньою сферою відповідальності, однак лише в сукупності ці шари формують систему. Розуміння того, який функціональний обов’язок несе той чи інший шар, дає нам змогу архітектурно правильно побудувати наш застосунок, спираючись на розуміння того, яка частина нашого застосунку і яким чином вплине на нього.

Простіше кажучи, залучаючи становий шар, ми не очікуємо того, що він раптом може вплинути на UI нашого користувача безпосередньо (наприклад, додаючи HTML-елемент у DOM). Ми знаємо, що і від чого очікувати і відтак робимо роботу з нашим кодом більш передбачуваною. Ми чітко окреслюємо сферу, за яку шар несе відповідальність, і зазвичай ця сфера хоч і може бути дотичною та схожою до обов’язку іншого шару, однак однаково залишається його винятковим обов’язком.

Забезпечивши те, що шари належним чином взаємодіють між собою, ми повинні також подбати про те, щоб вони не втручались в діяльність один одного. Окрім вищезгаданого мною прикладу про маніпуляцію DOM становим шаром, можна також уявити сервісну функцію, що трансформує дані, отримані з API , та фактично перетягує на себе відповідальність, таким способом втручаючись в роботу іншого шару та виходячи за власні межі.

Для того, щоб уникнути таких випадків, шари повинні складати належну композицію, правильно розподіляючи обов’язки. Архітектурна організація їхньої взаємодії (наприклад, як правильно організувати компоненти, чи повинен той чи інший компонент залежати від даних в сховищі тощо) — це інше досить широке питання, але воно перебуває поза межами статі.

Я б хотів наголосити на тому, що задля забезпечення композиції ми повинні використовувати лише ті вхідні точки, які нам забезпечені самими шарами. На прикладі станового шару я зазначав, що задля отримання даних з нього нам потрібно використовувати гетер-функції, а не напряму через state. Правильніше буде сказати — використовувати лише те API, яке шар надає для взаємодії з ним.

Це стосується як станового шару, так і компонента з його прописами і подіями та й інших шарів.

Висновок

Загалом багаторівнева архітектура дозволяє нам правильно розподілити обов’язки між різними частинами програми, в той же час сприяє масштабованості інтерфейсу, покращує якість коду, роблячи його більш інтуїтивно зрозумілим і автономним, а також робить застосунок модульним, а його частини — придатними для повторного використання.

Я сподіваюся, що ця стаття була корисною, і мені вдалося поділитися своєю точкою зору і розумінням того, як повинен або може бути структурований Front-end.

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному8
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Гарна стаття, якраз те що треба для більшості потреб! Цікаво з точки зору автора як би змінилась ця архітектура для товстого клієнта?

Даєш силку на гіт де ти це робиш для прикладу?)

Даєш силку на гіт де є то про тут пишеться

Чудова стаття, але це хіба не база для розробки? Я сам не фронтедер, але почав рухатися в цьому напрямку і чомусь думав, що подібні практики- це база для розробки сервісу

Звичайно це базові речі, однак як я помітив — часто і їх не дотримуються, часто інколи і розробники з тривалим досвідом)

Тю, я думав це все — звичайна практика для розробника який має досвід. Ну, типу, single responsibility, асбтракції, і все таке. А це ціла multi-layer архітектура. Фронт-ендщики вирішили погратись в серйозних дядь-архітекторів :D

Single responsibility це не про це як воно звучить :)

Дякую за клопітку роботу.
Але у контексті сучасного Vue варто було згадати про Composables та їхню роль.
У нас це баланс між сервісами та компонентами.
Але проблеми з повторюваністю коду немає, згаданої проблеми по заміні fetch на axios теж.

А ще існує Feature-Sliced Design ;)

І такий є, однак поки що не знайшов для себе його практичного застосування :)

Штука продумана, але як на мене забагато common/backend патернів там які на фронті здебільшого надлишкові.

Залежить від товщини клієнта, яка в свою чергу залежить від бізнес ріквайрментів

у світі Agile-розробки та вимог, які постійно змінюються, це може статись в будь-яку мить.

Тому застосунок повинен бути побудований ретельно, з оглядом на майбутнє

Відколи програмісти вміють передбачати майбутнє?

Бачив неодноразово як ретельно побудована архітектура через пів року, рік ставала не актуальною, та більше заважала ніж допомагала. Коли до цього додається наполегливе бажання консистентності маленькі PR які пропонують локальні покращення відхиляються хазяїном коду. Нездорова ситуація.

Перш за все хочу подякувати за такий труд — стаття обʼємна та наповнена інформацією.

По друге хочу висловити свою думку стосовно подібного підходу. Воно звісно всіляко краще ніж просто кодити аби що аби куди. І мені здається що непогано ляже на не дуже великі проєкти. Але от далі можуть бути складності. При рості проєкту буде дуже складно утримувати в голові ось ці звʼязки між шарами, ну типу що де і коли викликається. Особливо важко може стати під час внесення змін або рефакторингу. Тобто коли декілька ui components мають в залежностях певний метод/клас з сервісного шару і нам треба внести зміни в один з цих компонентів. Треба або дописувати нове в сервісний шар, або обкладатися conditions типу якщо це ось такий компонент то роби так і так.

Ви можете сказати «раз критикуєш, то пропонуй», то я відповім що узагальнено vertical slicing, це коли у тебе фіча поєднує маленьку частину з кожного шару, буде більш прогнозований в роботі хоч і більш багатослівний, бо треба буде писати більше коду бо функціональність однієї фічі не може перетікати в іншу.

Звісно будь який архітектурний патерн при зрості проєкту тяжко підтримувати, і це я вважаю норма, однак тим не менш варто прагнути до збереження архітектури і не нехтувати цим при масштабуванні проєкту. На пій погляд дотримуючись такої архітектури можна забезпечити передбачуваність системи та її зрозумілість для інших розробників.
Стосовно вашого прикладу з компонентом — не зовсім зрозумів, тому буду вдячний якщо ви дасте більш розгорнутий приклад і я з радістю вам відповім!

Ну зараз спробую. В моєму випадку я розглядаю UI як джерело істини і відповідно до нього нарізаю функціональність. Тобто якщо у нас є модалка створення сутності, то відповідно до мого підходу, це атомарна функціональність. Уявимо собі що ми для неї створюємо директорію EntityCreateModal і в цій директорії ми зберігаємо все що стосується логіки дій цієї модалки — виклик API, можливо стейт цієї модалки якщо він потрібен. Функціональність яка заявлена в EntityCreateModal не перевикористовують ніякі інші UI віджети. Так, самі UI компоненти які ми стілізували використовуються наскрізно по всьому проекту. Таким чином ми маємо ізольовану функціональність яка гарантовано не зміниться якщо ми запроваджуємо зміни в інших частинах проєкту. Виходить більше коду, але він до біса стабільний, підтримуємий та розширюваний.

Звісно, подібний підхід може бути надлишковим для невеликих за розміром проектів, або тоді коли треба шось швидко дуже накидати.

Чи є якісь особливості у файловій структурі при такій архітектурі? Можливо ви якось виражаєте ці шари?

Підписатись на коментарі