Polymorphic React Components

من الصعب دعم جميع الخيارات والمكونات المحتملة التي قد يرغب المطور في استخدامها ضمن مكتبة مُكونات أو نظام تصميم مُعين، في هذا المقال سنستعرض كيف يمكن لمكونات React متعددة الأشكال (Polymorphic React components) أن تساعد في التغلب على هذا التحدي.
Polymorphic React Components

في هذه الصفحة

من الصعب دعم جميع الخيارات والمكونات المحتملة التي قد يرغب المطور في استخدامها ضمن مكتبة مُكونات (ui components library) أو نظام تصميم مُعين (Design System).

في هذا المقال سنستعرض كيف يمكن لمكونات React متعددة الأشكال (Polymorphic React components) أن تساعد في التغلب على هذا التحدي. سنتطرق إلى الإمكانيات التي توفرها هذه المكونات لتصميم واجهات مستخدم ديناميكية ومرنة تُلبي متطلبات متنوعة بكفاءة عالية.


طُرق إنشاء مُكونات رياكت مُتعددة الأشكال

يُعتبر التعدد الشكلي (Polymorphism) طريقة حيث يُمكن لعنصر واحد أن يتخذ أشكالا متعددة، مثل زر (Button) يُمكن أن يعمل أيضا كرابط (Link). استخدام هذه الطريقة بكفاءة يُمكن أن يُسهم في تقليل الجهد اللازم لصيانة عدة نسخ مختلفة من المكونات؛ فبدلاً من امتلاك مكونين منفصلين لكل من الزر والرابط، نستطيع الاعتماد على مكون واحد (Button) مرن يُمكنه القيام بالوظيفتين.

من الطرق الشائعة لتطبيق التعدد الشكلي في React هي استخدام نمط "as" ونمط "asChild".

// Output will be => "<button>This is a button</button>"
<Button>This is a button</Button>

// Output will be => "<a href="/demo">This is a link</a>"
<Button as="a" href="/demo">This is a link</Button>

// Output will be => "<a href="/login">Login</a>"
<Button asChild>
    <a href="/login">Login</a>
</Button>

تُعتبر المكونات متعددة الأشكال شائعة جدا، حيث تستخدمها العديد من المكتبات (libraries) وأنظمة التصميم (design systems). من مكتبات CSS-in-JS مثل Styled Components، مرورا بمكتبات المكونات مثل Chakra UI، وصولا إلى أنظمة التصميم مثل React Spectrum، تُظهر هذه الأدوات كيف يمكن للمكونات المتعددة الأشكال أن تُسهم بشكل فعال في تبسيط تطوير واجهات المستخدم وتعزيز المرونة.


نمط as

أحد الأمور الشائعة في تطوير واجهات المستخدم هي جعل رابط (Link) يبدو و كأنه زر (Button) من ناحية التنسيقات فقط لكنه يبقى مُحافظ على سلوكه الطبيعي (Navigation).

أحد الحلول التي تسمح لنا بذلك هي باستخدام نمط "as". هذا النمط يستفيد من خصائص JSX حيث أن React تتعامل مع العناصر التي تبدأ بحروف كبيرة كأنواع عناصر (element type)، مما يمكننا من التحكم في نوع العنصر المُراد تقديمه عبر متغير.

في التطبيق العملي، يمكننا توظيف هذه الميزة بتعيين القيمة من خاصية "as" إلى متغير يُدعى Component (اسم المُتغير هنا اختياري، يمكنك استخدام أي اسم تريده طالما أن الاسم يبدأ بحرف كبير). هذا يسمح لنا بتغيير نوع العنصر ديناميكيا بناءً على الخاصية المُعطاة. على سبيل المثال:

export function Button(props: any) {

  const { as, children, ...rest } = props;

  const Component = as || "button"; // If the 'as' prop wasn't provided, the default "button" is used.

  return (
    <Component {...rest}>{children}</Component>
  );

}

في المثال أعلاه، إذا كانت قيمة الخاصية "as" هي "a"، فإن نوع العنصر سيكون وسم رابط (anchor tag) و ليس وسم زر (button tag).


نمط as و TypeScript

للتأكد من أن المستخدم للمُكون يقوم بتمرير قيمة صحيحة للخاصية "as"، يمكننا استخدام أحد خواص  التايبسكريبت Generics، والسماح له بالتوريث من ElementType. و بهذا الشكل خاصية "as" يمكن أن تتضمن الآن أنواع عناصر صالحة، مثل <a> أو <button>:

import React, type { ElementType, ComponentPropsWithoutRef } from "react";

type ButtonProps<T extends ElementType> = {
  as?: T;
} & ComponentPropsWithoutRef<T>;

