النمط المركّب
في تطبيقنا، لدينا غالبًا مكوّنات (components) تخصّ بعضها بعضًا. فهي تعتمد على بعضها عبر الحالة (state) المشتركة، وتتشارك المنطق معًا. ونرى هذا غالبًا في مكوّنات مثل select، ومكوّنات القوائم المنسدلة (dropdown)، أو عناصر القوائم. يتيح لك نمط المكوّنات المركّبة (compound component pattern) إنشاء مكوّنات تعمل جميعها معًا لإنجاز مهمة واحدة.
واجهة السياق (Context API)
لننظر إلى مثال: لدينا قائمة من صور سنجاب! إلى جانب عرض صور السنجاب فقط، نريد إضافة زر يتيح للمستخدم تحرير الصورة أو حذفها. يمكننا تنفيذ مكوّن FlyOut يعرض قائمة عندما يبدّل المستخدم حالة المكوّن.
داخل مكوّن FlyOut لدينا أساسًا ثلاثة أشياء:
- غلاف
FlyOut، الذي يحتوي زر التبديل والقائمة - زر
Toggle، الذي يبدّل حالةList List، التي تحتوي على قائمة عناصر القائمة
استخدام نمط المكوّنات المركّبة مع واجهة Context API في React مثالي لهذا المثال!
أولًا، لنُنشئ مكوّن FlyOut. هذا المكوّن يحتفظ بالحالة، ويُعيد FlyOutProvider يحمل قيمة التبديل إلى كل الأبناء الذين يتلقاهم.
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
);
}
لدينا الآن مكوّن FlyOut ذو حالة يمرّر قيمتَي open وtoggle إلى أبنائه!
لنُنشئ الآن مكوّن Toggle. هذا المكوّن يعرض ببساطة العنصر الذي يمكن للمستخدم النقر عليه لتبديل حالة القائمة.
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
ولكي نمنح Toggle فعليًا وصولًا إلى مزوّد FlyOutContext، علينا عرضه كابن للمكوّن FlyOut! -يمكننا ببساطة عرضه كابن للمكوّن. لكن يمكننا أيضًا جعل المكوّن Toggle خاصية (property) من المكوّن FlyOut!*
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
);
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
FlyOut.Toggle = Toggle;
هذا يعني أنه إذا أردنا استخدام المكوّن FlyOut في أي ملف، فلن نضطر إلا إلى استيراد FlyOut!
import React from "react";
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
</FlyOut>
);
}
مجرد زر تبديل لا يكفي. نحتاج أيضًا إلى List تحتوي على عناصر قائمة، تُفتح وتُغلق بناءً على قيمة open.
function List({ children }) {
const { open } = React.useContext(FlyOutContext);
return open && <ul>{children}</ul>;
}
function Item({ children }) {
return <li>{children}</li>;
}
يعرض مكوّن List أبناءه بناءً على ما إذا كانت قيمة open هي true أو false. لنجعل List وItem خاصيتين من المكوّن FlyOut، تمامًا كما فعلنا مع المكوّن Toggle.
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
);
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
function List({ children }) {
const { open } = useContext(FlyOutContext);
return open && <ul>{children}</ul>;
}
function Item({ children }) {
return <li>{children}</li>;
}
FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
يمكننا الآن استخدامها كخصائص على المكوّن FlyOut! وفي حالتنا هذه نريد عرض خيارين للمستخدم: تعديل (Edit) وحذف (Delete). لنُنشئ FlyOut.List يعرض مكوّنَي FlyOut.Item، أحدهما لخيار تعديل (Edit) والآخر لخيار حذف (Delete).
import React from "react";
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
);
}
ممتاز! لقد أنشأنا للتو مكوّن FlyOut كاملًا من دون إضافة أي حالة (state) في FlyOutMenu نفسه!
index.js · FlyOut.js · FlyoutMenu.js · Images.js
import React from "react";
import "./styles.css";
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
);
}
النمط المركّب ممتاز عندما تبني مكتبة مكوّنات. وسترى هذا النمط كثيرًا عند استخدام مكتبات واجهة المستخدم مثل Semantic UI.
React.Children.map
يمكننا أيضًا تنفيذ نمط المكوّنات المركّبة عبر المرور على أبناء المكوّن (mapping over). يمكننا إضافة قيمتَي open وtoggle إلى هذه العناصر، عبر استنساخها (cloning) مع الخصائص (props) الإضافية.
export function FlyOut(props) {
const [open, toggle] = React.useState(false);
return (
<div>
{React.Children.map(props.children, (child) =>
React.cloneElement(child, { open, toggle })
)}
</div>
);
}
يتم استنساخ جميع المكوّنات الفرعية، ويُمرَّر إليها قيمتا open وtoggle. وبدلًا من الحاجة إلى استخدام واجهة Context API كما في المثال السابق، أصبح بإمكاننا الوصول إلى هاتين القيمتين عبر props.
index.js · FlyOut.js · FlyoutMenu.js · Images.js
import React from "react";
import Icon from "./Icon";
const FlyOutContext = React.createContext();
export function FlyOut(props) {
const [open, toggle] = React.useState(false);
return (
<div>
{React.Children.map(props.children, child =>
React.cloneElement(child, { open, toggle })
)}
</div>
);
}
function Toggle() {
const { open, toggle } = React.useContext(FlyOutContext);
return (
<div className="flyout-btn" onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
function List({ children }) {
const { open } = React.useContext(FlyOutContext);
return open && <ul className="flyout-list">{children}</ul>;
}
function Item({ children }) {
return <li className="flyout-item">{children}</li>;
}
FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
المميزات
تدير المكوّنات المركّبة حالتها الداخلية الخاصة، وتشاركها بين عدة مكوّنات فرعية. وعندما ننفّذ مكوّنًا مركّبًا، لا نضطر للقلق بشأن إدارة الحالة بأنفسنا.
وعند استيراد مكوّن مركّب، لا نضطر إلى استيراد المكوّنات الفرعية المتاحة على ذلك المكوّن بشكل صريح.
ملاحظة (React 18+): يظل نمط المكوّنات المركّبة باستخدام واجهة Context API في React نمطًا موصى به للمكوّنات المرتبطة التي تتشارك الحالة. والتنفيذ باستخدام الخطّافات (Hooks) (
useState،useContext) حديث ويتوافق مع أفضل الممارسات الحالية. وعند استخدام السياق، تجنّب عمليات إعادة العرض (re-render) غير الضرورية بعدم إعادة إنشاء قيم السياق في كل عرض. وفي السيناريوهات المعقدة، يمكنك تحسين الأداء عبر التخزين المؤقت (memoization) لقيمة السياق أو عبر تقسيم السياق (مثلًا: سياق للقيمة المنطقيةopenوآخر لدالةtoggle). والنمط متوافق تمامًا مع ميزات React القادمة مثل مكوّنات الخادم (Server Components) — يكفي فقط أن تكون مزوّدات السياق والمستهلكون جميعها إمّا مكوّنات خادم أو مكوّنات عميل حسب الحاجة.
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
);
}
العيوب
عند استخدام React.Children.map لتوفير القيم، يصبح تداخل المكوّنات محدودًا. فالأبناء المباشرة فقط للمكوّن الأب هي التي ستحصل على الخصائص open وtoggle، ما يعني أننا لا يمكننا تغليف أي من هذه المكوّنات داخل مكوّن آخر.
export default function FlyoutMenu() {
return (
<FlyOut>
{/* This breaks */}
<div>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</div>
</FlyOut>
);
}
استنساخ عنصر عبر React.cloneElement يقوم بدمج سطحي. فالخصائص الموجودة أصلًا ستُدمج مع الخصائص الجديدة التي نمرّرها. وقد ينتهي هذا إلى تعارض في التسمية إذا كانت خاصية موجودة بالفعل تحمل الاسم نفسه مثل الخصائص التي نمرّرها إلى الدالة React.cloneElement. وبما أن الدمج سطحي، فإن قيمة تلك الخاصية ستُستبدل بأحدث قيمة نمرّرها.