به مرور زمان که برنامههای React بزرگتر میشن، ممکنه که بخاطر نوشتن کد های بد اوضاع یکم بیریخت شه؛ کامپوننتهای پر از کد(تا چند صد خط!)، نگهداری سخت و باگهای غیرمنتظره. اینجاست که اصول SOLID به کار میان. این اصول که در اصل برای برنامهنویسی شیگرا طراحی شدن، بهمون کمک میکنن کد تمیز، انعطافپذیر و مقیاسپذیر بنویسیم. تو این مقاله، هرکدوم از این اصول رو توضیح میدم و نشون میدم چطور میتونیم ازشون تو React استفاده کنیم تا کامپوننتهامون مرتب، نگهداریشون راحتتر و آماده ی بزرگتر شدن باشن.
کلمه ی SOLID مخفف پنج اصل طراحی هست که هدفشون نوشتن کد تمیز، قابل نگهداری و مقیاسپذیره.
حرف S : اصل Single responsibility یا تک مسئولیتی میگه که هر کامپوننت باید فقط یک وظیفه داشته باشه.
حرف O : اصل Open/Closed یا باز و بسته میگه که کامپوننت ها باید برای گسترش باز باشن (قابلیت ارتقاي راحت) اما برای تغییر بسته باشن (نباید نیاز به تغییر در کد اصلی باشه).
حرف L: اصل Liskov Substitution یا جایگزینی لیسکو میگه که کامپوننتها باید بتونن توسط کامپوننتهای فرزندشون جایگزین بشن، بدون اینکه عملکرد اپلیکیشن خراب بشه.
حرف I: اصل Interface segregation یا جداسازی رابط میگه که کامپوننتها نباید مجبور بشن به عملکردهای اضافی که نیازی ندارن وابسته بشن (پراپ هایی دریافت بکنن که بهش نیاز ندارن).
حرف D: اصل Dependency Inversion یا وارونگی وابستگی میگه که کامپوننتها باید به انتزاعات وابسته باشن، نه پیادهسازیهای مشخص.
فرض کنید یه ربات اسباببازی دارید که فقط میتونه یه کار انجام بده، مثلاً راه رفتن. اگه ازش بخوایم کار دوم مثل حرف زدن انجام بده،بعد مثلا کار سوم بخوایم پشتک زدن انجام بده و... خب گیج میشه چون باید روی راه رفتن تمرکز کنه و کارش همون راه رفتن باشه! اگه کار دیگهای میخواید، باید یه ربات دیگه بگیرید.
تو React هم همینطوره خب؛ یه کامپوننت باید فقط یه کار انجام بده. اگه چندتا کار مثل گرفتن دیتا، مدیریت فرم و نمایش UI رو همزمان انجام بده، کامپوننتمون خیلی شلوغ وکثیف و مدیریتش سخت میشه.
const UserCard = () => { const [user, setUser] = useState(null); useEffect(() => { fetch('/api/user') .then(response => response.json()) .then(data => setUser(data)); }, []); return user ? ( <div> <h2>{user.name}</h2> <p>{user.email}</p> </div> ) : <p>Loading...</p>; };
در کد بالا مشکلی که هست اینه که کامپوننت userCard هم مسئولیت فچ کردن دیتا و هم نشون دادن دیتا رو بر عهده داره و به مرور ممکنه حتی فچ های بیشتری انجام بده و... که خب این کد باعث میشه اصل تک مسئولیتی شکسته شه.
const useFetchUser = (fetchUser) => { const [user, setUser] = useState(null); useEffect(() => { fetchUser().then(setUser); }, [fetchUser]); return user; }; const UserCard = ({ fetchUser }) => { const user = useFetchUser(fetchUser); return user ? ( <div> <h2>{user.name}</h2> <p>{user.email}</p> </div> ) : ( <p>Loading...</p> ); };
حالا در کد بالا ما فچ کردن رو بردیم توی یه هوک کاستوم و توی کامپوننت userCard دیگه کاری با فچ کردن دیتا نداریم و هوک این وظیفه رو برعهده داره و ما فقط دیتارو ازش میگیریم و به عنوان وظیفه ی این کامپوننت نشون میدیم.
فرض کنید یه شخصیت توی بازی ویدیویی دارید. میتونید به این شخصیت مهارتهای جدید اضافه کنید (گسترش)، بدون اینکه تواناییهای اصلیش تغییر کنه (تغییرات). اصل OCP دقیقاً همینو میگه؛ یعنی اجازه بدید کدتون رشد کنه و قابلیتهاش رو بیشتر کنید، بدون اینکه مجبور باشید چیزای قبلی رو تغییر بدید(بدون اینکه اصل کد رو دست بزنید.
const Alert = ({ type, message }) => { if (type === 'success') { return <div className="alert-success">{message}</div>; } if (type === 'error') { return <div className="alert-error">{message}</div>; } return <div>{message}</div>; };
اینجا هر بار که نیاز به نوع جدیدی از هشدار داشته باشید، باید کامپوننت Alert
رو تغییر بدید، که این اصل OCP رو نقض میکنه. هر وقت تو کامپوننتتون از conditional rendering یا switch
استفاده کنید، نگهداری اون سختتر میشه، چون در آینده مجبورید شرایط بیشتری اضافه کنید و کد اصلی اون کامپوننت رو تغییر بدید که این کار اصل OCP رو میشکنه.
const Alert = ({ className, message }) => ( <div className={className}>{message}</div> ); const SuccessAlert = ({ message }) => ( <Alert className="alert-success" message={message} /> ); const ErrorAlert = ({ message }) => ( <Alert className="alert-error" message={message} /> );
حالا کامپوننت Alert
برای گسترش بازه (با اضافه کردن چیزایی مثل SuccessAlert
، ErrorAlert
و غیره) ولی برای تغییر بسته است، چون برای اضافه کردن نوع جدید هشدار نیازی به دست زدن به کد اصلی کامپوننت Alert
نداریم.
اگه میخواید اصل OCP رو رعایت کنید، به جای inheritence از composition استفاده کنید و تا حد ممکن از شرطی رندر کردن توی کامپوننت هاتون دوری کنید.
فرض کنید یه تلفن معمولی دارید و بعد یه گوشی هوشمند میخرید. انتظار دارید همونطور که با تلفن معمولی تماس میگرفتید، با گوشی هوشمند هم بتونید تماس بگیرید و حالا یه سری کارای پیشرفته ی جدید هم بکنید. حالا اگه گوشی هوشمند نتونه تماس بگیره، یه جایگزین بد محسوب میشه و درواقع اصل رفتار یه گوشی هوشمند رو تغییر میده، درسته؟ اصل LSP همینو میگه؛ کامپوننتهای جدید یا فرزند باید مثل نسخه اصلی کار کنن، بدون اینکه چیزی رو خراب کنن یا رفتار پیشبینیشده رو تغییر بدن.
const Button = ({ , children }) => ( <button ={}>{children}</button> ); const IconButton = ({ , icon }) => ( <Button ={}> <i className={icon} /> </Button> );
در کد بالا IconButton یه ورژن دیگه از button هستش که ایکون هم داره توش ولی خب حالا ما این دکمه رو با کامپوننت Button عوض کنیم باید دقیقا مث همون کار بکنه با این تفاوت که ایکون هم داره حالا; ولی اینطور نیست چون کامپوننت IconButton چیزایی که Button میگیره (در این مثال label) رو نمیگیره پس رفتار رو تغییر میده.
const Button = ({ , children }) => (
<button ={}>{children}</button>
);
const IconButton = ({ , icon, label }) => (
<Button ={}>
<i className={icon} /> {label}
</Button>
);
// IconButton now behaves like Button, supporting both icon and label
حالا IconButton به درستی رفتار کامپوننت Button رو گسترش میده، هم icon و هم label رو پشتیبانی میکنه، بنابراین میتونید با کامپوننت Button جایگزینش کنید بدون اینکه عملکرد خراب بشه. این اصل Liskov Substitution Principle رو رعایت میکنه، چون کامپوننت فرزند IconButton میتونه جایگزین کامپوننت پدر Button بشه، بدون هیچ مشکلی!
اگه کامپوننت B از A گسترش پیدا کنه، هر جا که از کامپوننت A استفاده میشه، باید بتونید کامپوننت B رو هم جایگزین کامپوننت A کنید بدون اینکه هیچ یک از عملکردهای A از بین بره.
فرض کنید یه ریموت کنترل برای تماشای تلویزیون دارید٬ در این صورت شما فقط به چندتا دکمه برای خاموش و روشن کردن و بالا و پایین اوردن و تغییر کانال تلویزیون نیاز دارید و نه مثلا اینکه بیاید دکمه های خاموش روشن کردن چراغ خونه و رادیو و ماهواره و... رو هم با همون کنترل انجام بدید که کلی پیچیدگی به ریموت کنتترلتون اضافه میکنه.
حالا تصور کنید یه کامپوننت DataTable (جدول داده) دارید که کلی پراپ میگیره حتی اگه همشون رو هم استفاده بکنه بازم دلیل بر این نمیشه که همشون رو نیاز داره!
const DataTable = ({ data, sortable, filterable, exportable }) => ( <div> {/* Table rendering */} {sortable && <button>Sort</button>} {filterable && <input placeholder="Filter" />} {exportable && <button>Export</button>} </div> );
داره این کامپوننت بالا باعث میشه که مصرف کننده به تمام این مسائل از حمله فیلریتگ و سورت کردن و اکسپورت کردن فک کنه حتی اگه فقط یه دیتای ساده بدون این فانشکنالیتی ها بخواد!
اینجاس که شما باید کامپوننت رو به تیکه های کوچیک تر تقسیم کنید تا فقط پراپ های ضروری خودشون رو دریافت کنند.
const DataTable = ({ data }) => ( <div> {/* Table rendering */} </div> ); const SortableTable = ({ data }) => ( <div> <DataTable data={data} /> <button>Sort</button> </div> ); const FilterableTable = ({ data }) => ( <div> <DataTable data={data} /> <input placeholder="Filter" /> </div> );
حالا هر جدول فقط ویژگیهایی رو شامل میشه که واقعاً نیاز داره و پراپزهای غیرضروری همه جا استفاده نمیشن. این Interface Segregation principle (ISP) رو رعایت میکنه، جایی که کامپوننتها فقط به بخشهایی وابسته هستن که واقعاً نیاز دارن.
فرض کنید با بلوکهای LEGO دارید یه رباتی میسازید. اگه بخواید دست یا پاهای ربات رو عوض کنید، نباید کل ربات رو دوباره بسازید که! فقط اون قسمتها رو جایگزین میکنید. اصل DIP هم همینو میگه: کامپوننتهای اصلی نباید به جزئیات خاص وابسته باشن، بلکه باید به قطعات قابل تغییر تکیه کنن.
const UserComponent = () => { useEffect(() => { fetch('/api/user').then(...); }, []); return <div>...</div>; };
کد بالا مستقیما به fetch وابسته اس و خب اگه مثلا بخوایم توی محیط تست بجای این فچ از یه mock api ای چیزی استفاده کنیم یا دیتارو تغییر بدیم نمیتونیم اینکار رو بکنیم.
const UserComponent = ({ fetchUser }) => { useEffect(() => { fetchUser().then(...); }, [fetchUser]); return <div>...</div>; };
حالا تابع fetchUser
بهعنوان یک پراپز پاس داده شده (یا میتونیم از جای دیگه ایمپورتش کنیم اصن) و بهراحتی میتونیم اون رو با یه پیادهسازی دیگه (مثلاً یک API شبیهسازیشده یا منبع داده دیگه) جایگزین کنیم. این کار انعطافپذیری و تستپذیری کد رو بالا میبره.
درک و استفاده از اصول SOLID توی React میتونه کیفیت کدتونو بهطرز چشمگیری بهتر کنه. این اصول بهتون کمک میکنن کامپوننتایی بنویسید که ماژولارتر، منعطفتر و راحتتر نگهداری بشن. در نهایت، اصول SOLID باعث میشن کدهاتون تمیزتر و پایدارتر باشن.
ممنون که تا اینجای مقاله همراهی کردید و امیدوارم کدای باکیفیت تر و خفن تری بنویسید.
تا مقاله بعد خدانگهدار و موفق باشید🤞🏻