Глибокий аналіз архітектури компонентів React, порівняння композиції та успадкування. Дізнайтеся, чому React надає перевагу композиції, та вивчіть патерни, такі як HOC, Render Props і хуки для створення масштабованих, повторно використовуваних компонентів.
Архітектура React-компонентів: Чому композиція перемагає успадкування
У світі розробки програмного забезпечення архітектура має першорядне значення. Те, як ми структуруємо наш код, визначає його масштабованість, підтримуваність та можливість повторного використання. Для розробників, що працюють з React, одне з найфундаментальніших архітектурних рішень стосується того, як ділитися логікою та UI між компонентами. Це приводить нас до класичної дискусії в об'єктно-орієнтованому програмуванні, переосмисленої для компонентного світу React: Композиція проти Успадкування.
Якщо ви маєте досвід роботи з класичними об'єктно-орієнтованими мовами, такими як Java або C++, успадкування може здатися природним першим вибором. Це потужна концепція для створення зв'язків 'is-a' ('є'). Однак офіційна документація React дає чітку та рішучу рекомендацію: "У Facebook ми використовуємо React у тисячах компонентів, і ми не знайшли жодного випадку, де б ми рекомендували створювати ієрархії успадкування компонентів."
Ця стаття надасть всебічне дослідження цього архітектурного вибору. Ми розберемо, що означають успадкування та композиція в контексті React, продемонструємо, чому композиція є ідіоматичним і кращим підходом, та дослідимо потужні патерни — від компонентів вищого порядку до сучасних хуків — які роблять композицію найкращим другом розробника для створення надійних і гнучких застосунків для глобальної аудиторії.
Розуміння старої гвардії: Що таке успадкування?
Успадкування — це основний стовп об'єктно-орієнтованого програмування (ООП). Воно дозволяє новому класу (підкласу або нащадку) набувати властивостей і методів існуючого класу (суперкласу або батька). Це створює тісно пов'язаний зв'язок 'is-a' ('є'). Наприклад, a GoldenRetriever
є Dog
(Собака), який є Animal
(Тварина).
Успадкування поза контекстом React
Розгляньмо простий приклад класу JavaScript, щоб закріпити концепцію:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} видає звук.`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // Викликає конструктор батьківського класу
this.breed = breed;
}
speak() { // Перевизначає метод батьківського класу
console.log(`${this.name} гавкає.`);
}
fetch() {
console.log(`${this.name} приносить м'яч!`);
}
}
const myDog = new Dog('Бадді', 'Золотистий ретривер');
myDog.speak(); // Вивід: "Бадді гавкає."
myDog.fetch(); // Вивід: "Бадді приносить м'яч!"
У цій моделі клас Dog
автоматично отримує властивість name
та метод speak
від Animal
. Він також може додавати власні методи (fetch
) та перевизначати існуючі. Це створює жорстку ієрархію.
Чому успадкування дає збій у React
Хоча ця модель 'is-a' працює для деяких структур даних, вона створює значні проблеми при застосуванні до UI-компонентів у React:
- Тісний зв'язок: Коли компонент успадковується від базового компонента, він стає тісно пов'язаним з реалізацією свого батька. Зміна в базовому компоненті може несподівано зламати кілька дочірніх компонентів по ланцюжку. Це робить рефакторинг та обслуговування крихким процесом.
- Негнучке спільне використання логіки: Що, як ви хочете поділитися певною частиною функціональності, наприклад, отриманням даних, з компонентами, які не вписуються в ту саму ієрархію 'is-a'? Наприклад,
UserProfile
таProductList
можуть потребувати отримання даних, але немає сенсу, щоб вони успадковувалися від спільногоDataFetchingComponent
. - Пекло прокидання пропсів (Prop-Drilling Hell): У глибокому ланцюжку успадкування стає важко передавати пропси від компонента верхнього рівня до глибоко вкладеного дочірнього. Вам, можливо, доведеться передавати пропси через проміжні компоненти, які їх навіть не використовують, що призводить до заплутаного та роздутого коду.
- Проблема "горили та банана": Відома цитата експерта з ООП Джо Армстронга ідеально описує цю проблему: "Ви хотіли банан, а отримали горилу, яка тримає банан, і цілі джунглі." З успадкуванням ви не можете просто отримати потрібну частину функціональності; ви змушені тягнути за собою весь суперклас.
Через ці проблеми команда React розробила бібліотеку навколо більш гнучкої та потужної парадигми: композиції.
Прийняття шляху React: Сила композиції
Композиція — це принцип проєктування, який надає перевагу зв'язкам 'has-a' ('має') або 'uses-a' ('використовує'). Замість того, щоб компонент був іншим компонентом, він має інші компоненти або використовує їхню функціональність. Компоненти розглядаються як будівельні блоки — наче цеглинки LEGO — які можна комбінувати різними способами для створення складних UI, не будучи замкненими в жорсткій ієрархії.
Композиційна модель React неймовірно універсальна, і вона проявляється в кількох ключових патернах. Розгляньмо їх, від найпростіших до найсучасніших та найпотужніших.
Техніка 1: Вкладення за допомогою props.children
Найпростіша форма композиції — це вкладення. Це коли компонент діє як загальний контейнер або "коробка", а його вміст передається від батьківського компонента. React має для цього спеціальний вбудований проп: props.children
.
Уявіть, що вам потрібен компонент `Card`, який може обгортати будь-який вміст однаковою рамкою та тінню. Замість того, щоб створювати варіанти `TextCard`, `ImageCard` та `ProfileCard` через успадкування, ви створюєте один загальний компонент `Card`.
// Card.js - Загальний компонент-контейнер
function Card(props) {
return (
<div className="card">
{props.children}
</div>
);
}
// App.js - Використання компонента Card
function App() {
return (
<div>
<Card>
<h1>Ласкаво просимо!</h1>
<p>Цей контент знаходиться всередині компонента Card.</p>
</Card>
<Card>
<img src="/path/to/image.jpg" alt="Приклад зображення" />
<p>Це картка із зображенням.</p>
</Card>
</div>
);
}
Тут компонент Card
не знає і не дбає про те, що він містить. Він просто надає стилі обгортки. Вміст між відкриваючим та закриваючим тегами <Card>
автоматично передається як props.children
. Це прекрасний приклад розділення відповідальності та повторного використання.
Техніка 2: Спеціалізація за допомогою пропсів
Іноді компоненту потрібно, щоб кілька "отворів" були заповнені іншими компонентами. Хоча ви можете використовувати props.children
, більш явним і структурованим способом є передача компонентів як звичайних пропсів. Цей патерн часто називають спеціалізацією.
Розгляньмо компонент Modal
. Модальне вікно зазвичай має секцію заголовка, секцію вмісту та секцію дій (з кнопками "Підтвердити" або "Скасувати"). Ми можемо спроєктувати наш Modal
так, щоб він приймав ці секції як пропси.
// Modal.js - Більш спеціалізований контейнер
function Modal(props) {
return (
<div className="modal-backdrop">
<div className="modal-content">
<div className="modal-header">{props.title}</div>
<div className="modal-body">{props.body}</div>
<div className="modal-footer">{props.actions}</div>
</div>
</div>
);
}
// App.js - Використання Modal з конкретними компонентами
function App() {
const confirmationTitle = <h2>Підтвердити дію</h2>;
const confirmationBody = <p>Ви впевнені, що хочете виконати цю дію?</p>;
const confirmationActions = (
<div>
<button>Підтвердити</button>
<button>Скасувати</button>
</div>
);
return (
<Modal
title={confirmationTitle}
body={confirmationBody}
actions={confirmationActions}
/>
);
}
У цьому прикладі Modal
є компонентом розмітки з високим ступенем повторного використання. Ми спеціалізуємо його, передаючи конкретні JSX-елементи для його `title`, `body` та `actions`. Це набагато гнучкіше, ніж створювати підкласи `ConfirmationModal` та `WarningModal`. Ми просто компонуємо Modal
з різним вмістом за потреби.
Техніка 3: Компоненти вищого порядку (HOC)
Для спільного використання не-UI логіки, такої як отримання даних, автентифікація або логування, розробники React історично зверталися до патерну, що називається Компоненти вищого порядку (HOC). Хоча в сучасному React вони значною мірою замінені хуками, важливо їх розуміти, оскільки вони представляють ключовий еволюційний крок в історії композиції React і все ще існують у багатьох кодових базах.
HOC — це функція, яка приймає компонент як аргумент і повертає новий, розширений компонент.
Створімо HOC під назвою `withLogger`, який логує пропси компонента щоразу, коли він оновлюється. Це корисно для налагодження.
// withLogger.js - HOC
import React, { useEffect } from 'react';
function withLogger(WrappedComponent) {
// Він повертає новий компонент...
return function EnhancedComponent(props) {
useEffect(() => {
console.log('Компонент оновлено з новими пропсами:', props);
}, [props]);
// ... який рендерить оригінальний компонент з оригінальними пропсами.
return <WrappedComponent {...props} />;
};
}
// MyComponent.js - Компонент для розширення
function MyComponent({ name, age }) {
return (
<div>
<h1>Привіт, {name}!</h1>
<p>Вам {age} років.</p>
</div>
);
}
// Експорт розширеного компонента
export default withLogger(MyComponent);
Функція `withLogger` обгортає `MyComponent`, надаючи йому нові можливості логування без зміни внутрішнього коду `MyComponent`. Ми могли б застосувати цей самий HOC до будь-якого іншого компонента, щоб надати йому таку ж функцію логування.
Проблеми з HOC:
- Пекло обгорток (Wrapper Hell): Застосування кількох HOC до одного компонента може призвести до глибоко вкладених компонентів у React DevTools (наприклад, `withAuth(withRouter(withLogger(MyComponent)))`), що ускладнює налагодження.
- Колізії імен пропсів: Якщо HOC впроваджує проп (наприклад, `data`), який вже використовується обгорнутим компонентом, він може бути випадково перезаписаний.
- Неявна логіка: Не завжди з коду компонента зрозуміло, звідки надходять його пропси. Логіка прихована всередині HOC.
Техніка 4: Render Props
Патерн Render Prop з'явився як рішення деяких недоліків HOC. Він пропонує більш явний спосіб спільного використання логіки.
Компонент з render prop приймає функцію як проп (зазвичай з назвою `render`) і викликає цю функцію, щоб визначити, що рендерити, передаючи їй будь-який стан або логіку як аргументи.
Створімо компонент MouseTracker
, який відстежує координати X та Y миші та робить їх доступними для будь-якого компонента, що бажає їх використовувати.
// MouseTracker.js - Компонент з render prop
import React, { useState, useEffect } from 'react';
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = (event) => {
setPosition({ x: event.clientX, y: event.clientY });
};
useEffect(() => {
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
// Викликаємо функцію рендерингу зі станом
return render(position);
}
// App.js - Використання MouseTracker
function App() {
return (
<div>
<h1>Рухайте мишкою!</h1>
<MouseTracker
render={mousePosition => (
<p>Поточна позиція миші: ({mousePosition.x}, {mousePosition.y})</p>
)}
/>
</div>
);
}
Тут MouseTracker
інкапсулює всю логіку відстеження руху миші. Він нічого не рендерить самостійно. Натомість він делегує логіку рендерингу своєму пропу `render`. Це більш явно, ніж HOC, оскільки ви можете бачити, звідки саме надходять дані `mousePosition` прямо в JSX.
Проп children
також можна використовувати як функцію, що є поширеним та елегантним варіантом цього патерну:
// Використання children як функції
<MouseTracker>
{mousePosition => (
<p>Поточна позиція миші: ({mousePosition.x}, {mousePosition.y})</p>
)}
</MouseTracker>
Техніка 5: Хуки (сучасний та рекомендований підхід)
Представлені в React 16.8, хуки революціонізували спосіб написання React-компонентів. Вони дозволяють використовувати стан та інші можливості React у функціональних компонентах. Найголовніше, кастомні хуки надають найбільш елегантне та пряме рішення для спільного використання логіки зі станом між компонентами.
Хуки вирішують проблеми HOC та Render Props набагато чистішим способом. Давайте переробимо наш приклад MouseTracker
у кастомний хук під назвою useMousePosition
.
// hooks/useMousePosition.js - Кастомний хук
import { useState, useEffect } from 'react';
export function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (event) => {
setPosition({ x: event.clientX, y: event.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []); // Порожній масив залежностей означає, що цей ефект виконується лише один раз
return position;
}
// DisplayMousePosition.js - Компонент, що використовує хук
import { useMousePosition } from './hooks/useMousePosition';
function DisplayMousePosition() {
const position = useMousePosition(); // Просто викликаємо хук!
return (
<p>
Позиція миші: ({position.x}, {position.y})
</p>
);
}
// Інший компонент, можливо, інтерактивний елемент
import { useMousePosition } from './hooks/useMousePosition';
function InteractiveBox() {
const { x, y } = useMousePosition();
const style = {
position: 'absolute',
top: y - 25, // Центруємо блок на курсорі
left: x - 25,
width: '50px',
height: '50px',
backgroundColor: 'lightblue',
};
return <div style={style} />;
}
Це величезне покращення. Немає "пекла обгорток", немає колізій імен пропсів і немає складних функцій render prop. Логіка повністю відокремлена в функцію, що повторно використовується (`useMousePosition`), і будь-який компонент може "підключитися" до цієї логіки зі станом за допомогою одного чіткого рядка коду. Кастомні хуки є найвищим проявом композиції в сучасному React, що дозволяє створювати власну бібліотеку логічних блоків для повторного використання.
Коротке порівняння: Композиція проти успадкування в React
Щоб узагальнити ключові відмінності в контексті React, ось пряме порівняння:
Аспект | Успадкування (антипатерн у React) | Композиція (рекомендовано в React) |
---|---|---|
Відносини | Відносини 'is-a' ('є'). Спеціалізований компонент є версією базового компонента. | Відносини 'has-a' ('має') або 'uses-a' ('використовує'). Складний компонент має менші компоненти або використовує спільну логіку. |
Зв'язність | Висока. Дочірні компоненти тісно пов'язані з реалізацією свого батька. | Низька. Компоненти є незалежними та можуть бути повторно використані в різних контекстах без модифікації. |
Гнучкість | Низька. Жорсткі, класові ієрархії ускладнюють спільне використання логіки між різними деревами компонентів. | Висока. Логіку та UI можна комбінувати та повторно використовувати безліччю способів, як будівельні блоки. |
Повторне використання коду | Обмежене попередньо визначеною ієрархією. Ви отримуєте цілу "горилу", коли вам потрібен лише "банан". | Відмінне. Маленькі, сфокусовані компоненти та хуки можна використовувати у всьому застосунку. |
Ідіома React | Не рекомендується офіційною командою React. | Рекомендований та ідіоматичний підхід для створення застосунків на React. |
Висновок: Мисліть композиційно
Дебати між композицією та успадкуванням є фундаментальною темою в дизайні програмного забезпечення. Хоча успадкування має своє місце в класичному ООП, динамічна, компонентна природа розробки UI робить його поганим вибором для React. Бібліотека була фундаментально розроблена для застосування композиції.
Надаючи перевагу композиції, ви отримуєте:
- Гнучкість: Можливість змішувати та поєднувати UI та логіку за потребою.
- Підтримуваність: Слабо зв'язані компоненти легше розуміти, тестувати та рефакторити ізольовано.
- Масштабованість: Композиційне мислення заохочує створення дизайн-системи з невеликих, повторно використовуваних компонентів та хуків, які можна використовувати для ефективного створення великих, складних застосунків.
Як глобальному розробнику React, оволодіння композицією — це не просто дотримання найкращих практик, а розуміння основної філософії, яка робить React таким потужним та продуктивним інструментом. Почніть зі створення невеликих, сфокусованих компонентів. Використовуйте props.children
для загальних контейнерів та пропси для спеціалізації. Для спільного використання логіки в першу чергу звертайтеся до кастомних хуків. Мислячи композиційно, ви будете на правильному шляху до створення елегантних, надійних та масштабованих React-застосунків, які витримають випробування часом.