تصميم أنظمة الواجهات الأمامية

هندسة مكتبة المكونات ونظام التصميم

4 دقيقة للقراءة

"صمم مكتبة مكونات" هو سؤال تصميم أنظمة يختبر تصميم API وأنماط التركيب وإمكانية الوصول والتفكير في قابلية التوسع. يظهر في الشركات التي تبني منصات UI مشتركة.

مبادئ تصميم API

أفضل مكتبات المكونات تتبع هذه الأنماط:

المكونات المُركّبة

بدلاً من مكون واحد ضخم بعشرات الخصائص، قسّم إلى قطع قابلة للتركيب:

// سيء: مكون ضخم ثقيل الخصائص
<Select
  options={options}
  label="البلد"
  placeholder="اختر..."
  isSearchable
  isMulti
  onSearch={handleSearch}
  renderOption={renderOption}
  renderValue={renderValue}
/>

// جيد: نمط المكونات المُركّبة
<Select onValueChange={setCountry}>
  <Select.Trigger>
    <Select.Value placeholder="اختر بلدًا..." />
  </Select.Trigger>
  <Select.Content>
    <Select.Search placeholder="ابحث عن البلدان..." />
    <Select.Group label="شائعة">
      <Select.Item value="us">الولايات المتحدة</Select.Item>
      <Select.Item value="uk">المملكة المتحدة</Select.Item>
    </Select.Group>
    <Select.Group label="جميع البلدان">
      {countries.map(c => (
        <Select.Item key={c.code} value={c.code}>{c.name}</Select.Item>
      ))}
    </Select.Group>
  </Select.Content>
</Select>

لماذا تفوز المكونات المُركّبة:

  • المستخدمون يُركّبون فقط ما يحتاجون
  • كل مكون فرعي لديه API مُركّز
  • سهل إضافة عناصر مخصصة بين الأجزاء
  • الحالة مُشاركة عبر React Context داخليًا

خصائص العرض ونمط الفتحات

امنح المستهلكين التحكم في العرض:

// مكون بدون واجهة: يتعامل مع المنطق، المستهلك يتعامل مع العرض
function Combobox<T>({ items, onSelect, children }: {
  items: T[];
  onSelect: (item: T) => void;
  children: (props: {
    inputProps: InputHTMLAttributes<HTMLInputElement>;
    listProps: HTMLAttributes<HTMLUListElement>;
    getItemProps: (item: T, index: number) => HTMLAttributes<HTMLLIElement>;
    isOpen: boolean;
    highlightedIndex: number;
  }) => ReactNode;
}) {
  // جميع منطق لوحة المفاتيح والتركيز والتحديد مُعالج داخليًا
  // المستهلك يقرر كيفية عرض كل جزء
}

// الاستخدام
<Combobox items={users} onSelect={handleSelect}>
  {({ inputProps, listProps, getItemProps, isOpen, highlightedIndex }) => (
    <div className="my-custom-combobox">
      <input {...inputProps} className="custom-input" />
      {isOpen && (
        <ul {...listProps} className="custom-dropdown">
          {users.map((user, i) => (
            <li
              {...getItemProps(user, i)}
              key={user.id}
              className={i === highlightedIndex ? 'active' : ''}
            >
              <Avatar src={user.avatar} /> {user.name}
            </li>
          ))}
        </ul>
      )}
    </div>
  )}
</Combobox>

التركيب فوق التكوين

// نظام التصميم يوفر بدائيات
<Card>
  <Card.Header>
    <Card.Title>ملخص الطلب</Card.Title>
    <Card.Description>راجع عناصرك</Card.Description>
  </Card.Header>
  <Card.Content>
    <ItemList items={cartItems} />
  </Card.Content>
  <Card.Footer>
    <Button variant="outline">إلغاء</Button>
    <Button>تأكيد الطلب</Button>
  </Card.Footer>
</Card>

سؤال المتابعة من المحاور دائمًا هو نفسه: «ولمَ لا نضيف خصائص فحسب؟» التركيب ليس مجانيًا، ومن لا يستطيع تسمية تكلفته يبدو وكأنه قرأ مقالة.

مكونات مُركّبة أم مكوّن مُهيّأ بالخصائص؟

المستهلك يرتّب

المكوّنات المُركّبة / التركيب

