حسین شیره جونی
حسین شیره جونی
خواندن ۱۲ دقیقه·۱ سال پیش

راهنمای کامل Memo در React

در این مقاله به بررسی memoization می‌پردازیم. memoization یک تکنیک قدرتمند است که می‌تواند باعث بهبود عملکرد کد شود. اما مهم است که این تکنیک را به درستی استفاده کنید. این مقاله طولانی است، اما برای درک درست این تکنیک، ارزش خواندن دارد.


تعریف Memoization

ترجمه اش در فارسی "به خاطر سپردن" می شود، که یکی از تکنیک‌های رایج بهینه‌سازی در برنامه‌نویسی است. به این صورت که تابع در اولین بار که صدا زده می‌شود، خروجی خود را محاسبه می‌کند و سپس آن را در حافظه ذخیره می‌کند. در دفعات بعدی صدا زدن تابع، به جای محاسبه مجدد خروجی، از خروجی ذخیره شده قبلی استفاده می‌شود.


فلوچارت Memoization
فلوچارت Memoization


پیاده‌سازی توابع Memoization معمولا به دو روش انجام می‌شود:

روش اول: ذخیره‌سازی آخرین خروجی تابع است. در این روش، تابع در هنگام فراخوانی خروجی خود را محاسبه و در حافظه ذخیره میکند. در دفعه‌ی بعدی که تابع با همان آرگومان فراخوانی می‌شود، به جای محاسبه مجدد، از خروجی ذخیره شده قبلی استفاده می‌شود. در صورتی که تابع با argument متفاوتی صدا زده شود مقدار خروجی دوباره محاسبه میگردد و جایگزین مقدار قبلی می شود.

روش اول memoization
روش اول memoization


مزیت روش اول این است که همیشه یک خانه از حافظه را ذخیره میکند ولی درصورتی که تابع هر بار با ورودی های متفاوتی صدا زده شود مجبور به محاسبه مجدد می شود.

روش دوم: در این روش، برای هر آرگومان ورودی که تابع را فراخوانی می‌کند، یک key ایجاد می‌شود و output تابع با آن key ذخیره می‌شود. در دفعات بعدی که تابع با همان آرگومان فراخوانی می‌شود، ابتدا key مربوط به آن آرگومان بررسی می‌شود. در صورتی که key وجود داشته باشد، خروجی تابع از حافظه برگردانده می‌شود. در غیر این صورت، تابع محاسبه می‌شود و خروجی آن ذخیره می‌شود.

روش دوم memiozation
روش دوم memiozation


به دلیل اینکه این روش به ازای هر آرگومان متفاوت، خروجی تابع را ذخیره می‌کند، در استفاده از آن باید میزان فضای اشغال شده حافظه را در نظر گرفت. در ادامه پیاده سازی تابع memoize با استفاده از هر دو روش را مشاهده میکنید:

پیاده سازی روش اول
پیاده سازی روش اول


پیاده سازی روش دوم
پیاده سازی روش دوم


مهمترین نکته ای که باید همیشه در ذهنتان باشد:

مموایز کردن یک وسیله برای پایین آوردن هزینه زمان تابع در عوض افزایش هزینه فضا است؛ که به این معنی است که توابع مموایز شده برای افزایش سرعت در عوض استفادهٔ بیشتر از حافظه کامپیوتر بهینه می‌شوند.


کاربرد های Memoization در React

کاربرد های Memoization در React برای جلوگیری از رندر مجدد کامپوننت ها، محاسبه مقدار تابع و حفظ رفرنس قبلی تابع یا Object است.

استفاده از React.memo

با memo شما میتوانید از رندر مجدد کامپوننت جلوگیری کنید. همچنین، می‌توانید با استفاده از تابع arePropsEqual تعیین کنید که کامپوننت با تغییر چه props‌هایی رندر شود. کد زیر را در نظر بگیرید:

