أساسيات React.js

بناء مكتبات مكونات قابلة لإعادة الاستخدام

18 دقيقة الدرس 29 من 40

إنشاء مكتبات مكونات قابلة لإعادة الاستخدام

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

نهج نظام التصميم

نظام التصميم يوفر مجموعة موحدة من المكونات والأنماط والإرشادات:

مبادئ نظام التصميم: الاتساق، وإعادة الاستخدام، وإمكانية الوصول، والمرونة، والوثائق الواضحة هي أساس مكتبات المكونات الناجحة.
هيكل مكتبة المكونات:
src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.stories.tsx
│   │   ├── Button.test.tsx
│   │   ├── Button.module.css
│   │   └── index.ts
│   ├── Input/
│   ├── Card/
│   └── index.ts
├── tokens/
│   ├── colors.ts
│   ├── spacing.ts
│   ├── typography.ts
│   └── index.ts
├── utils/
│   └── classnames.ts
└── index.ts
رموز التصميم:
// tokens/colors.ts
export const colors = {
  primary: {
    50: '#e3f2fd',
    100: '#bbdefb',
    500: '#2196f3',
    700: '#1976d2',
    900: '#0d47a1',
  },
  neutral: {
    50: '#fafafa',
    100: '#f5f5f5',
    500: '#9e9e9e',
    900: '#212121',
  },
  semantic: {
    success: '#4caf50',
    warning: '#ff9800',
    error: '#f44336',
    info: '#2196f3',
  }
};

// tokens/spacing.ts
export const spacing = {
  xs: '0.25rem',
  sm: '0.5rem',
  md: '1rem',
  lg: '1.5rem',
  xl: '2rem',
  xxl: '3rem',
};

// tokens/typography.ts
export const typography = {
  fontFamily: {
    sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
    mono: ''Courier New', monospace',
  },
  fontSize: {
    xs: '0.75rem',
    sm: '0.875rem',
    base: '1rem',
    lg: '1.125rem',
    xl: '1.25rem',
    '2xl': '1.5rem',
  },
  fontWeight: {
    normal: 400,
    medium: 500,
    semibold: 600,
    bold: 700,
  }
};

نمط المكونات المركبة

المكونات المركبة تسمح للمكونات الأم والفرعية بالعمل معاً بشكل ضمني:

مثال مكون علامات التبويب:
import { createContext, useContext, useState } from 'react';

interface TabsContextValue {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabsContext = createContext<TabsContextValue | undefined>(undefined);

function useTabs() {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('Tabs compound components must be used within Tabs');
  }
  return context;
}

// المكون الأم
interface TabsProps {
  defaultTab: string;
  children: React.ReactNode;
}

function Tabs({ defaultTab, children }: TabsProps) {
  const [activeTab, setActiveTab] = useState(defaultTab);

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

// مكون قائمة علامات التبويب
function TabList({ children }: { children: React.ReactNode }) {
  return <div className="tab-list" role="tablist">{children}</div>;
}

// مكون علامة التبويب
interface TabProps {
  value: string;
  children: React.ReactNode;
}

function Tab({ value, children }: TabProps) {
  const { activeTab, setActiveTab } = useTabs();
  const isActive = activeTab === value;

  return (
    <button
      role="tab"
      aria-selected={isActive}
      className={`tab ${isActive ? 'active' : ''}`}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  );
}

// مكون لوحات علامات التبويب
function TabPanels({ children }: { children: React.ReactNode }) {
  return <div className="tab-panels">{children}</div>;
}

// مكون لوحة علامة التبويب
interface TabPanelProps {
  value: string;
  children: React.ReactNode;
}

function TabPanel({ value, children }: TabPanelProps) {
  const { activeTab } = useTabs();

  if (activeTab !== value) return null;

  return (
    <div role="tabpanel" className="tab-panel">
      {children}
    </div>
  );
}

// تركيب كل شيء
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;

export default Tabs;

// الاستخدام
function App() {
  return (
    <Tabs defaultTab="profile">
      <Tabs.List>
        <Tabs.Tab value="profile">Profile</Tabs.Tab>
        <Tabs.Tab value="settings">Settings</Tabs.Tab>
        <Tabs.Tab value="notifications">Notifications</Tabs.Tab>
      </Tabs.List>

      <Tabs.Panels>
        <Tabs.Panel value="profile">
          <h2>Profile Content</h2>
        </Tabs.Panel>
        <Tabs.Panel value="settings">
          <h2>Settings Content</h2>
        </Tabs.Panel>
        <Tabs.Panel value="notifications">
          <h2>Notifications Content</h2>
        </Tabs.Panel>
      </Tabs.Panels>
    </Tabs>
  );
}
فوائد المكون المركب: هذا النمط يوفر واجهة برمجة تطبيقات مرنة وتصريحية مع الحفاظ على مشاركة الحالة الداخلية بين المكونات. إنه مثالي لعناصر واجهة المستخدم المعقدة مثل علامات التبويب والأكورديون والقوائم.

نمط Render Props

Render props تسمح للمكونات بمشاركة السلوك بدون وراثة:

DataLoader مع Render Props:
interface DataLoaderProps<T> {
  url: string;
  children: (data: {
    data: T | null;
    loading: boolean;
    error: string | null;
    refetch: () => void;
  }) => React.ReactNode;
}

function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url);
      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return <>{children({ data, loading, error, refetch: fetchData })}</>;
}

