Головна / Статті / Де має знаходитися межа фреймворку в стеку UI, призначеному для повторного використання

Де має знаходитися межа фреймворку в стеку UI, призначеному для повторного використання

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

3630 слів

Більшість команд дуже добре розв’язують проблеми інтерфейсу користувача лише для однієї фреймворк-системи. Ретельно створений набір компонентів Angular, набір примітивів React, система дизайну, інтегрована з життєвим циклом певного рендерера – усе це працює чудово, поки проект не потребує іншого інструменту, а тоді майже всі ці зусилля залишаються марними. Розділення повторно використовуваного інтерфейсу на шари (поведінка, рендеровані компоненти та невеликі можливості HTML, такі як макетування та анімації) дозволяє свідомо вирішувати, які шари слід прив’язати до фреймворку, а які – ні.

Проблема розв’язання проблем інтерфейсу користувача лише для одного фреймворку

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

Скільки з вашої інфраструктури користувацького інтерфейсу має залишитися при зміні фреймворку?

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

Безголовий інтерфейс — це не те саме, що фреймворк-незалежний

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

Ці складні аспекти — це справжня робота: керування увагою, навігація за допомогою клавіатури, правильні атрибути ARIA, поведінка при закритті та безліч деталей, які легко ігнорувати, поки модальне вікно здається просто одним div, розташованим поверх іншого. Radix навмисно постачає свої примітиви без стилів та представляє їх як доступні примітиви React. Ви контролюєте візуальне представлення, проте базова модель компонентів залишається React.

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

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

headless
   ≠
framework-agnostic

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

Поведінка може знаходитися поза компонентом

Візьмемо список випадання. Два такі списки можуть зовсім не схожі візуально. Один розташований на сторінці маркетингу з великим шрифтом, достатньою кількістю вільного простору та анімованими переходами; інший знаходиться у тісній панелі інструментів IDE, де кожен піксель має значення. Один з них може бути створений за допомогою Angular, інший — React, а ще один — веб-компонентом.

Попри візуальні відмінності, постійно повертаються одні й ті самі запитання:

  • Чи є список випадання зараз відкритим?
  • Який елемент є активним?
  • Що робить клавіша Escape?
  • Чи може користувач перемикатися між елементами за допомогою клавіш-стрілок?
  • Як обробляються недійсні елементи?
  • Куди переміщується фокус після закриття списку випадання?

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

Машини станів як спільний контракт

Саме тут інтерфейс, керований машинами станів, стає особливо привабливим. Zag.js є найвідомішим прикладом такого підходу. Замість того, щоб розглядати компонент React як джерело істини, Zag моделює взаємодію як машини, незалежні від конкретної фреймворкової платформи. Окремі адаптери фреймворків під’єднують ці машини до механізмів, якими React, Vue, Solid, Svelte та інші забезпечують реактивність, обробку життєвого циклу та роботу з DOM, а їхня документація пояснює, як написати адаптер для фреймворку, який ще не покритий.

У звичайному компоненті кожна функція об’єднана в один блок, специфічний для певного фреймворку:

React Dropdown
  ├─ state
  ├─ interactions
  ├─ accessibility
  └─ rendering

Завдяки наявності машини посередині поведінка визначається один раз, а кожен рендерер її використовує:

Dropdown behavior
                    │
               state machine
                    │
       ┌────────────┼────────────┐
       ↓            ↓            ↓
     React         Vue         Svelte

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

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

Компонент може існувати у вигляді поведінки ще до того, як стане частиною інтерфейсу

Та сама ідея працює в бібліотеці Web Components. Уявіть собі бібліотеку, створену за допомогою Stencil, яка містить звичайні кнопки, діалогові вікна, спадні списки та картки. Ці компоненти не потребують заміни на машини станів. Натомість під ними може існувати окремий шар.

Фабрика кнопок знає про стани «вимкнено» та «завантаження», обробку кліків та те, які властивості мають бути передані інтерактивному елементу. Фабрика спадних списків знає про їх відкриття, закриття, вибір та навігацію за допомогою клавіатури. Фабрика діалогових вікон знає про їх життєвий цикл та правила взаємодії. Жодна з цих логік не повинна вирішувати, як має виглядати компонент.

Концептуально щось подібне може існувати ще до прийняття рішення про відображення:

const button = createButton({
  disabled: false,
  loading: false,
  onClick(event) {
    // application behavior
  },
});

Зверніть увагу, що відсутньо: немає Stencil, немає Angular, немає компонента React, навіть CSS немає. Фабрика — це лише опис поведінки, а рендерер можна додати пізніше. Наприклад, кнопка Stencil у бібліотеці використовує цю поведінку та надає стилізований Custom Element:

<and-button variant="destructive">
  Delete
</and-button>