رندر کامپوننت ExpensiveTree سنگین است و زمان زیادی طول می‌کشد تا این کامپوننت رندر شود. در کامپوننت App، استیت counter وجود دارد که با کلیک بر روی دکمه، باعث افزایش counter و رندر مجدد App می‌شود. همانطور که می‌دانید، در فرایند رندر کامپوننت، فرزندان آن کامپوننت نیز مجددا رندر می‌شوند. در حالی که ExpensiveTree مستقل از مقدار counter هست و نیازی به رندر مجدد ندارد با استفاده از memo میتوان جلوی رندر آن را گرفت.

تابع memo یک کامپوننت جدید React بازگردانده است. عملکرد آن مانند کامپوننتی است که به memo ارائه شده است، با این تفاوت که هنگام رندر کامپوننت پدر، فقط در صورتی که props های کامپوننت فرزند تغییر کرده باشد، آن را رندر میکند.

بدون استفاده از memo
بدون استفاده از memo
با استفاده از memo
با استفاده از memo

تابع arePropsEqual

معمولاً نیازی نیست از این تابع استفاده کنید، زیرا مقدار پیش فرض React کافی است. این تابع props قبلی و فعلی کامپوننت را به عنوان ورودی می‌گیرد و وظیفه آن مقایسهٔ این دو مقدار است. تابع پیش فرض React تمامی مقادیر props را با استفاده از object.is (که معادل === است) مقایسه می‌کند. اگر یکی از property‌ها تغییر کرده باشد، تابع مقدار false را برمی‌گرداند و کامپوننت مجدداً رندر می‌شود. شما می‌توانید این تابع را تغییر دهید تا کامپوننت فقط در صورتی که یک property خاص تغییر کرده باشد، مجدداً رندر شود.

استفاده از useMemo

با استفاده از این هوک می‌توانید مقدار یک تابع را محاسبه کنید و برای رندرهای بعدی از مقدار کش شدهٔ آن تابع استفاده کنید. این کار مشابه روش اول memization است که در ابتدای مقاله به آن اشاره کردیم. توجه داشته باشید که کالبکی که به useMemo می‌دهید نیازی به آرگومان ورودی ندارد. useMemo در هنگام mounting (رندر اول) این تابع را صدا می‌زند و مقدار آن را کش می‌کند. آرگومان دوم useMemo یک آرایه از وابستگی‌ها (dependency) است که تعیین می‌کند در صورت تغییر کدام آیتم‌های کامپوننت، مقدار تابع دوباره محاسبه و کش شود.

محاسبه اعداد فرد بدون useMemo
محاسبه اعداد فرد بدون useMemo


در تصویر بالا، کامپوننت ExpensiveList یک لیست ۱۰۰,۰۰۰ تایی از اعداد را فیلتر می‌کند و تعداد اعداد فرد را در صفحه چاپ می‌کند. این محاسبه سنگین در هر بار رندر شدن کامپوننت انجام می‌شود. با این حال، اگر لیست تغییر نکند، نیازی به محاسبه مجدد این مقدار نیست.

محاسبه اعداد فرد با استفاده از useMemo
محاسبه اعداد فرد با استفاده از useMemo

در تصویر بالا، کامپوننت ExpensiveList یک لیست ۱۰۰,۰۰۰ تایی از اعداد را فیلتر می‌کند و لیست جدید را کش می‌کند. سپس، تعداد اعداد فرد را در صفحه چاپ می‌کند. در رندرهای بعدی، کامپوننت تعداد را از لیست کش شده محاسبه می‌کند. در ادامه، تفاوت زمانی این بهبود را مشاهده می‌کنید.

محاسبه اعداد فرد بدون useMemo
محاسبه اعداد فرد بدون useMemo
محاسبه اعداد فرد با استفاده از useMemo
محاسبه اعداد فرد با استفاده از useMemo

توجه کنید مقدار اولیه محاسبه شده تفاوت زیادی باهم ندارند در نتیجه:

هوک useMemo باعث سریع‌تر شدن رندر اولیه نمی‌شود. این فقط به شما کمک می‌کند تا کارهای غیر‌ضروری را در رندر مجدد حذف کنید.