export function Button<T extends ElementType = "button">(
  props: ButtonProps<T>
) {

  const { as, children, ...rest } = props;

  const Component = as || "button";

  return (
    <Component {...rest}>{children}</Component>
  );

}

المثال أعلاه بسيط و في حالة عدم الحاجة إلى تمرير الـ ref و هو ما نفعله في العادة في المُكونات المُتعلقة بالمشروع بحد ذاته، لكن إذا ما كُنا نبني مكتبة مُكونات ففي الغالب نحتاج إلى التعامل مع تمرير الـ ref حتى تكون مُكوناتنا مرنة و قابلة للاستخدام في عديد السياقات خصوصا إذا ما كُنا نريد التعامل مع 3rd packages مثل charts… و عليه يُمكن أيضا أن تجد هذا النمط بصيغ مُختلفة في طريقة دعم الـ TypeScript.

import React, type { ElementType, ComponentPropsWithRef } from "react";

type Props<T extends ElementType> = {
  as?: T;
} & ComponentPropsWithRef<T>;

const Button = React.forwardRef(
  <T extends ElementType = "button">(
    { as: Component = "button", ...props }: Props<T>,
    ref: React.Ref<
      T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T] : T
    >
  ) => {
    return (
      <Component {...props} ref={ref}>
        {props.children}
      </Component>
    );
  }
);

Button.displayName = "Button";

المقال لا يُركز بشكل كبير على طريقة التنفيذ في TypeScript بقدر ما يُركز على فلسفة هاته الأنماط و الفرق بينها و مشاكلهم، لكن لا بأس بشرح هذا السطر الذي قد يكون مُبهم و غير مفهوم للبعض:

ref: React.Ref<T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T] : T>

هذا التعبير يفحص ما إذا كان النوع T يمثل نوعا صالحا من أنواع العناصر الأساسية (built-in elements) في HTML. إذا كان الأمر كذلك، يسترجع الخصائص الخاصة لذلك العنصر الأساسي. وإذا لم يكن كذلك، يُفترض أن T يمثل نوع مكون مخصص (custom components) ويُحافظ على النوع كما هو T. هذا يضمن أن خاصية ref تحتفظ بالنوع الصحيح سواء للمكونات الأساسية أو المكونات المخصصة.

بينما توفر المكونات المتعددة الأشكال مرونة كبيرة في التخصيص لكنها ليست خالية من المشكلات:

  • Type-checking performance: التحقق من الأنواع للمكونات المتعددة الأشكال قد يكون معقدا ويؤثر على أداء TypeScript.
  • Chaining components: خاصية "as" لا يمكن استخدامها أكثر من مرة في تسلسل المكونات، مما يحد من المرونة في بعض السيناريوهات.
// Doesn't work, `as` can only be used once
<Card as={Tooltip} as={Button}>Click Me</Card>
  • Props compatibility: قد تواجه مشكلات في توافق الخصائص بين المكونات، خصوصا إذا كانت الخصائص تحمل نفس الاسم ولكن بقيم مختلفة.

عند استخدام styled-components، مثلاً، يمكن تخصيص المكونات بخاصية "as":

<StyledButton as={CustomLink}>ِClick Here</StyledButton>

هذه الطريقة تفترض أن CustomLink يقبل خاصية className لتطبيق الأنماط. إذا لم يكُن يُدعم مكون الـ CustomLink هذه الخاصية فإن TypeScript لن يُظهر لنا تحذيرات أو أخطاء فببساطة فقط لن تعمل الخاصية كما هو مُتوقع.

بينما قد تكون بعض المشكلات المذكورة قابلة للحل، لكنها غالبا لا تُعالج بواسطة المكتبات، ويمكن أن تجعل الأمور مُعقدة بعض الشيىء أو لها تأثير قوي في بعض السياقات. و هذا ما أدى إلى ظهور نمط آخر asChild يحل بعض المشاكل بطريقة جيدة لكن في النهاية أيضا يبقى فيه بعض القصور و النُقص.


من نمط as الى نمط asChild

يُتيح نمط "as" التعدد الشكلي من خلال السماح للمكونات بتحديد كيفية التعامل مع أنواع العناصر المختلفة داخليا. في المقابل يُقدم نمط "asChild" نهج بديل حيث يستخدم العنصر الفرعي لتحديد نوع العنصر الأب.

// Renders the default element, a button
<ToggleButton>Toggle Me</ToggleButton>

// Renders MyButton
<ToggleButton asChild>
  <MyButton>Toggle Me</MyButton>
</ToggleButton>

إذا تم تمرير خاصية "asChild" فإن مكون ToggleButton لن يقوم بعرض الـزر بنفسه بل سيقوم بتوجيه جميع الخصائص (props) إلى العنصر الفرعي MyButton ودمج جميع الخصائص الموجودة على ذلك العنصر.

