در این مقاله به بررسی memoization میپردازیم. memoization یک تکنیک قدرتمند است که میتواند باعث بهبود عملکرد کد شود. اما مهم است که این تکنیک را به درستی استفاده کنید. این مقاله طولانی است، اما برای درک درست این تکنیک، ارزش خواندن دارد.
ترجمه اش در فارسی "به خاطر سپردن" می شود، که یکی از تکنیکهای رایج بهینهسازی در برنامهنویسی است. به این صورت که تابع در اولین بار که صدا زده میشود، خروجی خود را محاسبه میکند و سپس آن را در حافظه ذخیره میکند. در دفعات بعدی صدا زدن تابع، به جای محاسبه مجدد خروجی، از خروجی ذخیره شده قبلی استفاده میشود.
پیادهسازی توابع Memoization معمولا به دو روش انجام میشود:
روش اول: ذخیرهسازی آخرین خروجی تابع است. در این روش، تابع در هنگام فراخوانی خروجی خود را محاسبه و در حافظه ذخیره میکند. در دفعهی بعدی که تابع با همان آرگومان فراخوانی میشود، به جای محاسبه مجدد، از خروجی ذخیره شده قبلی استفاده میشود. در صورتی که تابع با argument متفاوتی صدا زده شود مقدار خروجی دوباره محاسبه میگردد و جایگزین مقدار قبلی می شود.
مزیت روش اول این است که همیشه یک خانه از حافظه را ذخیره میکند ولی درصورتی که تابع هر بار با ورودی های متفاوتی صدا زده شود مجبور به محاسبه مجدد می شود.
روش دوم: در این روش، برای هر آرگومان ورودی که تابع را فراخوانی میکند، یک key ایجاد میشود و output تابع با آن key ذخیره میشود. در دفعات بعدی که تابع با همان آرگومان فراخوانی میشود، ابتدا key مربوط به آن آرگومان بررسی میشود. در صورتی که key وجود داشته باشد، خروجی تابع از حافظه برگردانده میشود. در غیر این صورت، تابع محاسبه میشود و خروجی آن ذخیره میشود.
به دلیل اینکه این روش به ازای هر آرگومان متفاوت، خروجی تابع را ذخیره میکند، در استفاده از آن باید میزان فضای اشغال شده حافظه را در نظر گرفت. در ادامه پیاده سازی تابع memoize با استفاده از هر دو روش را مشاهده میکنید:
مهمترین نکته ای که باید همیشه در ذهنتان باشد:
مموایز کردن یک وسیله برای پایین آوردن هزینه زمان تابع در عوض افزایش هزینه فضا است؛ که به این معنی است که توابع مموایز شده برای افزایش سرعت در عوض استفادهٔ بیشتر از حافظه کامپیوتر بهینه میشوند.
کاربرد های Memoization در React برای جلوگیری از رندر مجدد کامپوننت ها، محاسبه مقدار تابع و حفظ رفرنس قبلی تابع یا Object است.
با memo شما میتوانید از رندر مجدد کامپوننت جلوگیری کنید. همچنین، میتوانید با استفاده از تابع arePropsEqual تعیین کنید که کامپوننت با تغییر چه propsهایی رندر شود. کد زیر را در نظر بگیرید:
رندر کامپوننت ExpensiveTree سنگین است و زمان زیادی طول میکشد تا این کامپوننت رندر شود. در کامپوننت App، استیت counter وجود دارد که با کلیک بر روی دکمه، باعث افزایش counter و رندر مجدد App میشود. همانطور که میدانید، در فرایند رندر کامپوننت، فرزندان آن کامپوننت نیز مجددا رندر میشوند. در حالی که ExpensiveTree مستقل از مقدار counter هست و نیازی به رندر مجدد ندارد با استفاده از memo میتوان جلوی رندر آن را گرفت.
تابع memo یک کامپوننت جدید React بازگردانده است. عملکرد آن مانند کامپوننتی است که به memo ارائه شده است، با این تفاوت که هنگام رندر کامپوننت پدر، فقط در صورتی که props های کامپوننت فرزند تغییر کرده باشد، آن را رندر میکند.
معمولاً نیازی نیست از این تابع استفاده کنید، زیرا مقدار پیش فرض React کافی است. این تابع props قبلی و فعلی کامپوننت را به عنوان ورودی میگیرد و وظیفه آن مقایسهٔ این دو مقدار است. تابع پیش فرض React تمامی مقادیر props را با استفاده از object.is (که معادل === است) مقایسه میکند. اگر یکی از propertyها تغییر کرده باشد، تابع مقدار false را برمیگرداند و کامپوننت مجدداً رندر میشود. شما میتوانید این تابع را تغییر دهید تا کامپوننت فقط در صورتی که یک property خاص تغییر کرده باشد، مجدداً رندر شود.
با استفاده از این هوک میتوانید مقدار یک تابع را محاسبه کنید و برای رندرهای بعدی از مقدار کش شدهٔ آن تابع استفاده کنید. این کار مشابه روش اول memization است که در ابتدای مقاله به آن اشاره کردیم. توجه داشته باشید که کالبکی که به useMemo میدهید نیازی به آرگومان ورودی ندارد. useMemo در هنگام mounting (رندر اول) این تابع را صدا میزند و مقدار آن را کش میکند. آرگومان دوم useMemo یک آرایه از وابستگیها (dependency) است که تعیین میکند در صورت تغییر کدام آیتمهای کامپوننت، مقدار تابع دوباره محاسبه و کش شود.
در تصویر بالا، کامپوننت ExpensiveList یک لیست ۱۰۰,۰۰۰ تایی از اعداد را فیلتر میکند و تعداد اعداد فرد را در صفحه چاپ میکند. این محاسبه سنگین در هر بار رندر شدن کامپوننت انجام میشود. با این حال، اگر لیست تغییر نکند، نیازی به محاسبه مجدد این مقدار نیست.
در تصویر بالا، کامپوننت ExpensiveList یک لیست ۱۰۰,۰۰۰ تایی از اعداد را فیلتر میکند و لیست جدید را کش میکند. سپس، تعداد اعداد فرد را در صفحه چاپ میکند. در رندرهای بعدی، کامپوننت تعداد را از لیست کش شده محاسبه میکند. در ادامه، تفاوت زمانی این بهبود را مشاهده میکنید.
توجه کنید مقدار اولیه محاسبه شده تفاوت زیادی باهم ندارند در نتیجه:
هوک useMemo باعث سریعتر شدن رندر اولیه نمیشود. این فقط به شما کمک میکند تا کارهای غیرضروری را در رندر مجدد حذف کنید.
کد دوم (محاسبه اعداد فرد با استفاده از useMemo) یک مشکل دارد: در صورتی که لیست ما، لیست داینامیک بود و اعداد آن تغییر میکرد، مقدار جدید محاسبه نمیشد. برای رفع این مشکل، باید لیست جدید را به عنوان dependency به useMemo اضافه کنیم. مانند تصویر زیر:
وابستگی (dependency) یک آرایه از مقادیر یا متغیرهای کامپوننت است که معمولاً به عنوان آرگومان دوم به بعضی از هوکهای React مانند useEffect، useMemo و ... داده میشود. React در هر رندر، با مقایسه مقادیر این آرایه با مقادیر رندر قبلی، متوجه تغییرات میشود و در صورت تغییر، افکت یا محاسبه کامپوننت را دوباره اجرا میکند. توجه داشته باشید که این مقایسه با استفاده از Object.is (مانند ===) انجام میشود. بنابراین، اگر یک آبجکت را به عنوان آیتم این لیست در نظر بگیرید، فقط در صورتی که رفرنس آنها برابر باشد، React تشخیص میدهد که آبجکت تغییر نکرده است.
همانطور که در تصویر بالا مشاهده میکنید، متغیرهای 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 میتوان رفرنس یک تابع را در بین رندرها نگهداری کرد. این کار مشابه روش اول memoization است، با این تفاوت که مقداری که باید کش شود، رفرنس یک تابع است. تابعی که به useCallback میدهید صدا زده نمیشود، بلکه رفرنس آن تابع نگهداری میشود. این کار میتواند برای مواقعی که تابعی را به عنوان dependency در آرایه وابستگی قرار میدهید یا به عنوان property کامپوننت پاس میدهید، مفید باشد. آرگومان دوم useCallback یک آرایه از وابستگیها (dependency) است که تعیین میکند در صورت تغییر کدام آیتمهای کامپوننت، رفرنس جدید نگهداری شود.
کد بالا باعث میشود که با هر بار رندر شدن کامپوننت، تابع makeHeavyRequest جدیدی تعریف شود. در نتیجه، رفرنس این تابع متفاوت است و useEffect دوباره صدا زده میشود.
اگر در کد بالا از useCallback استفاده کنیم:
در کد بالا، رفرنس تابع makeHeavyRequest در بین رندرها یکسان باقی میماند. از آنجایی که آرایه وابستگی useEffect نیز در بین رندرها یکسان است، useEffect فقط یک بار اجرا میشود.
اگر استفاده از 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 رندر می شود.
اگر کامپوننت ما به نحوی بود که امکان انتقال به کامپوننت جداگانه را نداشت مانند مثال زیر:
میتوان کامپوننت سنگین را به عنوان prop به آن کامپوننت داد. این روش جالبی است که به دلیل نحوه برخورد React با JSX به وجود آمده است. دلیل کامل این روش را میتوانید در مقاله Kent C. Dodds ببینید.
همانطور که در ادامه میبینید، کامپوننت Counter رندر میشود، اما ExpensiveTree رندر نمیشود. دلیل این امر این است که رفرنس JSX که به عنوان prop به کامپوننت Counter داده شده است، با رندر قبلی یکسان است.
در بعضی مواقع انتقال استیت به StateManagement و استفاده فقط در کامپوننتی که به آن نیاز دارد، میتواند راه حل خوبی به جای رندر کامپوننت پدر به همراه تمامی فرزندان باشد.
من در هنگام توسعه، از memoization به عنوان آخرین راه حل استفاده میکنم. اگر نتوانستم راهحل مناسبی برای یک مشکل پیدا کنم، آنگاه از memoization استفاده میکنم. البته، در یک پترن تکراری خاص، استثناء، ترجیح من استفاده از memoization هست. نمونه کد زیر را در نظر بگیرید:
این مورد را زیاد دیدهام. راه حل اولیه این است که مقدار ثابت را به initializer state بدهیم. این کار باعث میشود که مقدار در اولین رندر محاسبه شود و در رندرهای بعدی نیازی به محاسبه مجدد نباشد. اگر setState هیچ وقت صدا زده نشود و این مقدار فقط یک مقدار محاسباتی اولیه باشد، ترجیح من استفاده از useMemo است.
مورد دیگری که زیاد رخ میدهد اینکه مقدار state شما به مقدار props وابسته باشد و مانند مورد زیر:
میتوان از useMemo برای بهبود کارایی این کد استفاده کرد. چرا؟ چون با تغییر props در کد بالا، دو بار رندر رخ میدهد. یک رندر به دلیل تغییر props است و رندر بعدی به دلیل تغییر مقدار state است.در کد زیر هوک های useState و useEffect حذف می شوند:
من تلاش کردم تا تمام آنچه را که در مورد memoization میدانستم و طی چند سال تجربه کردهام، در این مقاله بنویسم. برای این کار، از منابع مختلف نیز استفاده کردم. مطلب بالا خلاصهای از تجربه خودم و مطالبی است که مطالعه کردهام. ممنون میشوم نظرات شما را بشنوم و اگر جایی از مطلب نکتهای ایرادی وجود دارد، به من اطلاع دهید.
https://overreacted.io/before-you-memo/
https://kentcdodds.com/blog/optimize-react-re-renders
https://tkdodo.eu/blog/the-uphill-battle-of-memoization