Інша програма може оточити той самий поведінковий ядро зовсім іншою кнопкою. Уявіть IDE у стилі десктопу, інтерфейс якого навмисно значно щільніший, ніж у звичайного веб-сайту. Його кнопки використовують інші розміри, інші дизайнерські елементи та іншу візуальну мову, тому імпорт загальновживаної кнопки Web Component був би неправильним рішенням. IDE просто має власну кнопку Angular, і ця кнопка все одно може викликати ту саму фабрику createButton().

Ось у чому полягає практична перевага. Мета не в цьому:

ONE BUTTON
    ↓
use everywhere

Мета полягає у наступному:

shared behavior
                     │
          ┌──────────┴──────────┐
          ↓                     ↓
   Web Component          Angular component
          ↓                     ↓
      Web UI                 IDE UI

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

Web Components роблять відображуваний компонент переносним

Машини станів роблять поведінку переносною. Вони не впливають на відображуваний інтерфейс, і саме тут Web Components зберігають свою цінність. Такий Custom Element, як наведено нижче, належить до Web Platform, а не до Angular, React чи Vue:

<and-button>
  Save
</and-button>

Angular може його відображати, Astro може його емітувати, React може ним користуватися, а звичайна HTML-сторінка може включити його за допомогою тега script. Фреймворк навколо нього може змінюватися, тоді як сам елемент залишається абсолютно таким самим.

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

У результаті існує два різних типи повторного використання. Один з них — це портативна поведінка:

State machine
    ↓
portable behavior

Інший — це портативний компонент, який відображається:

Web Component
    ↓
portable rendered component

Іноді вам потрібен весь Веб-компонент. Якщо елемент Button у системі дизайну має виглядати та поводитися однаково у кількох додатках, його пакування як Custom Element є розумним вибором. Іноді ж вам зовсім не потрібен повний компонент. Сценарій інтеграції в IDE — саме такий випадок: зберегти логіку взаємодії, але дозволити додатку повністю керувати відображенням та дизайном.

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

Для форматування не потрібен компонент

Форматування — найяскравіший приклад. Ось звичайний елемент Tailwind:

<div
  class="
    flex
    flex-col
    items-start
    gap-6
    p-6
    rounded-xl
    border
    bg-card
    shadow-sm
  "
>
  ...
</div>

У цьому немає нічого поганого. Однією з справжніх переваг Tailwind є те, що ви можете зрозуміти більшу частину елемента, не перемикаючись постійно між шаблоном та стилевим файлом. Але подивіться, скільки функцій виконує цей єдиний атрибут class. Деякі класи описують візуальну ідентичність:

rounded-xl
border
bg-card
shadow-sm

Інші описують просторові взаємозв’язки між елементом та його дочірніми елементами:

flex
flex-col
items-start
gap-6
p-6

Браузер не робить між ними жодної різниці. class — це просто універсальний механізм для додавання ідентифікаторів, які можуть бути вибрані CSS та JavaScript; у HTML немає поняття „класу для макету“ та „класу для дизайну“. Однак як рішення щодо проектування API їх розділення виявляється зручним. Експериментальний атрибут and-layout окремо відображає просторову частину:

<div
  class="card"
  and-layout="vertical align:start gap:lg p:lg"
>
  ...
</div>

Перевага полягає не у стислості; іноді ця версія навіть не коротша. Перевагою є те, що відповідальність стає видимою: class описує, як виглядає елемент, а and-layout — як він розташовує простір.

Як реалізується атрибут

Для реалізації не потрібні ні фреймворки, ні JavaScript. Це чистий CSS: кожен токен у атрибуті зіставляється з вибірниками атрибутів (вибірник ~= з роздільниками у вигляді пробілів ідеально підходить) та мапується на правила Flexbox, Grid, формування відстаней та адаптивності. Таке оголошення, як показано нижче, по суті є просто CSS Grid із точками переривання.

<div
  and-layout="grid cols:1 cols@md:2 cols@lg:3 gap:lg"
>

За ним не прихований жоден движок макетування, жодна директива Angular, жоден компонент React та жоден обгорток, як цей, якщо тільки ви дійсно не хочете використати Stack:

<Stack direction="vertical" gap="lg">

Ця характеристика має значення. Компоненти макетування — це не щось погане. Абстракція <Stack> може бути дуже зручною, особливо в системі проектування фреймворків. Підхід з використанням атрибутів — це просто ще один варіант: якщо елемент, який вам потрібен, вже існує, можливо, не доведеться створювати додатковий компонент лише для того, щоб описати, як розташовані його дочірні елементи.

Варто пам’ятати про компроміс: згідно зі специфікацією, користувацькі атрибути без префікса data- не є дійсним HTML, хоча кожен браузер із задоволенням їх форматує. Валідатори та деякі інструменти перевірки будуть скаржитися, а правопис data-and-layout уникає цієї проблеми, але збільшує кількість слів.