طلب تخطيط جديدبلا تغيير في المكتبة
المستهلك يكتبترميزًا أكثر
يفشل حينيرتّبه المستهلكون خطأً
المزايا
  • التخطيط الذي لم يتوقعه أحد يمكن التعبير عنه دون إصدار جديد من المكتبة، وهذا ما يمنع نظام التصميم من التحول إلى طابور انتظار
  • ترميز المستهلك يُظهر البنية، فيرى المراجع الشكل المعروض دون فتح المكون
  • كل مكوّن فرعي يبقى صغيرًا وقابلًا للاختبار باستقلال
العيوب
  • لا شيء يمنع المستهلك من إغفال جزء مطلوب أو تعشيشه خطأً — تحتاج تحذيرات وقت تشغيل أو أنواعًا لفرض ما كانت خاصية واحدة ستضمنه
  • الحالة المشتركة تنتقل عبر السياق، فلا يمكن استخدام المكونات الفرعية خارج الأب، ورسالة الخطأ حين يحاول أحدهم تكون غامضة عادةً
  • كل مستهلك يعيد كتابة الترتيب، فأي تغيير في البنية الموصى بها يعني codemod عبر كل موضع استدعاء
المكتبة تقرّر

التهيئة عبر الخصائص

طلب تخطيط جديدخاصية جديدة وإصدار جديد
المستهلك يكتبعنصرًا واحدًا
يفشل حينتتباعد المتطلبات
المزايا
  • الاستخدام الصحيح هو الاستخدام الوحيد الذي يسمح به توقيع النوع، وهذا يهم أكثر ما يهم في المكونات ذات متطلبات إمكانية الوصول
  • تغيير البنية الداخلية شأن المكتبة، ويصل إلى كل المستهلكين دفعة واحدة
  • موضع الاستدعاء قصير، وهذا أفضل فعلًا في الحالة الشائعة
العيوب
  • عدد الخصائص ينمو مع كل متطلب جديد، والتوليفات المنطقية بينها تنمو أسرع من الخصائص نفسها
  • الخصائص المنطقية التي لا تنطبق إلا حين تُضبط أخرى هي أول رائحة تصل — `showHeader` مع `headerVariant` مع `hideHeaderOnMobile`
  • التخطيط غير المتوقع مستحيل حتى تُصدر المكتبة، فيفرّع المستهلكون المكون وتخسر نظام التصميم

الإجابة التي تُقرأ كخبرة: ابدأ مُهيّأً بالخصائص، وحوّل إلى التركيب حين تبدأ قائمة الخصائص في ترميز التخطيط بدل السلوك. قلها بهذه الصيغة — بالمُحفّز، لا بالتفضيل وحده.

نُهج التنسيق

النهجالمزاياالعيوبالأفضل لـ
متغيرات CSSبدون تكلفة وقت تشغيل، أصلية، تعمل مع SSRمنطق محدود (بدون حسابات شرطية)معظم أنظمة التصميم
CSS-in-JS (styled-components، Emotion)تنسيقات ديناميكية، أنماط مترافقةحمل وقت التشغيل، تعقيد SSRتنسيق ديناميكي عالي
Tailwind CSSأولوية الأدوات المساعدة، حزمة صغيرة، تكرار سريعترميز مُطوّل، منحنى تعلمالتطوير السريع

متغيرات CSS (الافتراضي الموصى به)

/* رموز التصميم كمتغيرات CSS */
:root {
  --color-primary: #2563eb;
  --color-primary-hover: #1d4ed8;
  --color-background: #ffffff;
  --color-text: #111827;
  --radius-md: 8px;
  --space-4: 16px;
  --font-sans: 'Inter', system-ui, sans-serif;
}

/* تجاوز الوضع الداكن */
[data-theme="dark"] {
  --color-primary: #60a5fa;
  --color-primary-hover: #93bbfd;
  --color-background: #0f172a;
  --color-text: #f1f5f9;
}
// مُبدّل التنسيق بسيط
function ThemeToggle() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      تبديل التنسيق
    </button>
  );
}

إمكانية الوصول مُدمجة

كل مكون في نظام التصميم يجب أن يتعامل مع إمكانية الوصول بشكل افتراضي. لا يجب على المستهلكين إضافة سمات ARIA يدويًا.

التنقل بلوحة المفاتيح