// الاستخدام
interface User {
  id: number;
  name: string;
  email: string;
}

function App() {
  return (
    <DataLoader<User[]> url="/api/users">
      {({ data, loading, error, refetch }) => {
        if (loading) return <p>Loading...</p>;
        if (error) return <p>Error: {error}</p>;

        return (
          <div>
            <button onClick={refetch}>Refresh</button>
            <ul>
              {data?.map(user => (
                <li key={user.id}>{user.name}</li>
              ))}
            </ul>
          </div>
        );
      }}
    </DataLoader>
  );
}

مكونات عالية المستوى (HOCs)

HOCs هي دوال تأخذ مكوناً وتعيد مكوناً محسّناً:

withLoading HOC:
interface WithLoadingProps {
  loading: boolean;
}

function withLoading<P extends object>(
  Component: React.ComponentType<P>,
  LoadingComponent: React.ComponentType = () => <p>Loading...</p>
) {
  return function WithLoadingComponent(props: P & WithLoadingProps) {
    const { loading, ...rest } = props;

    if (loading) {
      return <LoadingComponent />;
    }

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

// الاستخدام
interface UserListProps {
  users: User[];
}

function UserList({ users }: UserListProps) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

const UserListWithLoading = withLoading(UserList);

function App() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);

  return <UserListWithLoading users={users} loading={loading} />;
}
withAuth HOC:
interface AuthenticatedComponentProps {
  user: User;
}

function withAuth<P extends AuthenticatedComponentProps>(
  Component: React.ComponentType<P>
) {
  return function WithAuthComponent(props: Omit<P, 'user'>) {
    const { user, isAuthenticated } = useAuth();

    if (!isAuthenticated || !user) {
      return <Navigate to="/login" />;
    }

    return <Component {...(props as P)} user={user} />;
  };
}

// الاستخدام
function Dashboard({ user }: AuthenticatedComponentProps) {
  return <h1>Welcome, {user.name}!</h1>;
}

const ProtectedDashboard = withAuth(Dashboard);
قيود HOC: HOCs يمكن أن تؤدي إلى "جحيم الأغلفة" وتجعل التصحيح أصعب. فضّل الخطافات للاهتمامات المشتركة عندما يكون ذلك ممكناً. استخدم HOCs عندما تحتاج إلى تغليف المكونات في وقت البناء.

المكونات بدون رأس

المكونات بدون رأس توفر المنطق بدون تصميم، مما يسمح بتخصيص كامل لواجهة المستخدم:

مكون تبديل بدون رأس:
interface UseToggleOptions {
  defaultOn?: boolean;
  onToggle?: (isOn: boolean) => void;
}

interface UseToggleReturn {
  isOn: boolean;
  toggle: () => void;
  setOn: () => void;
  setOff: () => void;
  getTogglerProps: (props?: React.HTMLAttributes<HTMLButtonElement>) => {
    onClick: () => void;
    'aria-pressed': boolean;
  };
}

function useToggle({
  defaultOn = false,
  onToggle
}: UseToggleOptions = {}): UseToggleReturn {
  const [isOn, setIsOn] = useState(defaultOn);

  const toggle = useCallback(() => {
    setIsOn(prev => {
      const newValue = !prev;
      onToggle?.(newValue);
      return newValue;
    });
  }, [onToggle]);

  const setOn = useCallback(() => {
    setIsOn(true);
    onToggle?.(true);
  }, [onToggle]);

  const setOff = useCallback(() => {
    setIsOn(false);
    onToggle?.(false);
  }, [onToggle]);

  const getTogglerProps = useCallback(
    (props: React.HTMLAttributes<HTMLButtonElement> = {}) => ({
      ...props,
      onClick: () => {
        props.onClick?.({} as any);
        toggle();
      },
      'aria-pressed': isOn,
    }),
    [isOn, toggle]
  );

  return { isOn, toggle, setOn, setOff, getTogglerProps };
}

// الاستخدام - تحكم كامل في واجهة المستخدم
function CustomToggle() {
  const { isOn, getTogglerProps } = useToggle({
    onToggle: (isOn) => console.log('Toggled:', isOn)
  });

  return (
    <div>
      <button
        {...getTogglerProps()}
        className={`toggle ${isOn ? 'on' : 'off'}`}
      >
        {isOn ? 'ON' : 'OFF'}
      </button>
      <p>The toggle is {isOn ? 'on' : 'off'}</p>
    </div>
  );
}

// تطبيق واجهة مستخدم مختلف
function SwitchToggle() {
  const { isOn, getTogglerProps } = useToggle();

  return (
    <label className="switch">
      <input type="checkbox" checked={isOn} readOnly />
      <span {...getTogglerProps()} className="slider" />
    </label>
  );
}
مكون قائمة منسدلة بدون رأس:
interface UseDropdownOptions {
  onSelect?: (value: string) => void;
}

