شهریار
شهریار
خواندن ۱۴ دقیقه·۴ سال پیش

نگاهی عمیق‌تر به هوک‌های Memoize در React

هوک‌های useCallback و useMemo هوک‌هایی هستند که توی دسته هوک‌های Memoize قرار میگیرند. یعنی اطلاعات رو در خودشون نگه می‎دارند.

بهترین راه برای درک بهتر و کامل هوک‌هایی که "به یاد میسپارند"، این هست که خودمون رو در معرض مشکلی که قراره این هوک‌ها حل کنند قرار بدیم.

مثال‌های اولیه از هوک‌ها خیلی این مشکلات رو نمایان نمی‌کنند. اون مثال‌ها یکجورایی هوک‌هارو خیلی منظم و راحت نشون میدند - که البته منطقی هم هست.

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


چرا Memoize Hooks ؟

خیلی ساده - این هوک‌ها قرار هست چیزی رو به خاطرشون بسپرند.

این ساده‌ترین راهیه که میشه این هوک‌هارو تعریف کرد. سوال‌های اصلی و تیزبینانه اینها هستند:

  1. چرا باید چیزی یادشون بمونه؟
  2. چی به یادشون میمونه؟
  3. کِی دوباره به یاد میارند؟

توی این مقاله تلاشم این هست که با پاسخ به این سه سوال، هوک‌های Memoize رو بهتر بشناسیم.


1. چرا هوک‌ها ( در ری‌اکت ) نیاز دارند که به یاد داشته باشند؟

برای اینکه بهتر درک کنیم که چرا نیازه که هوک‌ها به یاد بسپرند ( memoize )، باید در درجه اول انگیزه‌‌ی پشت سر این اتفاق در ری‌اکت رو بهتر درک کنیم. Memoization در برنامه‌نویسی استراتژی محاسباتی هست که توابع، مقدار خروجی اجرای قبلی خودشون رو " به یاد نگه می‌دارند " و ازش به عنوان یک معیار برای محاسباتی بعدی خودشون استفاده می‌کنند.

اگر برای چندماهی ری‌اکت نوشته باشید، احتمالا با shouldComponentUpdate و یا PureComponent برخورد داشتید. با کمک این کامپوننت‌ها، تا زمانی که تغییرات state مستقیما بر رابط کاربری (
UI ) تاثیری نداشته باشه، بارگذاری مجددی ( re-rendering ) رخ نمیده.

کاملا منطقیه، چون به عنوان مثال اگر کامپوننت App ما ساختاری به شکل زیر داشته باشه:

با فرض اینکه Input ما باعث آپدیت state توی App بشه، کل درخت کامپوننت‌های ما ری-رندر میشه. کامپوننت List بارگذاری مجدد میشه، ListItem هم همینطور! محض رضای خدا شاید ما فقط یه چک باکس روی توی Input تیک زده باشیم، چرا باید کل کامپوننت ها ری-رندر بشه؟

باشه، شاید من خیلی دارم جو میدم، این ساختار خیلی کوچیکتر از اونیه که ما بخوایم نگران پرفورمنس و بازدهیش باشیم. اما چی می‌شد اگر ما 5 تا 10 تا کامپوننت تو در توی دیگه داشتیم؟ اون زمان فکر کنم باید نگران پرفورمنس باشیم.

یک مثال دیگه ای داریم که بهتر نشون میده چه اتفاقی داره میوفته. این صفحه رو باز کنید و بعدش React Dev Tools مرورگرتون رو اجرا کنید و به تنظیمات برید و گزینه‌ی "Highlight Updates" رو تیک بزنید.

زمانی دکمه‌‌های افزایش یا کاهش رو کلیک کنید، میبینید که تک تک کامپوننت‌ها فلش میزنند؛ که یعنی دارند ری-رندر میشند :)

دقیق‌تر به دکمه‌ها نگاه کنید. ببینید که چطور با کادر آبی نشون میدند که دارند ری-رندر میشند. اگر درست بخوایم در نظر بگیریم، تنها کامپوننتی که باید ری-رندر بشه، کامپوننت Counter هست که مسئول نمایش عدد نهاییه. کامپوننت‌های Button نباید دوباره بارگذاری بشند.

همونطور که قبلا هم گفتیم، ری‌اکت از shouldComponentUpdate و یا PureComponent برای کنترل کردن آپدیت‌هایی بدین شکل استفاده میکنه. با کمک اینها ما میتونیم کامپوننت Button رو طوری بازنویسی کنیم که زمانی که Count تغییر میکنه، این کامپوننت مجدد ری-رندر نشه:

من دارم به Button با کمک shouldComponentUpdate میگه که " هی! مهم نیست که چه اتفاقی داره میوفته، فقط زمانی دوباره ری-رندر بشو که padding تغییر کرده باشه! " . که یعنی زمانی که count داره تغییر میکنه، دکمه‌های ما دیگه ری-رندر نمیشند.

اگر نگران مقایسه‌ی سطحی به شکل this.props.padding !== nextProps.padding هستید ( که بیشتر اوقات باعث نگرانی هم هست ) میتونید از PureComponent استفاده کنید:

این ساختار با توجه به چیزی که توی مستندات گفته شده باید کار کنه، ولی بنا به دلایلی برای مثالی که ما اینجا داریم کار نمیکنه. اگر شما دکمه افزایش یا کاهش رو کلیک کنید، میبینید که هردو کامپوننت Button دارند فلش میزنند که یعنی دارند ری-رندر میشند. کجا رو اشتباه کردیم؟

باگ ما تابع هست که به شکل props توسط App داره به Button پاس داده میشه. هربار که ری-رندری توی App رخ میده، یک ورژن جدیدی از ساخته میشه و به Button داده میشه. برای همینم Button دوباره ری-رندر میشه.

چطور ما این تابع رو کَش کنیم؟ ( cache, ذخیره کنیم؟ )، شاید روش قدیمی bind کردن با کمک this یادتون باشه؟ میتونیم با کمک this این تابع رو توی constructor گره بزنیم. این ترفند درستش میکنه!

تبدیل arrow function به یک instance function و گره زدنش با this در constructor. با اینکار میشه تابع رو نبست به رندرهای متعاقب کـَش کرد. کاری کردیم که تابع مقدار آخرین محاسبات خودش رو "به یاد داشته باشه".

اینجا میتونید نسخه دمو رو مشاهده کنید
اینجا میتونید نسخه دمو رو مشاهده کنید


دو نکته‌ای که از این مشاهدمون بدست آوردیم اینه که:

  1. دیدیم که چطور میتونیم با کمک shouldComponentUpdate و PureComponent ری-رندر شدن کامپوننت رو کنترل کنیم.
  2. همچنین دیدیم که چطور میتونیم توابع رو memoize کنیم تا از ری-رندر شدن الکیشون جلوگیری بشه.

مسئله اینجاست که این دو نکته تنها در class component ها امکان پذیر هستند و در functional component ما نمیتونیم چنین ساختاری داشتیم باشیم، چون:

  1. فانکشنال کامپوننت‌ها نمیتونند instance از متد‌ها داشته باشند، پس sCU در کار نیست.
  2. فاکنشنال کامپوننت‌ها نمیتونند کلاس‌های دیگه ای رو Extend کنند، پس خبری از PureComponent نیست
  3. فانکشنال کامپوننت‌ها constractor ندارند، پس نمیتونیم متد‌ها رو اونجا کش کنیم.

هوک‌های Memoize به فانکشنال کامپوننت‌ها کمک می‌کنند که بتونند از پس چنین چالشی بر بیاند

این جواب سوال اول مارو میده: چرا این هوک‌ها نیاز دارند که به یاد داشته باشند؟ چرا ما به memoize کردن احتیاج داریم؟

یک جدولی درست کردم از مقایسه memoization با هوک‌ها در مقابل کلاس کامپوننت‌ها

2. چرا این هوک‌ها به یاد میسپارند؟

سوال اول تقریبا جواب این سوال رو هم به شکل ضمنی داد. نیازه که این هوک‌ها ( Memoized Hooks ) اطلاعات و یا توابع رو به خاطر بسپارند تا:

  1. از ری-رندر شدن غیرضرروی جلوگیری کنند
  2. اطلاعات و یا توابع رو نگه میدارند ( کش / cache کنند ) تا کپی جدیدی ازشون ساخته نشه


3. کی به یاد می‌آورند؟

جواب این سوال تقریبا خیلی مشخصه - زمان ری-رندر به یاد میارند.


رابطه بین Data Hooks و Memoize Hooks

هوک‌های اطلاعات، هوک‌هایی هستند که اطلاعات رو در خودشون ذخیره می‌کنند. ذخیره کردن با نگه‌داشتن اطلاعات متفاوته. شما اطلاعاتی که قسمتی از تغییرات UI بهش وابسته هست رو ذخیره میکنید ( store ) و بخشی از اطلاعاتی که اون قسمت از UI مستقیما باهاش در ارتباط نیست رو به شکل کَش نگه می‌دارید.

یک خط مبهم و باریک هم این وسط هست. useRef هوکیه که میتونه هر دو نقش رو بازی کنه. بستگی داره شما چطور ازش استفاده کنید.

بطور کلی useState و useRef ( و همچنین useReducer ) هوک‌های اطلاعات هستند و useCallback , useMemo هوک‌های کش کردن و memoize هستند. با کمک تصویر زیر بهتر میتونید محدوده کارایی این هوک‌ها رو ببینید:

کش کردن اطلاعات با کمک useMemo

هوک useMemo ذاتا یه هوک memoize ـه و اینطوری طراحی شده. بقیه هوک‌ها مثل useCallback و useRef یجورایی با توجه به شرایط اینجوری هستند. این هوک یه تابع رو با رفرنسی به مقادیر وابسته به اون تابع میگیره و فقط زمانی این تابع رو اجرا میکنه که اون مقادیر تغییر کرده باشند. نکته مهم: این هوک موقع اجرا، تابعی که در بر گرفته رو اجرا میکنه و مقدار برگشتی اون تابع رو برگشت میده.

مثالی که اول مقاله داشتیم رو دوباره به شکل فانکشنال بازنویسی کردم، و میبینید که دوباره برگشتیم به جایی که دکمه‌ها هم موقع آپدیت، ری-رندر میشند

میتونیم Button رو با تمام مقادیری که بهش پاس دادیم توی useMemo قرار بدیم، یا بهتر بگم میتونیم useMemo رو دور Button بکشیم. چون ما میخوایم که تحت هیچ شرایطی این دکمه‌ی ما ری-رندر نشه:

فقط دکمه‌ی increment رو با useMemo گرفتم و همونطور که فلشر‌های آپدیت نشون میدند، دکمه‌ی increment ما ری-رندر نمیشه و فقط Decrement داره هربار ری-رندر میشه:

اما یه سوتی اینجا رخ داده! وقتی که دکمه افزایش رو میزنیم، با اینکه ری-رندر نمیشه، ولی فقط یکبار عمل میکنه و دیگه کار نمیکنه!

خب باید بگم که اینکار رو عمدا انجام دادم تا بهتون نشون بدم که باگ‌هایی شاید با memoization رخ بده. من دارم تابعی رو memoize میکنم که کل کامپوننت رو برگشت میده، این یعنی نتیجه اولین محاسبات این تابع داره کش میشه و به همین دلیل هی به شما همون جواب رو میده. میتونید ببینید که count روی 1 گیر میکنه و هرچی شما کلیک میکنید، فقط 1 نمایش داده میشه.

اگر فقط میخواستیم یک مقدار استاتیک رو لاگ کنیم، یا میخواستیم یک عملیات رو برای یکبار انجام بدیم که این عملیات به مقدار state بعدی ما وابسته نبود، این راه بهترین راه برای انجام اینکار بود، برای مثال:

جمله‌ی ' I got clicked ' هربار لاگ میشه که این یعنی تابع همیشه اجرا میشه. مشکل اینجاست که تابع کش شده و از مقدار state بعدی اپلیکیشن شما بی خبره.

این مسئله رو با پاس دادن count به آرایه مقادیر وابسته‌ی useMemo میتونید حل کنید، ولی اینکار دوباره باعث ری-رندر شدن button ها میشه که خب یجورایی هدف ما از انجام memoize کردن رو از بین میبره.

ری‌اکت یک روش متفاوت دیگه‌ای داره که میتونه جایگزین PureComponent بشه، و اون memo هست. اگر ما کامپوننت Button رو با React.memo ( نه React.useMemo ) در بر بگیریم، مثل اینجا:

متاسفانه با اینکار هم حتی Button با هر کلیک ری-رندر میشه، چون همچنان تابع ما کش نشده. شاید راه حلی که به ذهنتون برسه اینه که میتونیم بجای اینکار از useCallback استفاده کنیم:

و بعد هم اینطوری به کامپوننتمون پاسش بدیم:

متاسفانه همچنان بازهم این روش کار نمیکنه چون اینبار count ما در closure میوفته و فقط به 1 آپدیت میشه و ما دوباره همون نتیجه‌ایی رو میگیریم که با useMemo داشتیم.

در حال حاضر تنها راهی که میشه انجام داد اینه که Button رو به کلاس کامپوننت برگردونیم و از shouldComponentUpdate و گره زدن توی constructor استفاده کنیم.

اینکه useMemo نتونست توی این شرایط کمکمون کنه دلیل بر این نیست که خوبی‌های خودش رو نداره. توی قسمت بعدی که میخوایم در مورد useCallback صحبت کنیم، مثال‌هایی رو میبینیم که با کمک useMemo میتونیم چطور توابع رو کش کنیم.

نقطه قوت useMemo بیشتر جاییه که شما یه محاسبات خیلی سنگینی دارید و نمیخواید که این محسبات هر دفعه اجرا بشه و فقط تنها زمانی اجرا بشه که مقدار جدیدی وارد معادله شده.

مقایسه تاثیر useMemo روی محاسبات سنگین رو توی این مثال ساده میتونید ببینید. همچنین Brian Holt هم توی این مثال فوق‌العاده تمام هوک‌های رایج رو باهم مقایسه کرده. ببینید که چطور useMemo و useCallback رو مثال زده.


کش کردن اطلاعات با useCallback

یه Counter دیگه‌ای رو در نظر بگیرید که به مقدار count وابسته نباشه، مثلا:

کامپوننت کانتر جدید ما و دکمه‌های داخلش با anotherCount کنترل میشه ، و کاملا بی ربط به کامپوننت counter اول ما هستند. فکر میکنید زمانی که یکی از چهارتا دکمه رو کلیک کنیم، چه اتفاقی میوفته؟ اینجا تست کنید

ازونجایی که state توی کامپوننت App ما داره کنترل میشه، هر تغییری چه توی count و یا anotherCount باعث ری-رندر شدن App میشه و این کامپوننت با تمام بچه‌هاش دوباره ری-رندر میشه

اگر ما ما تابع‌هایی که روی دکمه‌های count هستند رو با کمک useCallback کش کنیم، اینجوری میتونیم از ری-رندر شدن بی دلیل اونها جلوگیری کنیم:

با اینکار چیزی که داریم به ری‌اکت میگیم اینه که:

" ببین، زمانی که App شروع بعه ری-رندر کرد، چک کن ببین که count اگر تغییر کرده، فقط دکمه‌های count رو ری-رندر کن. اگر فقط anotherCount تغییر کرده، پس اینهارو دیگه ری-رندر نکن"

مقدار count به عنوان استدلال دوم به تابع useCallback پاس داده شده تا تنها در صورتی این تابع اجرا بشه که تغییری در count رخ داده باشه. اگر شما مقدار count رو پاس ندید، دوباره همون مشکلی که داشتیم رخ میده، یعنی هی ما توی هر رندر مقدار 1 رو دریافت میکنیم:

حتی میتونید کامپوننت Counter اولی رو با کمک React.memo تبدیل به PureComponent کنید تا زمانی که anotherCount تغییری کرد، این کامپوننت ری-رندر نشه.

تفاوت useMemo و useCallback:

1. هوک useMemo "مقدار" کش شده رو بازگشت میده؛ و زمانی که بخواد آپدیت بشه، فانکشن رو اجرا میکنه و دوباره "مقدار" جدید رو بازگشت میده

2. هوک useCallback "رفرنس به تابع" رو کش میکنه و اجازه ساخت تابع جدید رو نمیده، زمانی که آپدیتی رخ بده، این هوک "تابع" رو در اختیار شما قرار میده تا دوباره اجرا کنید، به همین دلیل با کمک useCallback شما میتونید argument های متفاوتی به هوک پاس بدید تا هربار فانکشن اونهارو حساب کنه.

همچنین گفتن این نکته حايز اهمیته که بدونیم:

با این شکل از useMemo، عملکرد یکسانی دارند:

پس میتونیم تابع‌هایی که با useCallback کش کرده بودیم رو اینطوری هم بنویسیم:

کش کردن اطلاعات با useRef

این هوک در اصل قرار بود مثل class ref عم کنه و درکل ref ها قرار بود که به ما توی دسترسی به DOM node ها کمک کنند.

توی تلاش اینکه این نقش توی هوک‌ها هم اجرا بشه، useRef یجورایی تبدیل به یه هوک خیلی قدرتمند شد که نه تنها میتونه به ما کمک کنه تا به DOM node ها دسترسی پیدا کنیم، بلکه:

  1. میتونه اطلاعات رو در خودش ذخیره کنه
  2. زمانی که تغییری توی اطلاعاتی که ذخیره کرده رخ میده، باعث ری-رندر نشه
  3. حتی زمانی که تغییر توی state رخ میده و useState ری-رندر میکنه، بازهم یادش بمونه که چه اطلاعاتی ذخیره داشته

خیلی خفنه نه؟ بریم چندتا مثال ببینیم:

برگردیم به همون اپ شمارنده خودمون و بیاین سعی کنیم که بازنویسیش کنیم. مثلا بجای decrementMemoizedCallback بیایم از useRef بجای useCallback استفاده کنیم:

هوک useRef میتونه یک مقدار اولیه بگیره، ولی اگر لازم نباشه میتونید بذارید خالی بمونه تا بعدا ست کنید.

توی حرکت بعد باید سعی کنید راهی پیدا کنید که تابع برگشتی از decrementMemoizedCallback فقط زمانی اجرا بشه که count تغییری کرده باشه. اینجا جاییه که useEffect میاد وسط:

میتونید از اینجا مثال آپدیت شده رو ببینید.
میتونید از اینجا مثال آپدیت شده رو ببینید.


میبینید که اولین دکمه Decrement یکبار فلش میزنه و بعد وایمیسته؟ این بخاطر اینه که قانون اول هوک useEffect اینه که حداقل یکبار اجرا بشه. مسلما useRef به قدرت useCallback و useMemo نیست ولی همچنان یه هوک خیلی پرقدرته. اگر توی شرایط درست و به شکل درست ازش استفاده بشه میتونه کلی کمک به ما بکنه. توی مثال بالا ما میتونیم به همون useCallback و یا useMemo برگردیم.




امیدوارم ازین مقاله لذت برده باشید، اگر سوالی بود حتما توی قسمت نظرات بپرسید تا باهم به نتیجه برسم.

این مقاله ترجمه‌ی این مقاله‌ی بسیار عالی بود و مقالات دیگه‌ رو میتونید از طریق لینک‌های زیر بخونید

https://coderlife.ir/classcomponent-vs-functionalcomponent-react-nmlyr2tns0eh
https://virgool.io/@shxhryar/hoisting-%D8%AF%D8%B1-%D8%AC%D8%A7%D9%88%D8%A7-%D8%A7%D8%B3%DA%A9%D8%B1%DB%8C%D9%BE%D8%AA-%D8%A8%D9%87-%D8%B2%D8%A8%D8%A7%D9%86-%D8%B3%D8%A7%D8%AF%D9%87-qliuupossapk
https://virgool.io/apieco/what-is-jamstack-m4b3ldc6tg3x


react hookusememofrontendreactjavascript
Musician / WebHead
شاید از این پست‌ها خوشتان بیاید