function Tabs({ children, defaultValue }: TabsProps) {
  const [activeTab, setActiveTab] = useState(defaultValue);
  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());

  function handleKeyDown(e: React.KeyboardEvent) {
    const tabs = Array.from(tabRefs.current.keys());
    const currentIndex = tabs.indexOf(activeTab);

    let nextIndex: number;
    switch (e.key) {
      case 'ArrowRight':
        nextIndex = (currentIndex + 1) % tabs.length;
        break;
      case 'ArrowLeft':
        nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
        break;
      case 'Home':
        nextIndex = 0;
        break;
      case 'End':
        nextIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    const nextTab = tabs[nextIndex];
    setActiveTab(nextTab);
    tabRefs.current.get(nextTab)?.focus();
  }

  return (
    <div role="tablist" onKeyDown={handleKeyDown}>
      {/* أزرار التبويب مع role="tab"، aria-selected، aria-controls */}
      {/* لوحات التبويب مع role="tabpanel"، aria-labelledby */}
    </div>
  );
}

إدارة التركيز

// الحوار يحبس التركيز عند الفتح
function Dialog({ isOpen, onClose, children }: DialogProps) {
  const dialogRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isOpen) return;

    const dialog = dialogRef.current;
    if (!dialog) return;

    // تخزين العنصر المُركّز سابقًا
    const previouslyFocused = document.activeElement as HTMLElement;

    // تركيز أول عنصر قابل للتركيز في الحوار
    const firstFocusable = dialog.querySelector<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    firstFocusable?.focus();

    // استعادة التركيز عند إغلاق الحوار
    return () => previouslyFocused?.focus();
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div role="dialog" aria-modal="true" ref={dialogRef}>
      {children}
    </div>
  );
}

الإصدارات والتغييرات الكاسرة

إصدارات نظام التصميم تتبع الإصدار الدلالي (semver):

نوع التغييرزيادة الإصدارمثال
إصلاح خطأ، تعديل تنسيقتصحيح (1.0.X)إصلاح لون التمرير للزر
مكون جديد، خاصية جديدةثانوي (1.X.0)إضافة مكون <Skeleton>
خاصية محذوفة، مكون مُعاد تسميتهرئيسي (X.0.0)إعادة تسمية <Input> إلى <TextField>

استراتيجية الترحيل للتغييرات الكاسرة:

  1. الإهمال أولاً: أضف تحذيرات console قبل الحذف بإصدار ثانوي واحد
  2. وفّر codemod (jscodeshift) لأتمتة الترحيل
  3. حافظ على API القديم كاسم مستعار لإصدار رئيسي واحد
  4. وثّق كل تغيير كاسر مع أمثلة قبل/بعد

إزالة الشجرة وحجم الحزمة

يجب على المستهلكين الدفع فقط مقابل ما يستوردونه:

// سيء: تصدير البرميل يُجبر المُحزم على تضمين كل شيء
import { Button, Card, Dialog, Table, Tabs } from '@mylib/components';

// جيد: نقاط دخول فردية
import { Button } from '@mylib/components/button';
import { Card } from '@mylib/components/card';

كيفية تمكين إزالة الشجرة:

  • اضبط "sideEffects": false في package.json
  • استخدم التصديرات المُسماة، وليس التصديرات الافتراضية
  • تجنب الآثار الجانبية على المستوى الأعلى في الوحدات
  • وفّر بناءات ESM و CJS
  • استخدم حقل exports في package.json لنقاط دخول كل مكون
{
  "name": "@mylib/components",
  "sideEffects": false,
  "exports": {
    "./button": {
      "import": "./dist/button/index.mjs",
      "require": "./dist/button/index.cjs"
    },
    "./card": {
      "import": "./dist/card/index.mjs",
      "require": "./dist/card/index.cjs"
    }
  }
}

التوثيق و Storybook

نظام تصميم بدون توثيق هو نظام تصميم لا يستخدمه أحد.

التوثيق الأساسي لكل مكون:

  • ساحة لعب تفاعلية (قصص Storybook)
  • جدول الخصائص مع الأنواع والقيم الافتراضية
  • أمثلة استخدام للأنماط الشائعة
  • ملاحظات إمكانية الوصول (اختصارات لوحة المفاتيح، سلوك قارئ الشاشة)
  • إرشادات مرئية افعل/لا تفعل

نصيحة للمقابلات: عند طلب تصميم مكتبة مكونات، ابدأ بـ API المستهلك. أظهر كيف سيستخدم المطورون مكوناتك قبل مناقشة التنفيذ. هذا يُظهر تفكير المنتج، وليس الهندسة فقط.

هذا يُكمل وحدة تصميم الأنظمة. خذ الاختبار لاختبار معرفتك، ثم تمرّن مع مختبر نظام الإشعارات. :::

اختبار

الوحدة 4: تصميم أنظمة الواجهات الأمامية

خذ الاختبار
هل كان هذا الدرس مفيدًا؟

سجّل الدخول للتقييم