interface UseDropdownReturn {
  isOpen: boolean;
  selectedValue: string | null;
  open: () => void;
  close: () => void;
  toggle: () => void;
  select: (value: string) => void;
  getMenuProps: () => React.HTMLAttributes<HTMLDivElement>;
  getItemProps: (value: string) => React.HTMLAttributes<HTMLButtonElement>;
  getTriggerProps: () => React.HTMLAttributes<HTMLButtonElement>;
}

function useDropdown({ onSelect }: UseDropdownOptions = {}): UseDropdownReturn {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedValue, setSelectedValue] = useState<string | null>(null);

  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);
  const toggle = useCallback(() => setIsOpen(prev => !prev), []);

  const select = useCallback((value: string) => {
    setSelectedValue(value);
    setIsOpen(false);
    onSelect?.(value);
  }, [onSelect]);

  const getTriggerProps = useCallback(() => ({
    onClick: toggle,
    'aria-expanded': isOpen,
    'aria-haspopup': true as const,
  }), [isOpen, toggle]);

  const getMenuProps = useCallback(() => ({
    role: 'menu',
    hidden: !isOpen,
  }), [isOpen]);

  const getItemProps = useCallback((value: string) => ({
    role: 'menuitem',
    onClick: () => select(value),
  }), [select]);

  return {
    isOpen,
    selectedValue,
    open,
    close,
    toggle,
    select,
    getTriggerProps,
    getMenuProps,
    getItemProps,
  };
}

// الاستخدام
function CustomDropdown() {
  const dropdown = useDropdown({
    onSelect: (value) => console.log('Selected:', value)
  });

  return (
    <div className="dropdown">
      <button {...dropdown.getTriggerProps()}>
        {dropdown.selectedValue || 'Select option'}
      </button>
      <div {...dropdown.getMenuProps()} className="dropdown-menu">
        <button {...dropdown.getItemProps('option1')}>Option 1</button>
        <button {...dropdown.getItemProps('option2')}>Option 2</button>
        <button {...dropdown.getItemProps('option3')}>Option 3</button>
      </div>
    </div>
  );
}
فوائد واجهة المستخدم بدون رأس: فصل المنطق عن العرض يسمح بأقصى قدر من المرونة. يمكن للمستخدمين تنفيذ أي تصميم مع الاستفادة من السلوك القوي وميزات إمكانية الوصول.

التوثيق مع Storybook

Storybook هو المعيار الصناعي لتوثيق المكونات:

تثبيت Storybook:
# تهيئة Storybook
npx storybook@latest init

# تشغيل Storybook
npm run storybook
مثال قصة الزر:
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import Button from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'danger'],
      description: 'Button style variant',
    },
    size: {
      control: 'radio',
      options: ['small', 'medium', 'large'],
    },
    disabled: {
      control: 'boolean',
    },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: 'Secondary Button',
  },
};

export const Disabled: Story = {
  args: {
    disabled: true,
    children: 'Disabled Button',
  },
};

export const AllSizes: Story = {
  render: () => (
    <div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
      <Button size="small">Small</Button>
      <Button size="medium">Medium</Button>
      <Button size="large">Large</Button>
    </div>
  ),
};

export const WithIcon: Story = {
  args: {
    children: (
      <>
        <span>📧</span>
        Send Email
      </>
    ),
  },
};
فوائد Storybook: ساحة لعب مكونات تفاعلية، وتوثيق تلقائي، واختبار التراجع البصري، واختبار إمكانية الوصول، وعرض مكونات قابل للمشاركة للمصممين والمطورين.

تمرين 1: بناء نافذة مركبة

أنشئ مكون نافذة مركب مع:

  • Modal (أم)، Modal.Header، Modal.Body، Modal.Footer
  • سياق مشترك لحالة فتح/إغلاق
  • ميزات إمكانية الوصول (فخ التركيز، ESC للإغلاق)
  • دعم الرسوم المتحركة
  • عرض البوابة

تمرين 2: ترقيم الصفحات بدون رأس

ابنِ خطاف ترقيم صفحات بدون رأس:

  • تتبع الصفحة الحالية، إجمالي الصفحات، العناصر لكل صفحة
  • توفير طرق: nextPage، prevPage، goToPage
  • إرجاع getters الخصائص لأزرار الصفحة
  • معالجة حالات الحافة (الصفحة الأولى/الأخيرة)
  • دعم تطبيقات واجهة مستخدم مخصصة

تمرين 3: إعداد نظام التصميم

قم بتهيئة مكتبة مكونات مع نظام تصميم:

  • عرّف رموز التصميم (الألوان، التباعد، الطباعة)
  • أنشئ 5 مكونات أساسية (Button، Input، Card، Badge، Avatar)
  • أعد Storybook مع التوثيق
  • أضف أنواع TypeScript لجميع المكونات
  • نفّذ أنماط API متسقة