class може робити все, але не зобов’язаний цього робити

За цим стоїть більш загальна закономірність. Сучасний маркап може покласти велику частину обов’язків на class. Якщо додати CSS-функції та плагін для анімацій, цілком звичайний елемент може перетворитися на щось подібне до цього:

<div
  class="
    card
    flex
    flex-col
    items-center
    gap-6
    p-8
    rounded-xl
    border
    bg-card
    animate-in
    fade-in
    slide-in-from-bottom-4
    duration-500
  "
>

Це працює, і зрозумілі класи-функції значно кращі, ніж вдача, що кожен інтерфейс потребує ідеально семантичної ієрархії BEM. Справжнє питання не в тому, чи може class нести всю цю ношу; очевидно, що може. Питання в тому, чи має один атрибут представляти кожну з можливостей елемента. Розділення функцій дає вам:

<div
  class="card"
  and-layout="vertical align:center gap:lg p:xl"
  and-motion="slide-in-up"
  and-motion-duration="500ms"
>

Тепер елемент на перший погляд демонструє три окремі функції:

class       → visual identity
and-layout  → spatial organization
and-motion  → animation

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

Анімація отримує власний декларативний канал

Сфера анімацій будується на патерні, який ефективно працює вже багато років. Animate.css, опублікований на animate.style, керує анімаціями виключно за допомогою класів: достатньо додати базовий клас та назву анімації, і елемент почне анімуватися.

<h1 class="animate__animated animate__bounce">
  Hello
</h1>

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

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

<div
  class="
    card
    animate-in
    fade-in
    slide-in-from-bottom-4
    duration-500
  "
>

ви описуєте анімацію окремо:

<div
  class="card"
  and-motion="slide-in-up"
  and-motion-duration="500ms"
>

А коли важливий тригер, ви прямо зазначаєте його разом із затримкою та тривалістю:

<div
  class="card"
  and-motion="slide-in-up"
  and-motion-trigger="enter"
  and-motion-duration="800ms"
  and-motion-delay="200ms"
>

Маркап оголошує, яка анімація виконується, коли вона виконується та як контролюється її час; реалізація відповідає за все інше. Для тригера enter це, ймовірно, означає використання IntersectionObserver для стеження за елементом. Тригери на наведення курсору та натискання використовують власні слухачі. Важливо те, що медіа-запит prefers-reduced-motion можна враховувати в одному центральному місці, замість того щоб покладатися на те, що кожен автор компонента про це пам’ятатиме.

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

Декларативний підхід доки він допомагає

У цього підходу є обмеження. Проста анімація входу ідеально підходить для одного атрибута:

and-motion="fade-in"

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

await player.play('fade-zoom-out');
closeModal();

Спроба закодувати кожні взаємозв’язки життєвого циклу у постійно зростаючий атрибут HTML лише погіршить декларативний API, а не покращить його. Керівним принципом є вибір найпростішого рівня, який точно відображає проблему — чи то звичайний CSS, атрибут, машина станів, звичайний сервіс фреймворку чи веб-компонент. Декларативний стиль не повинен ставати догмою. Ніхто не намагається позбутися JavaScript чи фреймворків. Мета — уникати переведення кожної проблеми на найскладніший доступний рівень абстракції лише через те, що він існує.

Атрибути як невеликі API з можливостями

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

<section
  class="feature-card"
  and-layout="vertical gap:lg p:xl"
  and-motion="fade-in"
  and-motion-trigger="enter"
>
  <h2>Framework-agnostic UI</h2>
  <p>At least as much as possible.</p>
</section>

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

<and-stack>
  <and-motion-container>
    <and-card>
      ...
    </and-card>
  </and-motion-container>
</and-stack>

Ця вкладена версія не є автоматично неправильною. Якщо ці елементи мають змістовну поведінку та надають корисний API, вони цілком можуть бути компонентами. Різниця є вужчою: повторно використовувана функціональність не обов’язково мусить стати ще одним компонентом. Часто вузол DOM вже існує та потребує лише форматування, анімації чи поведінки підказок. Фреймворк не завжди мусить про це знати, і коли абстракція знаходиться безпосередньо в HTML, вона може бути використана безкоштовно в Angular, Astro, React, Vue чи на сторінці без фреймворку.

Фреймворк-незалежність не означає відсутності фреймворку

Тут існує очевидний ризик: підхід „незалежний від фреймворку“ може легко перетворитися на ще одну гонку за чистоту, що повністю втратить сенс. Фреймворки здобувають своє місце завдяки інтеграції різних елементів. Angular пропонує сигнали, шаблони, вставку залежностей, форми, маршрутизацію та чітку модель додатку. React має дуже розвинену екосистему композицій. Vue та Svelte передбачають інші компроміси.