کد دوم (محاسبه اعداد فرد با استفاده از useMemo) یک مشکل دارد: در صورتی که لیست ما، لیست داینامیک بود و اعداد آن تغییر می‌کرد، مقدار جدید محاسبه نمی‌شد. برای رفع این مشکل، باید لیست جدید را به عنوان dependency به useMemo اضافه کنیم. مانند تصویر زیر:

آرایه‌ی وابستگی(dependency array)

وابستگی (dependency) یک آرایه از مقادیر یا متغیرهای کامپوننت است که معمولاً به عنوان آرگومان دوم به بعضی از هوک‌های React مانند useEffect، useMemo و ... داده می‌شود. React در هر رندر، با مقایسه مقادیر این آرایه با مقادیر رندر قبلی، متوجه تغییرات می‌شود و در صورت تغییر، افکت یا محاسبه کامپوننت را دوباره اجرا می‌کند. توجه داشته باشید که این مقایسه با استفاده از Object.is (مانند ===) انجام می‌شود. بنابراین، اگر یک آبجکت را به عنوان آیتم این لیست در نظر بگیرید، فقط در صورتی که رفرنس آنها برابر باشد، React تشخیص می‌دهد که آبجکت تغییر نکرده است.

Object.is
Object.is

همانطور که در تصویر بالا مشاهده می‌کنید، متغیرهای a و b هر دو مقدار یک آبجکت خالی را دارند، اما به دلیل اینکه ارجاع(reference) آنها متفاوت است، Object.is() مقدار false را برمی‌گرداند. در مقابل، متغیرهای a و c به یک آبجکت در حافظه اشاره می‌کنند، بنابراین برابر هستند و Object.is() مقدار true را برمی‌گرداند.

در کد بالا، آرایه items که به کامپوننت Tab داده شده است، در هر بار رندر دوباره ساخته می‌شود. این کار باعث می‌شود که رفرنس آرایه تغییر کند. در نتیجه، اگر این آرایه به عنوان dependency در یک کامپوننت فرزند استفاده شود، همواره نتیجه مقایسه Object.is برابر با false خواهد بود. بنابراین، React افکت یا تابع هوک مربوطه را دوباره اجرا می‌کند. برای مثال:

در نمونه کد بالا، به دلیل اینکه آرایه props.items در هر بار رندر، رفرنس آن تغییر می‌کند، مقدار tabItems نیز هر بار محاسبه می‌شود.

مواردی مانند نمونه بالا زیاد اتفاق می‌افتند. بسته به شرایط، می‌توان آرایه را به بیرون از کامپوننت منتقل کرد، به درون کامپوننت منتقل کرد یا با استفاده از هوک useMemo، رفرنس آرایه را برای رندرهای بعدی یکسان نگه داشت. در ادامه، نمونه منتقل کردن آرایه به بیرون از کامپوننت را میبیند:

از آنجایی که آرایه items به صورت استاتیک مقداردهی شده است و تغییر نمی‌کند، می‌توان آن را به بیرون از کامپوننت منتقل کرد. در این صورت، آرایه فقط در هنگام import ماژول ساخته می‌شود و رفرنس آن در طول برنامه تغییر نمی‌کند. بنابراین، useMemo فقط در رندر اول مقدار آرایه را محاسبه می‌کند.

استفاده از useCallback

با استفاده از هوک useCallback می‌توان رفرنس یک تابع را در بین رندرها نگهداری کرد. این کار مشابه روش اول memoization است، با این تفاوت که مقداری که باید کش شود، رفرنس یک تابع است. تابعی که به useCallback می‌دهید صدا زده نمی‌شود، بلکه رفرنس آن تابع نگهداری می‌شود. این کار می‌تواند برای مواقعی که تابعی را به عنوان dependency در آرایه وابستگی قرار می‌دهید یا به عنوان property کامپوننت پاس می‌دهید، مفید باشد. آرگومان دوم useCallback یک آرایه از وابستگی‌ها (dependency) است که تعیین می‌کند در صورت تغییر کدام آیتم‌های کامپوننت، رفرنس جدید نگهداری شود.

