بیشتر آموزشهای عملکرد (performance) در React با یک توصیه اشتباه مشابه تمام میشوند: همه چیز را در useMemo و useCallback قرار دهید و کار را تمام شده بدانید. اگر از این توصیه پیروی کرده باشید، احتمالاً اپلیکیشن خود را کندتر کردهاید. این هوکها رایگان نیستند. هر کدام حافظه اختصاص میدهند، وابستگیها (dependencies) را مقایسه میکنند و مقادیر کششده را ذخیره میکنند. اگر بدون هدف از آنها استفاده شود، به جای بهینهسازی، باعث ایجاد بار اضافی (overhead) میشوند.
بیایید این موضوع را به آنچه واقعاً اهمیت دارد محدود کنیم.
هر هوک واقعاً چه کاری انجام میدهد
useMemo یک مقدار (value) را به خاطر میسپارد. شما تابعی را به آن میدهید که کار سنگینی انجام میدهد و این هوک نتیجه را برمیگرداند. در رندر بعدی، اگر وابستگیهای شما تغییر نکرده باشند، React از محاسبه صرفنظر کرده و همان نتیجه قبلی را برمیگرداند.
useCallback یک تابع (function) را به خاطر میسپارد. این هوک تابع را برای شما اجرا نمیکند، بلکه صرفاً تا زمانی که وابستگیهایش ثابت بمانند، همان نمونه (instance) تابع را در رندرهای مختلف برمیگرداند.
تمام تفاوت در همین است. یکی یک مقدار محاسبهشده را کش میکند و دیگری یک مرجع (reference) را. اشتباه گرفتن این دو منجر به کدی میشود که به نظر بهینه میرسد اما رفتاری دقیقاً مشابه کد بدون هوک دارد، در حالی که حافظه اضافی مصرف میکند.
چرا هویت تابع ساختار درختی شما را از هم میپاشد
وقتی یک کامپوننت دوباره رندر میشود، React کل بدنه تابع را دوباره اجرا میکند. هر متغیر دوباره ساخته میشود. هر تابع درونخطی (inline function) یک آدرس کاملاً جدید در حافظه میگیرد.
در JavaScript، دو تابع که دقیقاً منطق یکسانی دارند، با هم برابر نیستند. عبارت () => {} === () => {} مقدار false را برمیگرداند. همین قاعده برای اشیاء (objects) و آرایهها (arrays) نیز صدق میکند. اگر کامپوننت والد handleSubmit را تعریف کرده و آن را به یک فرزند پاس دهد، آن فرزند در هر رندر یک prop جدید دریافت میکند. حتی اگر فرزند در React.memo قرار گرفته باشد، نمیتواند تشخیص دهد که تابع جدید همان کار تابع قدیمی را انجام میدهد. چون مرجع (reference) تغییر کرده است، فرزند دوباره رندر میشود.
این همان مشکل اصلی است که useCallback برای حل آن ساخته شده است. موضوع سرعت نیست، بلکه موضوع پایداری (stability) است.
چه زمانی استفاده از useMemo ارزشش را دارد
زمانی به useMemo نیاز دارید که در حال انجام کاری هستید که از نظر محاسباتی سنگین (expensive) است و میتوانید تأخیر قابلاندازهگیری در آن مشاهده کنید.
فیلتر کردن یک مجموعه داده بزرگ را تصور کنید. اگر جدولی با دهها هزار ردیف و یک ورودی جستجو داشته باشید، ممکن است چیزی شبیه به این را داخل کامپوننت خود بنویسید:
const visibleRows = rows.filter(r => r.name.includes(query));
بدون useMemo آن حلقه در هر رندر اجرا میشود. اگر کاربر روی دکمهای کلیک کند که یک سایدبار را باز و بسته میکند، والد دوباره رندر میشود و فیلتر شما دوباره اجرا میشود، حتی اگر rows و query هرگز تغییر نکرده باشند. در یک مجموعه داده بزرگ، این لرزش (stutter) کاملاً قابل مشاهده است.
useMemo این مشکل را با ثابت نگه داشتن نتیجه حل میکند:
const visibleRows = useMemo(() => {
return rows.filter(r => r.name.includes(query));
}, [rows, query]);
حالا React آن فیلتر را فقط زمانی دوباره اجرا میکند که وابستگیها واقعاً تغییر کنند.
همین منطق برای محاسبات ریاضی پیچیده، تبدیل پاسخهای API به فرمتهای مناسب برای نمودار، یا استخراج وضعیتی (state) که در غیر این صورت مدام دوباره محاسبه میشود، صدق میکند.
یک مورد استفاده دوم و کمتر بدیهی نیز وجود دارد. اگر یک شیء یا آرایه را به صورت محلی (locally) ایجاد کنید و آن را در آرایه وابستگی useEffect قرار دهید، ممکن است به طور تصادفی آن اثر (effect) را در هر رندر اجرا کنید. اشیاء و آرایههای درونخطی هر بار هویت جدیدی میگیرند، بنابراین useEffect یک وابستگی تغییریافته را میبیند و دوباره اجرا میشود. ذخیره کردن آن شیء با useMemo باعث میشود مرجع ثابت بماند و اثر شما فقط زمانی اجرا شود که دادههای اصلی واقعاً تغییر کنند.
چه زمانی useCallback ضروری میشود
useCallback زمانی بیشترین اهمیت را دارد که در حال پاس دادن هندلرها (handlers) به کامپوننتهای فرزنی هستید که با React.memo بهینه شدهاند.
یک کامپوننت والد را تصور کنید که یک شمارنده (counter) دارد. این کامپوننت همچنین یک لیست فرزند سنگین را رندر میکند:
function Parent() {
const [count, setCount] = useState(0);
const handleItemClick = (id) => {
console.log(id);
};
return (
<div>
<button onClick={() => setCount(c + 1)}>{count}</button>
<ExpensiveList onItemClick={handleItemClick} />
</div>
);
}
هر بار که count تغییر میکند، Parent دوباره رندر میشود. یک handleItemClick جدید ساخته میشود. از آنجایی که ExpensiveList یک مرجع prop جدید دریافت میکند، آن هم دوباره رندر میشود. اگر ExpensiveList در React.memo قرار گرفته باشد، آن بهینهسازی کاملاً هدر میرود زیرا prop مربوط به تابع تغییر کرده است.
useCallback مرجع را حفظ میکند:
const handleItemClick = useCallback((id) => {
console.log(id);
}, []);
حالا ExpensiveList فقط زمانی دوباره رندر میشود که واقعاً نیاز باشد.
موقعیت بحرانی دیگر مربوط به useEffect است. اگر یک اثر (effect) به تابعی که داخل کامپوننت شما تعریف شده مشترک (subscribe) شود، و آن تابع در هر رندر هویت خود را تغییر دهد، آن اثر مدام از کار افتاده و دوباره اشتراکگذاری میشود (teardown and resubscribe). ذخیره کردن تابع با استفاده از memoization باعث پایداری اثر میشود.
تلهی آرایه وابستگی و Stale Closures
هر دو هوک به آرایههای وابستگی متکی هستند و این همان جایی است که بیشتر باگها در آن پنهان شدهاند.
اگر متغیری را از آرایه وابستگی (dependency array) حذف کنید، تابع یا مقدار مموئیز شدهی شما روی نسخهی قدیمی آن متغیر بسته میشود (close over). این یک stale closure است. ممکن است رابط کاربری (UI) دادههای جدید را نمایش دهد، اما کالبک شما همچنان به استیت مربوط به سه رندر قبل نگاه میکند. راه حل ساده است اما در بررسیهای کد (code reviews) بهراحتی نادیده گرفته میشود: هر مقداری را که در داخل هوک استفاده شده و پتانسیل تغییر دارد، در آرایه بگنجانید.
قانون react-hooks/exhaustive-deps در ESLint را اجرا کنید. این قانون موارد نادیده گرفته شدهی واضح را شناسایی میکند. اما با آن مثل یک ربات برخورد نکنید. درک کنید که چرا هر وابستگی اهمیت دارد.
هزینهی پنهان بهینهسازی بیش از حد
مبتدیان اغلب هر تابع و هر مقداری را با این هوکها محافظت میکنند، چون این کار احساس امنیت میدهد. اما این عادت نتیجهی معکوس میدهد.
React باید مقادیر کششده را در حافظه ذخیره کند. در هر رندر، باید در آرایه وابستگی شما پیمایش کند و هر آیتم را با استفاده از Object.is مقایسه کند. این مقایسه ارزان است، اما رایگان نیست. اگر یک هندلر رویداد ساده مثل onClick={() => setOpen(true)} را داخل useCallback قرار دهید، در واقع دارید هزینهی حافظه و CPU را میپردازید تا از ایجاد تابعی جلوگیری کنید که تخصیص آن لحظهای بود.
این هوکها باعث ایجاد نویز (شلوغی) نیز میشوند. کدی که در useMemo و useCallback پیچیده شده است، خواندن و نگهداری آن دشوارتر است. هر آرایه وابستگی، یک stale closure بالقوه است که منتظر است شما را غافلگیر کند.
قاعدهی اصلی، ساده اما موثر است: ابتدا کد معمولی بنویسید. تنها زمانی بهینهسازی کنید که مدرکی مبنی بر وجود مشکل داشته باشید. از React DevTools Profiler استفاده کنید تا مشخص کنید کدام کامپوننتها سنگین هستند و کدام رندرها باعث هدر رفتن منابع میشوند. اگر یک رندر کمتر از چند میلیثانیه طول بکشد، هیچ کاربری متوجه آن نخواهد شد و مموئیزاسیون شما هیچ مشکلی را حل نمیکند.
خلاصه کلام
useMemo برای مقادیر سنگین است. useCallback برای ارجاعهای تابعی پایدار است. هیچکدام از این هوکها به تنهایی باعث رندر سریعتر کامپوننت نمیشوند؛ آنها از انجام کارهای غیرضروری در مراحل بعدی جلوگیری میکنند. بدون آنها شروع کنید، با ابزارهای واقعی اندازهگیری کنید و آنها را دقیقاً در جایی که پروفایلر یک گلوگاه (bottleneck) نشان میدهد، اضافه کنید. کد تمیزی که گاهی اوقات دوباره رندر میشود، تقریباً همیشه بر کد بیش از حد مهندسیشدهای که همهچیز را مموئیز میکند، برتری دارد.