Поведінка, незалежна від фреймворку, все одно має бути пов’язана з реактивною системою кожного додатку. Саме тому Zag постачає адаптери фреймворків: адаптер прив’язує машину до реактивності, життєвого циклу та конвенцій DOM фреймворку, і машина може залишатися незалежною лише тоді, коли інший шар розуміє цей фреймворк.

Описаний тут багатошаровий підхід також має свої витрати:

  • Компонент Angular, побудований на універсальному фабрику стану, може потребувати використання effect(), щоб підтримувати синхронність вхідних сигналів із зовнішнім станом.
  • Веб-компонент має під’єднати шар стану до власних функцій обробки життєвого циклу.
  • Макетування, засноване на атрибутах, вводить невелику DSL, яку кожен учасник команди мусить опанувати.
  • Атрибути руху додають ще один інтерфейс API.
  • Розділення всього на окремі пакети створює контракти, які мають залишатися сумісними з часом.

Іноді простий компонент, створений спеціально для певної фреймворк-системи, є явно кращим рішенням з точки зору дизайну. Саме тому набір компонентів, призначений виключно для Angular та готовий до копіювання, все ще має сенс існувати поруч із усім цим. Ніхто не повинен передавати кожну кнопку Angular через ланцюжок з чотирьох адаптерів, пари машин стану та Web Component лише тому, що термін „агностичний“ звучить більш складно. Це дуже дорогий спосіб уникнути написання:

<volt-button>

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

Інтерфейс користувача, незалежний від фреймворку, як стек, а не бібліотека

Фраза „бібліотека інтерфейсу, незалежна від фреймворку“ зазвичай асоціюється з набором компонентів, які якимось чином працюють скрізь. Web Components досить близькі до цього для відображуваних компонентів. Однак кориснішою є модель, при якій існує не одна універсальна бібліотека, а низка функціональних блоків, кожен з яких стає специфічним для певного фреймворку у різний момент:

Application
                         │
          Angular / React / Vue / Astro
                         │
                  framework adapters
                         │
    ─────────────────────────────────────────
                         │
         headless behavior / state machines
                         │
     Web Components     HTML capabilities
          │                 │
          │          layout / motion attributes
          │                 │
    ─────────────────────────────────────────
                         │
                    Web Platform

Жоден додаток не змушений використовувати кожен рівень:

  • Один проект використовує Web Component безпосередньо та ніколи не торкається базового стану під ним.
  • Інший використовує лише машину станів та будує власний інтерфейс Angular зверху.
  • Статична сторінка Astro може не потребувати нічого, крім атрибутів макетування та анімації.
  • Додаток на Angular може ігнорувати всю цю структуру та використовувати нативний набір Angular із примітивами Angular, оскільки це забезпечує найкращий досвід розробки.

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

Зберігання фреймворку на рівні додатку

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

  • Візуальний дизайн випадаючого списку може належати продукту, а його відображення — Angular, але модель взаємодії не обов’язково мусить належати фреймворку.
  • Спільна кнопка може бути веб-компонентом, якщо потрібна однакова кнопка у кількох проектах, або спеціальним компонентом Angular, який має лише невеликий базовий набір функцій, якщо продукт потребує власної візуальної системи.
  • Картку можна стилізувати за допомогою Tailwind, при цьому зберігаючи її макет у and-layout.
  • Для ефекту входу може знадобитися лише невеликий обсервер та один атрибут, і йому не має бути важливо, чи створив HTML Astro, чи Angular.
  • Якщо розглядати все разом, елементи складаються в цілісну картину:

    • Headless UI видаляє елементи візуального оформлення.
    • Машини станів звільняють поведінку від залежності від конкретного рендерера.
    • Web Components дозволяють готовим, рендерованим компонентам переміщуватися між різними середовищами.
    • Спеціалізовані атрибути прив’язують легкі функції, такі як макетування та анімація, безпосередньо до самого HTML, а не до фреймворку.

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

    Ключові висновки

    • Розрізняйте „без стилів“ та „незалежні від рендерера“; більшість безголових бібліотек належать лише до першої категорії.
    • Логіку взаємодії, якою користуються кілька продуктів, розміщуйте у машинних станах або фабриках без використання фреймворку, усвідомлено приймаючи витрати, пов’язані з адаптерами.
    • Використовуйте Web Components тоді, коли сам компонент після рендерингу, а не лише його поведінка, має бути ідентичним у різних середовищах.
    • Для формування макету та простих анімацій використовуйте лише можливості CSS, а переходьте на імперативний код, як тільки починає мати значення порядок життєвого циклу.
  • Зберігайте компоненти, специфічні для певної фреймворк-системи, там, де портабельність не є обов’язковою; мета полягає у використанні фреймворків без того, щоб кожен шар залежав від них.
  • Пов’язана література