بدون استفاده از useCallback
بدون استفاده از useCallback

کد بالا باعث می‌شود که با هر بار رندر شدن کامپوننت، تابع makeHeavyRequest جدیدی تعریف شود. در نتیجه، رفرنس این تابع متفاوت است و useEffect دوباره صدا زده می‌شود.

بدون استفاده از useCallback
بدون استفاده از useCallback

اگر در کد بالا از useCallback استفاده کنیم:

با استفاده از useCallback
با استفاده از useCallback

در کد بالا، رفرنس تابع makeHeavyRequest در بین رندرها یکسان باقی می‌ماند. از آنجایی که آرایه وابستگی useEffect نیز در بین رندرها یکسان است، useEffect فقط یک بار اجرا می‌شود.

با استفاده از useCallback
با استفاده از useCallback

چرا نباید در همه جا استفاده کنیم

اگر استفاده از memo در همه جا منطقی بود، React تمامی کامپوننت‌ها و هوک‌های خود را با همین پیشفرض توسعه می‌داد.

کاهش خوانایی کد

هنگام خواندن کدی که بخشی از آن توسط useMemo یا useCallback احاطه شده است، باید به دو نکته توجه کنید:

بررسی Dependencyها: باید بررسی کنید که کدام متغیرها یا توابع در dependency ها قرار گرفته‌اند. و چه زمانی این مقادیر تغییر میکنند.

بررسی هنگام استفاده: هنگام استفاده از خروجی این هوک‌ها، باید توجه کنید که از مقدار جدید یا مقدار کش شده استفاده می‌کند. یعنی دقت به dependency ها نه تنها در زمان نوشتن، در زمان استفاده نیز باید انجام داد.

فرایند توسعه را پیچیده میکند

هنگام استفاده از memoization باید دقت کنیم که مقادیر آرایه وابستگی به درستی تغییر کند. مخصوصاً در مواردی که از آبجکت‌ها، توابع یا آرایه‌ها به عنوان dependency استفاده می‌کنیم. این توجه فقط برای نوشتن اولیه کد نیست، بلکه با هر بار تغییر نیز باید به این موضوع دقت شود. نمونه مثال زیر:

در مثال بالا، اگر استایل را به عنوان props به کامپوننت ExpensiveTree پاس دهیم، به دلیل اینکه آبجکت style در هر رندر ساخته می شود، کامپوننت ExpensiveTree نیز در هر رندر مجدداً رندر می‌شود. این اتفاق باعث می‌شود که memo کاربرد خود را از دست بدهد. برای جلوگیری از این اتفاق، می‌توان از useMemo برای نگهداری مقدار style استفاده کرد.

ولی با این تغییر، هنگام توسعه در آینده باید به هر دو بخش، Props های کامپوننت ExpensiveTree و آرایه وابستگی useMemo دقت کرد و خیلی ساده در ریفکتور بعدی، میتواند این ساختار شکسته شود مثلا اگر در ریفکتور بعدی، تصمیم گرفته شود style از کامپوننت parent گرفته شود، عملا استفاده از useMemo را بیهوده می‌کند. مانند:

احتمال حذف نشدن از حافظه

هنگامی که از هوک‌های useMemo یا useCallback استفاده می‌کنیم، مقدار اولیه آن‌ها در حافظه باقی می‌ماند، حتی اگر در رندرهای بعدی تغییر نکنند. این به این دلیل است که Garbage Collector نمی‌تواند مقدار اولیه را از حافظه پاک کند، زیرا هنوز به آن از طریق dependency ها یا سایر objects اشاره وجود دارد. اگر این مقدار اولیه در کامپوننت‌های دیگر یا objects دیگری که لایف تایم بیشتری از کامپوننت دارند استفاده شود، می‌تواند باعث memory leak شود. البته این مورد را نمیتوان فقط به این هوک‌ها اختصاص داد ولی به دلیل نحوه استفاده شون احتمال رخدادشون بیشتر است.