export function Button({ asChild, children, ...props }: any) {
  return asChild ? (
    React.cloneElement(children, { className: "button", ...props })
  ) : (
    <button className="button" {...props}>
      {children}
    </button>
  );
}

يُوضح هذا التنفيذ implementation البسيط الفكرة لهذا النمط، لكنه لا يُغطي عديد الحالات الشائعة مثل دمج الـ props المتعارضة، الـ styles، الـ event handlers ... لقد استخلصت مكتبة Radix هذا المنطق في مكون مُساعد Utility Component يُسمى Slot يُمكنك استخدامه في مشاريعك.


متى نستخدم "as" ومتى نستخدم "asChild"؟

بما أنه لدي تعامل و  اهتمام كبير بمكتبات الـ UI، فيُمكنني القول أن أكثر الاستخدامات لهاته الأنماط في العادة يكون محصور في مُكونات الـ Typography و المُكونات لي تحتوي في أحد أجزائها على Button مثل Dialog، Drawer … 

فيما يتعلق بمُكونات الـ Typography مثل Heading فهنا من الأفضل استخدام النمط "as" لأن مثل هذا المُكون لا يحتوي على سيناريوهات استخدام كثيرة أو مُعقدة.

import React from 'react';

// Heading component using the 'as' pattern
const Heading = ({ as: Component = 'h1', children, ...props }) => {
  return <Component {...props}>{children}</Component>;
};

// Usage of the Heading component
const App = () => {
  return (
    <div>
      <Heading as="h1">Main Title</Heading>
      <Heading as="h2">Sub Title</Heading>
      <Heading as="h3">Section Title</Heading>
      <Heading as="h4">Sub Section Title</Heading>
    </div>
  );
};

export default App;

بينما أُفضل نمط "asChild"  على حساب نمط "as" في باقي السياقات لأنه يُعطينا بعض المزايا الجيدة التي تستحق استخدامه كـ:

  • توزيع الأدوار بوضوح: يُصبح من الواضح أي عنصر يتحكم في التنسيق وأي عنصر يتحكم في الوظيفة. يقوم Button بتنسيق التصميم، بينما يتولى عنصر <a> جانب التنقل.
/////////////////////////////////
// Clear distribution of roles //
/////////////////////////////////

<StyledButton as={CustomLink}>ِClick Here</StyledButton>

<Button asChild style={{ backgroundColor: 'blue', color: 'white', padding: '10px' }}>
  <a href="#top">
    Back to Top
  </a>
</Button>

مثلما نرى في هذا المثال أعلاه، عند قراءة الكود الخاص بالنمط asChild نرى الوضوح في أداء المهام أفضل من النمط as. فالـ Button مسؤول هنا عن التنسيق styles بينما العنصر الفرعي a مسؤول عن التنقل navigation.

  • المرونة: ستكون لدينا مُرونة أفضل في تنسيق المُكونات الفرعية. ففي المثال السابق يُمكنني إضافة تنسيقات أو خصائص على مستوى المكون الفرعي على عكس نمط as نرى أنه لا يُمكننا تمرير خصائص أو تنسيقات.
  • تجنب التعقيد: هذا النهج يتجنب التعقيد الذي يمكن أن ينشأ من الحاجة لدعم أنواع متعددة من العناصر في Button بشكل مباشر. مع نمط as قد يتطلب الأمر دعم الكثير من الإصدارات المختلفة، مما يجعل الكود غير واضح. على عكس asChild أين يتم تفويض العرض للمكون الفرعي، مما يلغي الحاجة إلى دعم جميع الاحتمالات.

هناك أيضا تساؤل حول ما إذا كان ينبغي استخدام التعدد الشكلي Polymorphism أصلا. عندما تجد أن هناك عناصر متشابهة، فقد يكون من المنطقي دمجها باستخدام التعدد الشكلي. ولكن إذا كان لديك العديد من الحالات الخاصة التي تعتمد على نوع العنصر، فمن الأفضل إبقاؤها منفصلة.


كلمة الختام

هنالك عدة طُرق لإنشاء مُكونات مُتعددة الأشكال Polymorphic React components سواء عن طريق نمط as أو asChild ولا نستثني أيضا دعم TypeScript للحصول على أفضل تجربة تطوير DX. قد يكون التعدد الشكلي سلاح ذو حدين، إذ يمكن أن يُضيف تعقيد على الكود لكن عند استخدامه بحكمة وفي الحالات التي تستدعيه، يُمكن أن يجعل الكود أكثر وضوحا وسهولة في الصيانة.

اشترك الآن بنشرة اقرأ-تِك الأسبوعية

لا تدع أي شيء يفوتك. واحصل على أحدث المقالات المميزة مباشرة إلى بريدك الإلكتروني وبشكل مجاني!