بناء مكتبات مكونات قابلة لإعادة الاستخدام
إنشاء مكتبات مكونات قابلة لإعادة الاستخدام
بناء مكتبات مكونات قابلة لإعادة الاستخدام ضروري للحفاظ على الاتساق عبر التطبيقات والفرق. في هذا الدرس، سنستكشف أنماط التصميم والأدوات لإنشاء مكتبات مكونات مرنة وقابلة للتركيب وموثقة جيداً.
نهج نظام التصميم
نظام التصميم يوفر مجموعة موحدة من المكونات والأنماط والإرشادات:
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 تسمح للمكونات بمشاركة السلوك بدون وراثة:
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 هي دوال تأخذ مكوناً وتعيد مكوناً محسّناً:
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} />;
}
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);
المكونات بدون رأس
المكونات بدون رأس توفر المنطق بدون تصميم، مما يسمح بتخصيص كامل لواجهة المستخدم:
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
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
</>
),
},
};
تمرين 1: بناء نافذة مركبة
أنشئ مكون نافذة مركب مع:
- Modal (أم)، Modal.Header، Modal.Body، Modal.Footer
- سياق مشترك لحالة فتح/إغلاق
- ميزات إمكانية الوصول (فخ التركيز، ESC للإغلاق)
- دعم الرسوم المتحركة
- عرض البوابة
تمرين 2: ترقيم الصفحات بدون رأس
ابنِ خطاف ترقيم صفحات بدون رأس:
- تتبع الصفحة الحالية، إجمالي الصفحات، العناصر لكل صفحة
- توفير طرق: nextPage، prevPage، goToPage
- إرجاع getters الخصائص لأزرار الصفحة
- معالجة حالات الحافة (الصفحة الأولى/الأخيرة)
- دعم تطبيقات واجهة مستخدم مخصصة
تمرين 3: إعداد نظام التصميم
قم بتهيئة مكتبة مكونات مع نظام تصميم:
- عرّف رموز التصميم (الألوان، التباعد، الطباعة)
- أنشئ 5 مكونات أساسية (Button، Input، Card، Badge، Avatar)
- أعد Storybook مع التوثيق
- أضف أنواع TypeScript لجميع المكونات
- نفّذ أنماط API متسقة