بعضی از راه های جایگزین

در هنگام توسعه در هر بخش از کد که تصمیم به استفاده از memoization گرفتید قبل از آن به راه حل های زیر نیز فکر کنید:

انتقال استیت به درون کامپوننت

می‌توان استیتی که باعث رندر مجدد کامپوننت می‌شود را به یک کامپوننت جداگانه منتقل کرد. به عنوان مثال، در کد ExpensiveTree می‌توان استیت counter را به یک کامپوننت جداگانه منتقل کرد:

با این تغییر با اپدیت شدن مقدار counter، فقط کامپوننت Counter رندر می شود.

انتقال محتوا به بالا (Lift Content Up)

اگر کامپوننت ما به نحوی بود که امکان انتقال به کامپوننت جداگانه را نداشت مانند مثال زیر:

می‌توان کامپوننت سنگین را به عنوان prop به آن کامپوننت داد. این روش جالبی است که به دلیل نحوه برخورد React با JSX به وجود آمده است. دلیل کامل این روش را می‌توانید در مقاله Kent C. Dodds ببینید.

همانطور که در ادامه می‌بینید، کامپوننت Counter رندر می‌شود، اما ExpensiveTree رندر نمی‌شود. دلیل این امر این است که رفرنس JSX که به عنوان prop به کامپوننت Counter داده شده است، با رندر قبلی یکسان است.

استفاده از State Management

در بعضی مواقع انتقال استیت به StateManagement و استفاده فقط در کامپوننتی که به آن نیاز دارد، میتواند راه حل خوبی به جای رندر کامپوننت پدر به همراه تمامی فرزندان باشد.


دیدگاه شخصی خودم هنگام توسعه

من در هنگام توسعه، از memoization به عنوان آخرین راه حل استفاده می‌کنم. اگر نتوانستم راه‌حل مناسبی برای یک مشکل پیدا کنم، آنگاه از memoization استفاده می‌کنم. البته، در یک پترن تکراری خاص، استثناء، ترجیح من استفاده از memoization هست. نمونه کد زیر را در نظر بگیرید:

این مورد را زیاد دیده‌ام. راه حل اولیه این است که مقدار ثابت را به initializer state بدهیم. این کار باعث می‌شود که مقدار در اولین رندر محاسبه شود و در رندرهای بعدی نیازی به محاسبه مجدد نباشد. اگر setState هیچ وقت صدا زده نشود و این مقدار فقط یک مقدار محاسباتی اولیه باشد، ترجیح من استفاده از useMemo است.

مورد دیگری که زیاد رخ میدهد اینکه مقدار state شما به مقدار props وابسته باشد و مانند مورد زیر:

می‌توان از useMemo برای بهبود کارایی این کد استفاده کرد. چرا؟ چون با تغییر props در کد بالا، دو بار رندر رخ می‌دهد. یک رندر به دلیل تغییر props است و رندر بعدی به دلیل تغییر مقدار state است.در کد زیر هوک های useState و useEffect حذف می شوند:

پایان

من تلاش کردم تا تمام آنچه را که در مورد memoization می‌دانستم و طی چند سال تجربه کرده‌ام، در این مقاله بنویسم. برای این کار، از منابع مختلف نیز استفاده کردم. مطلب بالا خلاصه‌ای از تجربه خودم و مطالبی است که مطالعه کرده‌ام. ممنون می‌شوم نظرات شما را بشنوم و اگر جایی از مطلب نکته‌ای ایرادی وجود دارد، به من اطلاع دهید.

منابع

https://react.dev

https://overreacted.io/before-you-memo/

https://kentcdodds.com/blog/optimize-react-re-renders

https://tkdodo.eu/blog/the-uphill-battle-of-memoization


Memoizationjavascriptreact hooks
Software Engineer | مثل اینکه کد میزنم!
شاید از این پست‌ها خوشتان بیاید