Розділення UI та Logic в React: підхід чистого коду з Headless-компонентами
Усі статті, обговорення, новини про Front-end — в одному місці. Підписуйтеся на телеграм-канал!
Привіт! Мене звати Роман, і я Front-end Technical Lead у Valtech. Ми розробляємо велику e-commerce-платформу, використовуючи React-бібліотеку та сучасні підходи до архітектури, зокрема Headless-компоненти.
Впродовж останніх п’яти років я працював над ентерпрайз-проєктами різного розміру, і зміг зануритися в особливості та переваги різних архітектурних рішень.
Поки JavaScript продовжує залишатися лідером у створенні динамічних вебсайтів та застосунків, важливо розуміти, як різні підходи до архітектури можуть вплинути на проєкт. Зокрема, враховуючи проєктні вимоги, може бути корисним застосування Headless-підходу до написання компонентів, яке дозволяє розділити UI від бізнес-логіки і забезпечє більшу гнучкість та швидкість розробки.
Я хочу поділитися інсайтами про те, як оптимізувати роботу команди на проєкті, підвищити ефективність роботи з великою кількістю компонентів і досягти високого рівня перевикористання коду. Використання Headless-підходу дозволяє ефективно управляти змістом і функціональністю без прямої залежності від конкретного фронтенд-фреймворку. А це відкриває широкі можливості для експериментів.
Розробка вебзастосунків вимагає вирішення бізнес-проблем, функціональності та продуктивності за високої якості коду.
Чітке розділення зон відповідальності між інтерфейсом (UI) та бізнес-логікою застосунку — один із ключових факторів, який сприяє підвищенню якості коду. Це розділення дозволяє розробникам ефективніше справлятися з комплексністю проєктів, спрощує тестування та підтримку коду, забезпечує більшу гнучкість під час проєктування юзер-інтерфейсу.
Роль React в сучасній веброзробці
React, бібліотека для побудови юзер-інтерфейсів, створена Facebook і займає передове місце у світі сучасної веброзробки. Завдяки своїй гнучкості, компонентному підходу та ефективності в рендерингу DOM-дерева, React дозволяє розробникам створювати швидкі та інтерактивні вебзастосунки.
Використання компонентного підходу сприяє перевикористанню коду та спрощує процес розробки, роблячи React одним з найпопулярніших інструментів серед веброзробників.
Вступ до концепції Headless-компонентів
Концепція Headless-компонентів — один з підходів до реалізації ідеї розділення UI та бізнес-логіки.
Headless-компоненти — це компоненти, які мають лише логіку без прив’язки до конкретного UI. Це означає, що розробники мають можливість використовувати UI-частину незалежно від бізнес-логіки, що робить розробку дизайну більш гнучкою і адаптивною до змін.
Використання Headless-компонентів у React дозволяє створювати більш чистий та організований код, сприяє кращій сепарації відповідальностей та підвищує ефективність роботи розробника.
Таким чином розділення UI та логіки в React з використанням Headless-компонентів виступає не просто як рекомендація, а як важливий підхід до створення чистого, ефективного та легко підтримуваного коду в сучасній веброзробці.
У ентерпрайз-проєктах з великою кількістю компонентів і складною бізнес-логікою можливість ізолювати UI від логіки може значно спростити роботу над кодом, оновлення і тестування. Великі проєкти часто мають довгий життєвий цикл і команда таких проетів має багатьох розробників, тому підтримка чистоти й організованості коду є критичною.
Headless-компоненти дозволяють реалізувати рефакторинг і модифікацію бізнес-логіки без ризику появи багів на стороні UI, що забезпечує стабільність і надійність застосунку.
Враховуючи вимоги до швидкої адаптації до різних платформ та розширень екранів, великі проєкти можуть ефективно використовувати компоненти без власного інтерфейсу (headless) для створення послідовного юзер-досвіду на всіх пристроях, зберігаючи водночас унікальний інтерфейс (UI). Це дозволяє командам швидше реагувати на зміни у дизайні та вимогах бізнесу, оскільки інтерфейс можна оновлювати незалежно від логіки застосунку.
Використання Headless-компонентів у великих проєктах сприяє також поліпшенню співпраці між дизайн командою та розробниками. Дизайнери можуть працювати над UI-елементами, поки розробники фокусуються на логіці, що забезпечує більш ефективний процес розробки.
Основи Headless Компонентів
Визначення та призначення Headless-компонентів
Headless-компоненти в React — це компоненти, які не мають власного графічного інтерфейсу (GUI), визначеного напряму в коді. Вони зосереджені на логіці та функціональності та надають розробникам можливість використовувати цю логіку з будь-яким виглядом UI-інтерфейсу, який може бути реалізований окремо.
Головна ідея полягає в тому, що логіка компонента (наприклад, управління станом, взаємодія з API, обробка подій) розробляється окремо від його візуальної частини, дозволяючи розробникам створювати більш гнучкі та перевикористовувані компоненти.
Переваги використання headless компонентів у проєктах React
- Підвищення відсотку перевикористання коду: оскільки headless компоненти відокремлюють логіку від UI-частини, їх можна легко перевикористовувати в різних частинах застосунку або навіть у різних проєктах з різними вимогами до UI.
- Гнучкість дизайну: розробники і дизайнери не обмежені структурою чи стилем конкретних компонентів. Вони мають повну свободу створювати унікальний інтерфейс, який відповідає потребам їхнім користувачів, не змінюючи бізнес-логіку компонента.
- Спрощення тестування: тестування логіки, відокремленої від UI, стає простішим і більш зосередженим. Можна легко написати юніт-тести для функціональності без необхідності враховувати взаємодію з DOM або стилізацію.
- Поліпшення розділення відповідальностей: розділення відповідальностей між компонентами сприяє чистоті коду та його організації. Senior розробники можуть зосередитися на логіці, тоді як дизайнери або фронтен-розробники з меншим досвідом можуть незалежно працювати над UI.
- Легкість інтеграції з іншими бібліотеками та фреймворками: Headless-компоненти можуть бути легко інтегровані з іншими бібліотеками або фреймворками для UI, оскільки вони не накладають жорстких вимог на вигляд або структуру інтерфейсу.
- Оптимізація продуктивності: відсутність прив’язки до конкретних стилів або елементів DOM у Headless-компонентах може допомогти уникнути зайвого рендерингу, сприяючи кращій продуктивності застосунку.
З мого досвіду роботи над великими і складними проєктами я особисто переконався у перевагах використання Headless-підходу в написанні React-компонентів. На прикладі розробки нашої великої e-commerce платформи, що дозволяє будувати e-commerce вебсайти для різних клієнтів, стало зрозуміло, що розділення UI та бізнес-логіки є не просто зручним, а критично важливим кроком для забезпечення гнучкості та ефективності розробки.
Це розділення дозволяє нам не тільки швидше реагувати на вимоги клієнтів, але й надає нам можливість кастомізувати дизайн під кожного клієнта без потреби переписувати або коригувати велику кількість бізнес-логіки. Завдяки цьому наша команда може одночасно працювати над декількома проєктами, зосереджуючись на спеціалізованих завданнях: одні розробники займаються UI, а інші — бізнес-логікою.
Використання Headless-компонентів у React-проєктах відкриває нові можливості для розробників, надаючи їм інструменти для створення більш гнучких, ефективних та легко підтримуваних вебзастосунків.
Приклади традиційного підходу, де змішані UI та Логіка
Розгляньмо приклад типового коду компонента в React, де інтерфейс (UI) та логіка є тісно пов’язаними, і розгляньмо основні недоліки такого підходу.
import React, { useState } from 'react'; function RegistrationForm() { const [formData, setFormData] = useState({ username: '', email: '', password: '', }); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const [submissionMessage, setSubmissionMessage] = useState(''); const isValidForm = () => { const newErrors = {}; if (!formData.username) newErrors.username = 'Username is required'; if (!formData.email.includes('@')) newErrors.email = 'Email is invalid'; setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = (event) => { event.preventDefault(); if (!isValidForm()) return; setIsSubmitting(true); fetch('https://example.com/api/register', { method: 'POST', body: JSON.stringify(formData), headers: { 'Content-Type': 'application/json', }, }) .then(response => response.json()) .then(data => { setIsSubmitting(false); setSubmissionMessage(data.message); }) .catch(error => { setIsSubmitting(false); setErrors({ global: 'An unexpected error occurred.' }); }); }; return ( <form onSubmit={handleSubmit}> <div> <label>Username</label> <input type="text" value={formData.username} onChange={e => setFormData({...formData, username: e.target.value})} /> {errors.username && <p>{errors.username}</p>} </div> <div> <label>Email</label> <input type="email" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} /> {errors.email && <p>{errors.email}</p>} </div> <div> <label>Password</label> <input type="password" value={formData.password} onChange={e => setFormData({...formData, password: e.target.value})} /> {errors.password && <p>{errors.password}</p>} </div> {errors.global && <p>{errors.global}</p>} <button type="submit" disabled={isSubmitting}> {isSubmitting ? 'Submitting...' : 'Submit'} </button> {submissionMessage && <p>{submissionMessage}</p>} </form> ); }
Цей компонент ілюструє типовий сценарій, де бізнес-логіка і UI є взаємозалежними і змішані в одному компоненті.
Нумо детальніше розглянемо ключові аспекти та можливі проблеми, пов’язані з таким підходом:
- Логіка валідації: компонент містить функцію
isValidForm
, яка перевіряє поля введення щодо відповідності певним критеріям (наприклад, чи існує ім’я користувача, чи містить email символ@
). Ця валідація є критично важливою для забезпечення коректності даних перед їхнім відправленням на сервер. Однак інтеграція цієї логіки безпосередньо в компонент форми тісно пов’язує її з UI, обмежуючи її повторне використання в інших компонентах або її тестування незалежно від UI. - Управління станом форми: стан форми керується за допомогою хука
useState
, який зберігає значення введених даних і поточні помилки валідації. Поки це є звичайною практикою у React, змішення контролю станом з логікою відображення ускладнює ізоляцію бізнес-логіки від UI, що ускладнює їхнє тестування та повторне використання. - Відправлення запитів до API та обробка відповідей: компонент обробляє відправлення даних форми до API за допомогою асинхронного запиту в середині функції
handleSubmit
. Це включає керування станом для визначення процесу відправлення (isSubmitting
), а також обробку відповідей або помилок від сервера. Ця інтеграція API запитів з UI-компонентом робить код важчим для тестування, оскільки вам потрібно буде мокати HTTP-запити під час юніт-тестування, а також ускладнює повторне використання логіки відправлення даних у різних частинах застосунку.
Проблеми, з якими ми можемо стикнутися за такого підходу
Коли у нас маленький вебзастосунок, у нас немає великих проблем, оскільки в вас невелика кількість компонентів і над проєктом працює небагато розробників. Однак, коли наш застосунок починає розростатися, код роздувається до сотень стрічок і нагромаджує велику кількість компоненті, збільшується команда розробки ми можемо стикнутися з такими проблемами:
- Важкість повторного використання та ізоляції: інтеграція логіки, управління станом, відправлення даних та їхньої обробки безпосередньо в UI-компонент робить ці елементи важко повторюваними в інших частинах застосунку.
- Труднощі у тестуванні: через тісну інтеграцію з UI, тестування бізнес-логіки, такої як валідація або обробка відповідей сервера, вимагає складнішого налаштування тестового середовища, зокрема мокання компонентів і HTTP-запитів.
- Ускладнене управління станом: керування станом відповідей сервера, станом відправлення та помилками валідації в одному компоненті може призвести до «надмірної складності» компонента й ускладнити його розуміння та підтримку.
Використання Headless-компонентів для розділення цих частин може значно спростити архітектуру, покращити тестування і зробити бізнес-логіку більш гнучкою та повторно використовуваною.
Приклади Headless-підходу, де розділені UI та Логіка
Впровадження Headless компонентів в архітектуру React-застосунку дозволяє ефективно розділити логіку від його візуальної частини (UI). Це не тільки спрощує розробку та тестування, але й забезпечує більшу гнучкість у перевикористанні коду.
Розгляньмо, як можна створити та використовувати Headless-компоненти на прикладі форми реєстрації, яку ми показали в стандартному підході.
Розділення логіки з використанням Headless-компонента
Ми створимо Headless компонент, який буде відповідати за управління станом форми, валідацію даних та відправлення даних форми на сервер, відокремлюючи логіку від UI. Зазвичай такі компоненти обгортаються в хуки або HOC-компоненти.
Headless компонент: useForm
import { useState } from 'react'; function useForm(initialValues, api) { const [formData, setFormData] = useState(initialValues); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const [submissionMessage, setSubmissionMessage] = useState(''); const isValidForm = () => { const newErrors = {}; if (!formData.username) newErrors.username = 'Username is required'; if (!formData.email.includes('@')) newErrors.email = 'Email is invali setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = (event) => { event.preventDefault(); if (!isValidForm()) return; setIsSubmitting(true); fetch(api, { method: 'POST', body: JSON.stringify(formData), headers: { 'Content-Type': 'application/json', }, }) .then(response => response.json()) .then(data => { setIsSubmitting(false); setSubmissionMessage(data.message); }) .catch(error => { setIsSubmitting(false); setErrors({ global: 'An unexpected error occurred.' }); }); }; const handleChange = e => { const { name, value } = e.target; setFormData(prevState => ({ ...prevState, [name]: value })); }; return { formData, handleChange, handleSubmit, errors, isSubmitting, submissionMessage, }; }
Цей Headless-компонент useForm
тепер відповідає за всю логіку, пов’язану з формою, але не містить жодного UI.
Використання useForm у UI-компоненті
Тепер ми можемо інтегрувати наш Headless компонент useForm
в UI компонент, який буде відповідати лише за відображення форми.
import React from 'react'; import { useForm } from './useForm'; function RegistrationForm() { const { formData, handleChange, handleSubmit, errors, isSubmitting, submissionMessage, } = useForm({ username: '', email: '', password: '', }, 'https://example.com/api/register'); return ( <form onSubmit={handleSubmit}> <div> <label>Username</label> <input type="text" name="username" value={formData.username} onChange={handleChange} /> {errors.username && <p>{errors.username}</p>} </div> <div> <label>Email</label> <input type="email" name="email" value={formData.email} onChange={handleChange} /> {errors.email && <p>{errors.email}</p>} </div> <div> <label>Password</label> <input type="password" name="password" value={formData.password} onChange={handleChange} /> {errors.password && <p>{errors.password}</p>} </div> {errors.global && <p>{errors.global}</p>} <button type="submit" disabled={isSubmitting}> {isSubmitting ? 'Submitting...' : 'Submit'} </button> {submissionMessage && <p>{submissionMessage}</p>} </form> ); }
Таке розділення дозволяє нам більш чітко управляти логікою та UI нашого компонента, а також полегшує тестування та повторне використання коду.
Практичні приклади
Реалізація Headless компонентів може суттєво покращити архітектуру застосунку. Розглянемо кілька типових практичних прикладів використання Headless компонентів для різних сценаріїв у React-застосунку, які найчастіше можна побачити в застосунках з Headless-підходом до написання компонентів.
1. Headless-форма
Ми вже розглянули приклад створення форми з використанням Headless-компоненту. Цей підхід дозволяє ізолювати логіку валідації, управління станом та відправлення форми від її UI-частини. Код стає більш читабельним та легким для повторного використання. Форми зазвичай громісткі та містять в собі багато складної логіки, тому їхню її (логіку) часто виносять в окремий Headless-компонент.
2. Headless-список
Припустимо, ми хочемо створити компонент для відображення списку елементів з можливістю вибору елемента. Використання Headless-компоненту дозволяє відокремити логіку управління вибором елемента списку від самого відображення списку.
function useSelectableList(items) { const [selectedItem, setSelectedItem] = useState(null); const selectItem = (item) => setSelectedItem(item); return { selectedItem, selectItem, items }; } // Використання в UI компоненті function SelectableList({ items }) { const { selectedItem, selectItem, items } = useSelectableList(items); return ( <ul> {listItems.map(item => ( <li key={item.id} className={selectedItem === item ? 'selected' : ''} onClick={() => selectItem(item)} > {item.name} </li> ))} </ul> ); }
3. Headless Модальне вікно
Модальне вікно, яке ми можемо показувати або приховувати, є ще одним ідеальним прикладом для реалізації. Такий підхід дозволяє нам один раз створити такий компонент і реюзати його у всьому застосунку.
function useModal() { const [isVisible, setIsVisible] = useState(false); const show = () => setIsVisible(true); const hide = () => setIsVisible(false); return { isVisible, show, hide }; } // Використання в UI компоненті function Modal({ children }) { const { isVisible, show, hide } = useModal(); if (!isVisible) return null; return ( <div> <button onClick={show}>Open Modal</button> <div className="modal"> <button onClick={hide}>Close</button> {children} </div> </div> ); }
4. Headless-пошук
Компонент для пошуку, який можна використовувати для фільтрації списку елементів за заданим критерієм. Він відокремлює логіку пошуку від UI та дозволяє легко інтегрувати пошук в будь-яку частину застосунку: таблиці, різного роду списки та інше.
function useSearch(items) { const [query, setQuery] = useState(''); const [filteredItems, setFilteredItems] = useState(items); useEffect(() => { const result = items.filter(item => item.toLowerCase().includes(query.toLowerCase()) ); setFilteredItems(result); }, [items, query]); return { query, setQuery, filteredItems }; } // Використання в UI компоненті function SearchableList({ items }) { const { query, setQuery, filteredItems } = useSearch(items); return ( <div> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> <ul> {filteredItems.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> </div> ); }
5. Headless-таби
Компонент табів, який дозволяє перемикатися між різними контентами без привʼязки до конкретної структури UI. Це хороший приклад використання Headless-компонентів, адже UI самих табів може суттєво відрізнятися в різних місцях нашого застосунку, водночас логіка залишається однаковою.
function useTabs(tabs) { const [activeTab, setActiveTab] = useState(tabs[0].name); const switchTab = (tabName) => setActiveTab(tabName); return { activeTab, switchTab, tabs }; } // Використання в UI компоненті function TabsComponent({ tabs }) { const { activeTab, switchTab, tabs: tabItems } = useTabs(tabs); return ( <div> <div className="tabs"> {tabItems.map((tab) => ( <button key={tab.name} className={activeTab === tab.name ? 'active' : ''} onClick={() => switchTab(tab.name)} > {tab.label} </button> ))} </div> <div className="tab-content"> {tabItems.map((tab) => activeTab === tab.name ? <div key={tab.name}>{tab.content}</div> : null )} </div> </div> ); }
6. Headless Product Card
Це складніший приклад з реального проєкту: створення продуктової картки (Product Card), яка містить різну логіку: управління станом вибраного продукту, обробку подій додавання в кошик, можливість відображення детальної інформації про продукт. Використання Headless компоненту дозволить ізолювати всю цю логіку від самого UI-компонента.
Першим кроком створюємо Headless-компонент, який міститиме всю логіку продуктової картки.
import { useState } from 'react'; function useProductCard(product) { const [isSelected, setIsSelected] = useState(false); const [quantity, setQuantity] = useState(1); const toggleSelection = () => setIsSelected(!isSelected); const incrementQuantity = () => setQuantity(quantity + 1); const decrementQuantity = () => setQuantity(quantity > 1 ? quantity - 1 : 1); const addToCart = () => { const productData = { id: product.id, quantity: quantity, }; fetch('https://example.com/api/add-to-cart', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(productData), }) .then(response => response.json()) .then(data => { console.log('Product added to cart:', data); }) .catch(error => { console.error('Error adding product to cart:', error); }); console.log(`Added ${quantity} of ${product.name} to cart.`); }; return { isSelected, quantity, toggleSelection, incrementQuantity, decrementQuantity, addToCart, product }; }
Тепер, коли створили Headless-компонент з усією необхідною логікою, переходимо до UI-компонента, який використає цю логіку незалежно від його UI-частини.
function ProductCard({ product }) { const { isSelected, quantity, toggleSelection, incrementQuantity, decrementQuantity, addToCart, product: productData } = useProductCard(product); return ( <div className={`product-card ${isSelected ? 'selected' : ''}`} onClick={toggleSelection}> <h3>{productData.name}</h3> <p>{productData.description}</p> <div> <button onClick={decrementQuantity}>-</button> <span>{quantity}</span> <button onClick={incrementQuantity}>+</button> </div> <button onClick={addToCart}>Add to Cart</button> </div> ); return ( <div className={`product-card ${isSelected ? 'selected' : ''}`} onClick={toggleSelection} data-testid="product-card" > <h3>{product.name}</h3> <p>{product.description}</p> <div> <button onClick={decrementQuantity()} data-testid="decrement-button">-</button> <span data-testid="quantity-display">{quantity}</span> <button onClick={incrementQuantity()} data-testid="increment-button">+</button> </div> <button onClick={handleAddToCart()} data-testid="add-to-cart-button">Add to Cart</button> </div> ); }
Цей підхід дозволяє нам чітко розділити логіку та UI. Це забезпечує високий рівень повторного використання та легкість у тестуванні компонентів. Компонент useProductCard
може бути легко інтегрований з різними UI без змін в самій логіці, тоді як компонент ProductCard
може бути адаптований під будь-який дизайн, зберігаючи чистоту та читабельність коду.
Тестування Headless-компонентів
Додавання тестів для Headless компонента та відповідного UI-компонента дозволить переконатися в правильності роботи логіки та взаємодії з користувачем. Для тестування використаємо бібліотеку тестування React, наприклад, Jest разом із React Testing Library для імітації взаємодії з UI.
Тестування Headless компонента (useProductCard)
import { renderHook, act } from '@testing-library/react-hooks'; import useProductCard from './useProductCard'; // Мок fetch запиту перед усіма тестами global.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve({ message: 'Product added successfully' }), }) ); describe('useProductCard', () => { beforeEach(() => { // Очищення моків перед кожним тестом fetch.mockClear(); }); it('allows toggling selection state', async () => { const { result } = renderHook(() => useProductCard({ name: 'Test Product', description: 'Description here' })); // Перевірка початкового стану expect(result.current.isSelected).toBeFalsy(); // Тогл вибраного стану act(() => { result.current.toggleSelection(); }); // Перевірка зміненого стану expect(result.current.isSelected).toBeTruthy(); }); it('handles quantity changes correctly', async () => { const { result } = renderHook(() => useProductCard({ name: 'Test Product' })); // Збільшення кількості act(() => { result.current.incrementQuantity(); }); expect(result.current.quantity).toBe(2); // Зменшення кількості act(() => { result.current.decrementQuantity(); }); expect(result.current.quantity).toBe(1); }); it('sends product data to server on add to cart', async () => { const product = { id: '123', name: 'Test Product' }; const { result, waitForNextUpdate } = renderHook(() => useProductCard(product)); act(() => { result.current.addToCart(); }); await waitForNextUpdate(); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith('https://example.com/api/add-to-cart', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: product.id, quantity: result.current.quantity }), }); }); });
Тестування UI компонента (ProductCard)
import { render, screen, fireEvent } from '@testing-library/react'; import ProductCard from './ProductCard'; describe('ProductCard', () => { const product = { id: '1', name: 'Test Product', description: 'Description here', price: '$10.00' }; it('renders product information', () => { render(<ProductCard product={product} />); expect(screen.getByText('Test Product')).toBeInTheDocument(); expect(screen.getByText('Description here')).toBeInTheDocument(); expect(screen.getByText('$10.00')).toBeInTheDocument(); }); it('toggle selection on click', () => { render(<ProductCard product={product} />); const card = screen.getByTestId('product-card'); fireEvent.click(card); // Перевіряємо, чи стан вибраності змінився. // Припустимо, що ми маємо клас `selected` для вибраних карт. expect(card).toHaveClass('selected'); }); it('handles increment and decrement buttons', () => { render(<ProductCard product={product} />); const incrementButton = screen.getByTestId('increment-button'); const decrementButton = screen.getByTestId('decrement-button'); const quantityDisplay = screen.getByTestId('quantity-display'); fireEvent.click(incrementButton); expect(quantityDisplay).toHaveTextContent('2'); // Припускаючи, що початкова кількість - 1 fireEvent.click(decrementButton); expect(quantityDisplay).toHaveTextContent('1'); // Повернення до початкової кількості }); it('shows add to cart button and handles click', () => { const mockAddToCartHandler = jest.fn(); render(<ProductCard product={product} onAddToCart={mockAddToCartHandler} />); const addToCartButton = screen.getByText('Add to Cart'); fireEvent.click(addToCartButton); expect(mockAddToCartHandler).toHaveBeenCalledWith(product.id); }); });
Ці приклади тестів демонструють базове тестування логіки та взаємодії в компоненті. Вони забезпечують певний рівень впевненості в коректності роботи компонентів.
Тестування Headless компонентів окремо від UI дозволяє зосередитися на логіці без необхідності враховувати особливості UI, що спрощує написання та підтримку тестів і дозволяє нам писати різного роду тести під UI та бізнес-логіку використовуючи різні підходи та бібліотеки.
Використання Storybook з Headless-компонентами
Ми використовуємо на проєкті є Storybook. Він підвищує ефективність всієї команди, дозволяє паралельно розробляти рішення для декількох клієнтів одночасно, розділивши роботу над UI та бізнес частинами застосунків. А також це дозволяє швидко залучати QA-команду для тестування UI-частини, не чекаючи, коли будуть готові цілі сторінки або великі частини функціоналу.
Що таке Storybook
Storybook — це інтерактивне середовище для розробки та тестування UI-компонентів. Воно дозволяє розробникам ізолювати кожен компонент та переглядати всі його стани в одному інтерфейсі, спрощуючи процес розробки та поліпшуючи якість коду.
Використання з Headless-підходом
Storybook ідеально підходить для роботи з Headless-компонентами, оскільки дозволяє розробникам фокусуватися на логіці компонента, водночас маючи можливість легко інтегрувати цю логіку з різними варіантами UI і запаралелити роботу команди.
Приклад з Headless-компонентом
Розглянемо приклад на основі попереднього useForm
hook та useProductCard
компонента. Ми можемо створити stories в Storybook для кожного з цих компонентів, щоб побачити UI, різні стани та варіанти використання. Я використаю UI бібліотеку ChakraUI, щоб компоненти були візуально більш красивіші.
Stories для Form
import React from 'react'; import { useForm } from './Form'; import { Input, Button, FormControl, FormLabel } from '@chakra-ui/react'; export default { title: 'Component/Form', component: useForm, }; const Template = (args) => { const { formData, handleChange, handleSubmit } = useForm({ username: '', email: '', }); return ( <form onSubmit={handleSubmit}> <FormControl id="username" isRequired> <FormLabel>Username</FormLabel> <Input name="username" value={formData.username || ''} onChange={handleChange} placeholder="Username" /> </FormControl> <FormControl id="email" isRequired mt={4}> <FormLabel>Email</FormLabel> <Input name="email" value={formData.email || ''} onChange={handleChange} placeholder="Email" /> </FormControl> <Button mt={4} colorScheme="teal" type="submit"> Submit </Button> </form> ); }; export const DefaultUseForm = Template.bind({});
Stories для ProductCard
import React from 'react'; import { Box, Image, Text, Button, Flex, IconButton, useColorModeValue, VStack } from '@chakra-ui/react'; import { MdAddShoppingCart, MdRemove, MdAdd } from 'react-icons/md'; import { useProductCard } from './useProductCard'; export default { title: 'Components/ProductCard', }; export const DefaultProductCard = (args) => { const simulatedProduct = { name: args.title, ...args }; const { incrementQuantity, decrementQuantity, quantity, addToCart } = useProductCard(simulatedProduct); return ( <Box maxW="sm" borderWidth="1px" borderRadius="lg" overflow="hidden"> <Image src={args.imageUrl} alt={`Image of ${args.title}`} height={270}/> <Box p="6"> <Box display="flex" alignItems="baseline"> <Text mt="1" fontWeight="semibold" as="h4" lineHeight="tight" isTruncated> {args.title} </Text> </Box> <Text mt="2" color="gray.500"> {args.description} </Text> <Flex mt="3" justifyContent="space-between" alignItems="center"> <Button colorScheme="pink" size="sm" onClick={decrementQuantity}>-</Button> <Text>{quantity}</Text> <Button colorScheme="green" size="sm" onClick={incrementQuantity}>+</Button> </Flex> <Button mt="3" colorScheme="teal" size="sm" onClick={addToCart}> Add to cart </Button> </Box> </Box> ); }; export const SecondProductCard = (args) => { const simulatedProduct = { name: args.title, ...args }; const { incrementQuantity, decrementQuantity, quantity, addToCart } = useProductCard(simulatedProduct); const bgColor = useColorModeValue('gray.50', 'gray.800'); const textColor = useColorModeValue('gray.800', 'white'); return ( <Box position="relative" maxW="sm" overflow="hidden" borderRadius="lg"> <Image src={args.imageUrl} alt={`Image of ${args.title}`} width="100%" height="auto" objectFit="cover" /> <Box position="absolute" top="0" right="0" p="2"> <IconButton icon={<MdAddShoppingCart />} isRound={true} colorScheme="teal" onClick={addToCart} aria-label="Add to cart" /> </Box> <Flex position="absolute" top="0" left="0" alignItems="center" justifyContent="center" gap="2" p="2"> <IconButton icon={<MdRemove />} aria-label="Decrement quantity" onClick={decrementQuantity} size="sm" isRound={true} colorScheme="pink" /> <Text color="white" fontSize="sm">{quantity}</Text> <IconButton icon={<MdAdd />} aria-label="Increment quantity" onClick={incrementQuantity} size="sm" isRound={true} colorScheme="green" /> </Flex> <Box p="6" bg={bgColor} color={textColor} mt="-4"> <VStack align="start"> <Text fontWeight="bold" as="h4" lineHeight="tight" isTruncated> {args.title} </Text> <Text>{args.description}</Text> </VStack> </Box> </Box> ); }; DefaultProductCard.args = { title: "Example Product", description: "This is a sample product description.", imageUrl: "https://images.unsplash.com/photo-1627384113743-6bd5a479fffd?q=80&w=3270&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D.jpeg", } SecondProductCard.args = { title: "Example Product", description: "This is a sample product description.", imageUrl: "https://images.unsplash.com/photo-1523275335684-37898b6baf30?q=80&w=3398&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D.jpeg", }
У цьому прикладі ми створили дві stories: одна для нашого useForm
hook, який демонструє його використання в стандартній формі, та інша для useProductCard
, де можемо одразу побачити його застосування та показати різні варіанти відображення UI продукту, використовуючи одну і ту саму логіку. Використання Storybook дозволяє нам ізолювати та тестувати ці компоненти в різних сценаріях.
Підсумки та рекомендації
Під час роботи з Headless компонентами важливо слідувати кільком ключовим принципам та практикам, щоб максимізувати їхню ефективність та забезпечити високу якість коду.
Best practices
Чітке розділення логіки та UI: зберігайте бізнес-логіку та управління станом в Headless-компонентах відокремлено від UI, тому що це полегшує повторне використання та тестування коду.
Використання хуків: React-хуки дозволяють створювати потужні та гнучкі Headless-компоненти з мінімальними зусиллями.
Тестування логіки незалежно від UI: використовуйте unit-тести для перевірки бізнес-логіки в Headless-компонентах, відокремлюючи її від UI-тестів.
Оптимізація для перевикористання: під час проєктування Headless-компонентів зосереджуйтеся на створенні загальних рішень, які можуть бути легко адаптовані та повторно використані в різних частинах вашого застосунку.
Рекомендації щодо тестування та оптимізації
Використовуйте ізоляцію компонентів для тестування: тестуйте Headless-компоненти в ізоляції від UI, щоб переконатися в коректності їхньої логіки.
Оптимізуйте рендеринг: мінімізуйте залежності в хуках та оптимізуйте рендеринг за допомогою React.memo та useCallback
для покращення продуктивності.
Документуйте компоненти: документуйте API та поведінку ваших Headless-компонентів, щоб полегшити їхнє повторне використання та інтеграцію розробниками.
Висновки
Розділення UI та логіки за допомогою Headless-компонентів у React забезпечує гнучкість, полегшує тестування та підтримку коду, а також сприяє його повторному використанню. Ця практика дозволяє розробникам ефективніше керувати складністю застосунків, пропонуючи в той же час можливість для креативності та інновацій в дизайні UI.
Ми розглянули кілька прикладів, що демонструють переваги використання Headless-компонентів, та надали рекомендації щодо їхнього ефективного застосування. Завжди потрібно експериментувати із цим підходом у своїх проєктах, використовуючи наведені ідеї та техніки для створення більш масштабованих, ефективних та з хорошим користувацьким інтерфейсом застосунків.
Корисні Ресурси
- React Documentation: офіційна документація React дає знання про хуки, компоненти вищого порядку (HOCs), та інші концепції React.
- Testing Library: Testing Library пропонує інструменти для тестування React-компонентів у більш природний спосіб.
- Storybook: Storybook є потужним інструментом для розробки UI-компонентів в ізоляції та документації.
12 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів