Memoize کردن کامپوننت React — از صفر تا صد
خاطرسپاری (Memoizing) یکی از قابلیتهای ارتقای عملکرد در فریمورک ریاکت محسوب میشود که با هدف افزایش سرعت فرایند رندر کامپوننت ارائه شده است. این تکنیک در طیف وسیعی از شرایط مختلف، از موتورهای بازی تا وباپلیکیشنها استفاده میشود. در این مقاله به بررسی تکنیکهای خاطرسپاری در ریاکت میپردازیم و در ادامه API-ها و برخی مثالهای کاربردی را بررسی میکنیم. با ما همراه باشید تا با روش Memoize کردن کامپوننت ریاکت آشنا شوید.
Memoizing در زمینه برنامهنویسی رایانه مفهوم شناختهشدهای به حساب میآید و هدف آن افزایش سرعت برنامهها از طریق «کش کردن» (Cashing) نتایج فراخوانی تابعهای پرهزینه و استفاده مجدد از این نتایج کش شده جهت جلوگیری از اجرای مکرر عملیات است.
با این که خاطرسپاری در اغلب موارد موجب صرفهجویی در چرخههای پردازشی میشود، اما در مورد میزان استفاده از آن محدودیتی وجود دارد که این محدودیت ناشی از ظرفیت حافظه سیستم است. زمانی که به فریمورک ریاکت نگاه میکنیم میبینیم که نتیجه متد ()render یک کامپوننت کش میشود و یا صرفاً JSX یک کامپوننت بازگشت مییابد.
خاطرسپاری را میتوان روی هر دو نوع کامپوننتهای کلاسی و تابعی استفاده کرد. این قابلیت در HOC و قلاب ریاکت پیادهسازی شده است که هر دو را در ادامه بررسی میکنیم.
در اغلب موارد بهتر است میزان کش شدن اپلیکیشن از طریق خاطرسپاری را به دقت زیر نظر بگیریم. با این که ممکن است شخصاً با محدودیت حافظه مواجه نشویم، اما اغلب دستگاههای موبایل حافظهای کمتر از سیستمهای دسکتاپ و لپتاپ دارند. خاطرسپاری در ریاکت ما را مطمئن نمیسازد که کامپوننتها کش خواهند شد، بلکه نهایت تلاش ما بر مبنای عواملی از قبیل منابع موجود محسوب میشود.
خاطرسپاری به واکنشگرایی UI کمک میکند
نمیتوان انکار کرد که UI سریع و واکنشگرا، امکانی عالی برای کاربر نهایی محسوب میشود. همچنین برای شناسایی برند با توجه تجربه ارائه شده بسیار مفید است. هر پاسخ UI که بیش از 100 میلیثانیه طول بکشد به چشم کاربر نهایی میآید. هدفگذاری برای پاسخدهی در طی 100 میلیثانیه یا کمتر برای رندر کامپوننت و به طور کلی بازخورد UI یک قاب زمانی ایدهآل برای عملکرد روان اپلیکیشن به حساب میآید. خاطرسپاری تنها یک تکنیک برای تضمین این نکته است که این حالت بدون تأخیر دوام مییابد.
در ادامه به بررسی بیشتر پیادهسازی Memoizing در ریاکت و سپس معرفی مثالهای کاربردی آن میپردازیم.
معرفی Memoizing با React.memo
Memoizing در ریاکت به طور عمده برای افزایش سرعت رندر و همچنین کاهش عملیات رندر، کش کردن نتیجه ()render کامپوننت در چرخه رندر اولیه و استفاده مجدد از آن با توجه به ورودیهای یکسان از قبیل props، حالت، مشخصههای کلاس، متغیرهای تابع و غیره است.
برای ذخیره صرفهجویی در این عملیات ()render و جلوگیری از تکرار و تولید یک نتیجه یکسان میتوانیم اقدام به کش کردن نتیجه متد ()render اولیه بکنیم و در رندر های بعدی کامپوننت به آن ارجاع بدهیم.
ممکن است فکر کنید React.PureComponent همین کار را انجام میدهد. با این که React.PureComponent قطعاً یک بهینهسازی عملکردی است، اما متد چرخه عمری ()componentShouldUdpdate را برای مقایسه props سطحی و مقایسه حالت از رندر قبلی استفاده میکند. در صورت تطبیق یافتن این مقایسهها، کامپوننت دیگر مجدداً رندر نخواهد شد.
1export class MyComponent extends React.PureComponent {
2 ...
3}
منظور از عبارت «سطحی» (shallow) در عبارت فوق این است که صرفاً props و state خود کامپوننت تست میشوند. props و state کامپوننتهای فرزند در زمان استفاده از React.PureComponent تست نمیشوند.
React.PureComponent صرفاً محدود به کامپوننتهای کلاس است و روی متدهای چرخه عمری و حالت تکیه دارد. برای جبران این ضعف، ریاکت یک API به نام React.memo معرفی کرده است. React.memo یک کامپوننت مرتبه بالا است که همان مقایسه سطحی را روی props کامپوننت اجرا میکند تا تشخیص دهد که آیا یک رندر مجدد پردازش میشود یا نه. این HOC میتواند در ادامه کامپوننتهای تابعی را نیز در خود جای دهد. بدین ترتیب میتوان یا API را مستقیماً درون کامپوننت قرار داد:
1const MyComponent = React.memo(function WrappedComponent(props) {
2 ...
3});
و یا یک کامپوننت و React.memo را مستقل از هم اعلان کرد:
1function WrappedComponent(props) {
2 ...
3}
4const MyComponent = React.memo(WrappedComponent);
React.memo این گزینه را نیز در اختیار ما قرار میدهد که تابع مقایسه خاص خود را به عنوان آرگومان دوم API ارائه کنیم و بدین ترتیب تسلط بیشتری روی تشخیص این که یک رفرش لازم است یا نه داشته باشیم:
1function myComparison(prevProps, nextProps) {
2 ...
3}
4export default React.memo(WrappedComponent, myComparison);
به طور معمول به یک کامپوننت که در یک HOC قرار گرفته است را WrappedComponent مینامیم. کامپوننت Memoize شده به صورت جداگانه تعریف شده و به عنوان default export اکسپورت میشود.
در ادامه چند سناریو را برای بررسی این که تابع مقایسه پیادهسازی شده یا نه بررسی میکنیم:
- شاید نیاز نباشد که همه مقادیر prop را بررسی کنیم تا ببینیم با prop-های قبلی تطبیق مییابند یا نه. برای نمونه اگر برخی props-ها برای مقداردهی به کامپوننت فرزند ارسال شده باشند، نیازی به مقایسه در رندهای بعدی وجود ندارد.
- در اپلیکیشنهای Reactive یعنی اپلیکیشنهایی که از پارادایم الگوی واکنشی (+) استفاده میکنند، ممکن است مشغول گوش دادن به سرویسهای بیرونی از قبیل رویدادهای وبسوکت یا یک فید RxJS باشیم و بخواهیم یک کامپوننت را صرفاً در مواردی رندر مجدد بکنیم که دادههای خاصی از سرور واکشی شده باشند. اساساً میتوانیم به متغیرهای سراسری درون myComparison ارجاع بدهیم و از این رو عوامل بیرونی میتوانند تعیین کنند که آیا کامپوننت باید رفرش شود یا نه.
- اگر با یک باگ در UI مواجه شویم، بهتر است یک false از myComparison بازگشت دهیم تا به طور موقت memorization را غیر فعال کنیم و یک رفرش را روی همه رندهای مجدد الزام کنیم تا رفتار پیشفرض کامپوننت به دست آید.
در مثال زیر به صورت تصادفی یک آرایه از نامها را انتخاب کرده و به کامپوننت NameShuffling ارسال میکنیم. در تصویر زیر میبینید که در این کامپوننت چه اتفاقی میافتد. NameShuffling تنها زمانی رندر مجدد میشود که prop به نام name تغییر یابد:
درون کامپوننت App از طریق تابع ()getName یک نام تصادفی به حالت اختصاص میدهیم:
1// class property housing names
2names = [
3 'Alice',
4 'Bob'
5];
6// randomly select a name
7getName = () => {
8 const index = Math.floor(Math.random() * (this.names.length - 1));
9 return (this.names[index]);
10}
مقدار نام در حالت به کامپوننت NameShuffling ارسال میشود. برای بهروزرسانی این مقدار دکمه Shuffle به فراخوانی ()getName میپردازد و نام حالت App را تعیین میکند.
NameShuffling کامپوننتی است که در این جا Memoize میکنیم. این یک کامپوننت تابعی است که درون React.memo قرار گرفته است:
1import React, { memo } from 'react';
2function WrappedComponent (props) {
3 console.log('Re-rendering');
4 return (
5 <h2>Selected Name: {props.name}</h2>
6 );
7}
8export const NameShuffling = memo(WrappedComponent);
9export default NameShuffling;
ما در این جا memo را از React ایمپورت کردیم، اما میتوانستیم از React.memo نیز استفاده کنیم. این تصمیم به طور کلی بر عهده توسعهدهنده است.
امکان نمایش تابع مقایسه نیز وجود دارد تا بررسی کنیم آیا prop نام از رندر قبلی متفاوت است یا نه. همچنین میتوانیم به متغیرهای سراسری و پردازشهای بیرونی ارجاع بدهیم:
1// some API outside the function scope
2const someGlobalVar = {
3 ready: 1
4};
5function areNamesEqual (prevProps, nextProps) {
6 return prevProps.name !== nextProps.name && someGlobalVar.ready === 1;
7}
در کد فوق، رندرهای مجدد را بیشتر محدود میکنیم و به مواردی محدود میشوند که someGlobalVar یک مقدار ready به میزان 1 بازگشت دهد و prop به نام name تغییر یافته باشد. اگر someGlobalVar یک پاسخ از سوی سرور باشد، در این صورت سرور میتواند در مورد زمان رندر شدن مجدد کامپوننت تصمیمگیری کند. این حالت در مواردی مفید است که کامپوننت منتظر یک لیست کامل از دادهها باشد که پیش از نمایش همه نتایج باید واکشی شود.
ارسال تابعها به صورت props به کامپوننتهای Memoize شده
Memoize کردن کامپوننتها برای تابعها نیز همچون props به خوبی کار میکند، به این شرط که در تابع هیچ وابستگی prop یا حالت وجود نداشته باشد. در این بخش علاوه بر props به بررسی شیوه کار با تابعهای callback با استفاده از قلاب ()useCallback نیز میپردازیم.
در مثال زیر یک تابع به NameShuffling ارسال میشود که نام جاری انتخاب شده را پاک میکند. بدین ترتیب با تغییر یافتن نام یک رندر مجدد کلید میخورد:
1// adding the ability to clear the name from state
2clearName = () => {
3 this.setState({ name: null });
4}
5render() {
6 return (
7 ...
8 <NameShuffling
9 name={this.state.name}
10 clearName={this.clearName}
11 />
12 ...
13 );
14}
NameShuffling خود شامل یک دکمه Clear است و یا نام انتخاب شده کنونی را نمایش میدهد و یا در صوتی که هیچ نامی انتخاب نشده باشد، None را نشان میدهد:
1function WrappedComponent (props) {
2 return (
3 <>
4 <h2>Selected Name: {props.name ? props.name : 'None'}</h2>
5 <button
6 onClick={() => { props.clearName() }}>Clear
7 </button>
8 </>
9 );
10}
زمانی که کامپوننت ما Memoize میشود، کلیک کردن مکرر روی Clear پس از این که نام به مقدار null تنظیم شد، موجب رندر مجدد نمیشود.
بدین ترتیب دموی مقدماتی اول ما خاتمه مییابد. در بخش بعدی یک سناریوی پیچیدهتر را بررسی میکنیم که در آن چند متد خاطرسپاری با همدیگر استفاده میشوند.
قلابهای useMemo و useCallback
خاطرسپاری یا Memoization با استفاده از قلابها و کامپوننتهای تابعی نیز امکانپذیر است و API با انعطافپذیری بیشتری نسبت به همتای خود React.memo ارائه میکند که بر مبنای کامپوننتها و props ساخته شده است.
اگر بخواهیم دقیقتر بیان کنیم، با استفاده از useMemo امکان پوشش دادن JSX درونخطی را از یک گزاره بازگشتی کامپوننت به دست میآوریم. همچنین امکان ذخیرهسازی نتایج Memoize شده به صورت متغیر فراهم میشود. از طرف دیگر تابعهای Callback نیز با استفاده از قلاب useCallback خاطرسپاری میشوند که معادل useMemo است و تنها ساختار آن کمی متفاوت است.
useCallback مشکل استفاده از تابع به عنوان prop را که قبلاً اشاره کردیم، حل میکنند، در حالی که یک تابع در صورتی که Memoize نشده باشد، در زمان رندر مجدد بازتعریف نمیشود.
امضای useMemo مشخص میسازد که یک تابع به عنوان آرگومان اول میگیرد و یک آرایه از مقادیر یا وابستگیها نیز میگیرد که در صورت تغییر یافتن مقادیر، رندر مجدد را کلید میزند:
1// useMemo signature
2const result = useMemo(() => computeComponentOrValue(a, b), [a, b]);
از useCallback همراه با useMemo برای خاطرسپاری تابعهای درونخطی استفاده میشود و امضای مشابهی دارد:
1// useCallback signature
2const memoizedCallback = useCallback(
3 () => {
4 doSomething(a, b);
5 },
6 [a, b]
7);
تفاوت بین این دو بر اساس مستندات ریاکت آن است که useMemo به همراه «تابعهای ایجاد» (creation functions) استفاده میشود، در حالی که برای «تابعهای درونخطی» (Inline Functions) مورد استفاده قرار میگیرد. این موضوع در نگاه نخست چندان روشن نیست، بنابراین به دموی Name Shuffling خود بازمیگردیم و کد را بازسازی کرده و این قلابها را همراه با React.memo پیادهسازی میکنیم.
دموی Name Shuffling با کامپوننتهای تابعی
در این بخش توجه خود را مجدداً معطوف به مثال Name Shuffling میکنیم، اما این بار آن را درون یک کامپوننت تابعی پیادهسازی میکنیم.
- useMemo
useMemo به روشی مشابه React.memo استفاده میشود. به جای این که همانند قبل یک کامپوننت NameShuffling را درون React.memo قرار دهیم، JSX درونخطی نمایش نام را مستقیماً درون useMemo قرار میدهیم.
- useCallback
از useCallback برای خاطرسپاری متدهای ()getName و ()clearName استفاده میکنیم. متد ()getName از آرایه names به عنوان تنها وابستگی استفاده میکند و تنها نامهایی را بهروزرسانی میکند که به لیست حذف یا اضافه شده باشند. متد ()clearName هیچ وابستگی ندارد و از این رو مقداردهی شده و تا زمانی که کامپوننت unmount نشده است در حافظه میماند.
- کامپوننت Button/ >>
کامپوننت Button/ >> را درون React.memo قرار میدهیم تا مطمئن شویم که رندر مجدد نخواهد شد. تابعهای useCallback به این دکمهها ارسال میشوند.
- آرایه names
آرایه names را به یک کامپوننت سطح بالا جدا میکنیم و قلابهای useCallback و useMemo را در کامپوننت فرزند تعریف میکنیم تا دادههای سطح بالا از منطق کامپوننت جدا شوند.
در تصویر زیر ساختار کامپوننت مشخص شده است:
در ادامه برخی نکات کلیدی برای اجرای روان خاطرسپاری را مرور میکنیم.
تابعهای Callback به اشیای useCallback ارسال میشوند.
1// memoizing callback functions
2const getName = React.useCallback(
3 () => {
4 const index = Math.floor(Math.random() * (names.length));
5 setName(names[index]);
6 },
7[names]);
8
9const clearName = React.useCallback(() => {
10 setName(null)
11 }, []);
در کد فوق تابعهای ()getName و ()clearName به عنوان props ارسال میشوند و ممکن است در رندرهای مختلف به عنوان تابعهای متفاوتی از کامپوننت والد شناسایی شوند. در واقع ما این تابعها را در زمان رندر مجدد کامپوننت بازتعریف میکنیم که کار جالبی محسوب نمیشود.
قلاب useCallback این مشکل را رفع میکند. تابع ()getName اکنون تنها در صورتی بهروزرسانی میشود که names تغییر یابد و این تنها وابستگی است. این حرکت معقولی است، زیرا names.length ممکن است متفاوت باشد و آرایه names میتواند شامل مجموعه مختلفی از مقادیر باشد. به جز این سناریو، ()getName همواره یکسان میماند که کاربردی عالی برای useCallback محسوب میشود.
()clearName نیز خاطرسپاری میشود، اما هیچ وابستگی ندارد که با آن بتواند بهروزرسانی شود. این وضعیت کاملاً معتبر است.
از JSX درونخطی به همراه useMemo استفاده میکنیم
در این مثال میبینیم که useMemo() درون گزاره بازگشتی کامپوننت < /Shuffle> جاسازی شده و به خوبی عمل میکند. به این ترتیب میتوانیم به وضوح کامپوننتی که باید خاطرسپاری شود را ببینیم:
1// inline JSX useMemo hook
2return (
3 ...
4 {React.useMemo(
5 () => {
6 console.log('Name re-render');
7 return (
8 <h2>Selected Name: {name ? name : 'None'}</h2>
9 );
10 },
11 [name]
12 )}
13 ...
14);
ما تصمیم گرفتهایم عنصر <h2> فوق را Memoize کنیم و از این رو تنها زمانی که مقدار name تغییر یابد، رندر مجدد خواهد شد.
قرار دادن JSX درون <> و </> معادل استفاده از React.Fragment است که امکان قرار دادن JSX را در جایی که بیش از یک کامپوننت سطح بالا وجود دارد فراهم میسازد. بدین ترتیب هیچ بخش از markup از فرگمانها تولید نمیشود.
از React.memo برای جلوگیری از رندر مجدد دکمهها استفاده میکنیم
یک بار دیگر در کامپوننتهای < /Button> برای اجرای مقایسه سطحی prop استفاده شده است تا تشخیص دهیم باید رندر مجدد شود یا نه. از آنجا که تابعهای Callback خاطرسپاری شده خود را ارسال میکنیم، نباید هیچ دلیلی برای رندر مجدد دکمهها وجود داشته باشد. از این رو callback-ها و برچسبها همواره یکسان باقی خواهند ماند.
کامپوننت < /Button> درون کامپوننت < /WrappedButton> قرار گرفته که متعلق به HOC مربوط به React.memo است. روش پیادهسازی آن به صورت زیر است:
1function WrappedButton (props) {
2 console.log('button Re-render');
3 return (
4 <button
5 onClick={() => {
6 props.click();
7 }}
8 >
9 {props.label}
10 </button>
11 );
12}
13const Button = React.memo(WrappedButton);
گزارههای console.log به منظور دیباگ کردن نوشته شدهاند تا در کنسول متوجه شویم آیا رندر مجدد صورت گرفته است یا نه. پیادهسازی کامل آن را میتوانید در این ریپوی گیتهاب (+) ببینید.
ملاحظات Memoizing
برای استفاده مؤثرتر از این API باید برخی نکات را در نظر داشته باشید تا اپلیکیشن عاری از باگ باشد و بتوانید از افزایش عملکرد Memoizing بهرهمند شوید:
- عوارض جانبی، یعنی هر چیزی که خارج از دامنه تابع اجرا میشود، نباید در تابع Memoize شده اجرا شود. این موضوع باید به مرحله commit کامپوننت موکول شود که مرحله رندر پایان یافته است.
- Memoize کردن در مرحله رندر یک کامپوننت صورت میگیرد و عوارض جانبی به طور معمول در componentDidMount, componentDidUpdate و componentDidCatch در کامپوننتهای کلاسی و یا در useEffect در هنگام استفاده از کامپوننتهای تابعی پدید میآیند.
- از Profiler (+) برای ثبت عملکرد اپلیکیشن پیش از Memoize کردن بهره بگیرید. اگر افزایش عملکرد چشمگیر نباشد، ممکن است بهتر باشد از خیر آن بگذریم و در آن بخشهایی از اپلیکیشن که منافع چندانی عاید نشده است، پیچیدگیهای ناشی از Memoize کردن را حذف کنیم تا حافظه آزاد شده و کد قالبی کاهش یابد.
- در صورت نیاز یک کامپوننت را به کامپوننتهای فرزند تقسیم کنید تا بتوانید از خاطرسپاری بهره بگیرید. برای نمونه در فرمها که کامپوننتهای استاتیک مانند دکمهها، برچسبها و آیکونها احتمالاً به همراه همتایان دینامیک خود از قبیل اعتبارسنجی که نیاز به رندر مجدد دارند ترکیب شدهاند، میتوان از این تکنیک استفاده کرد. بدین ترتیب باید در همه موارد ممکن اقدام به جداسازی و خاطرسپاری این موارد کرد. این حالت به طور خاص در موارد آیکونهای پیچید SVG که شامل مقدار زیادی markup هستند مفید است.
سخن پایانی
در این مقاله به صورت مقدماتی با مفهوم خاطرسپاری (Memoizing) در ریاکت با استفاده از API-های موجود آشنا شدیم که برای ارتقای سرعت عملکرد رندر مجدد اپلیکیشنها استفاده میشود. دموی دومی که مورد بررسی قرار گرفته بهطور خاص برای روشن شدن شیوه کاربرد عملی روشهای خاطرسپاری مفید است. امیدواریم بدین ترتیب بتوانید موجب افزایش سرعت و روانتر شدن اپلیکیشنهای خود بشوید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای جاوا اسکریپت
- مجموعه آموزشهای برنامهنویسی
- ری اکت (React) — راهنمای جامع برای شروع به کار
- راهنمای جامع React (بخش اول) — از صفر تا صد
- آموزش React.js در کمتر از ۵ دقیقه — از صفر تا صد
==