فصل ضمیمه: useCallback و useRef چیست؟

Appendix: What are useCallback and useRef

22 بهمن 1399
فصل ضمیمه: useCallback و useRef چیست؟

در قسمت قبل با نوشتن کدهای مربوط به onLoadIngredients به یک حلقه بی نهایت رسیدیم، بنابراین به راحتی می فهمیم که مشکل از onLoadIngredients است. در حال حاضر هر بار که onLoadIngredients یا enteredFilter تغییر کنند، useEffect نیز دوباره اجرا خواهد شد. ما می دانیم که enteredFilter فقط زمانی تغییر می کند که کاربر چیزی را تایپ کند و ما هم چیزی را تایپ نکرده بودیم بنابراین باز هم مطمئن می شویم که onLoadIngredients مسئول این مشکل است.

در واقع زمانی که onLoadIngredients برای بار اول اجرا می شود، کامپوننت پدر (ingredients.js) دوباره رندر می شود. چرا؟ به دلیل اینکه در بار اول onLoadIngredients را صدا می زنیم و onLoadIngredients نیز متد filteredIngredientsHandler را صدا می زند:

        <Search onLoadIngredients={filteredIngredientsHandler} />

سپس درون تابع filteredIngredientsHandler نیز setUserIngredients را صدا زده ایم که خودش State را تغییر می دهد و این تغییر state باعث رندر شدن دوباره این کامپوننت می شود بنابراین یک filteredIngredientsHandler کاملا جدید خواهیم داشت. رندر شدن دوباره کامپوننت یعنی اجرای دوباره آن. از طرفی تمام توابع درون جاوا اسکریپت شیء هستند بنابراین با دوباره ساخته شدن، با تابع قبلی فرق خواهند داشت (حتی اگر کدهایشان یکسان باشد). حالا همانطور که قبلا گفتم یک filteredIngredientsHandler جدید داریم که به onLoadIngredients پاس داده می شود (فایل ingredients.js):

        <Search onLoadIngredients={filteredIngredientsHandler} />

این شیء (filteredIngredientsHandler) به useEffect پاس داده می شود (همان onLoadIngredients) و از آنجایی که یک شیء جدید است (تغییر کرده است) useEffect دوباره اجرا خواهد شد. برای جلوگیری از این اتفاق از یک hook دیگر به نام useCallback استفاده می کنیم.

فایل ingredients.js را باز کرده و آن را import کنید:

import React, { useState, useEffect, useCallback } from 'react';

حالا می توانیم تابع filteredIngredientsHandler را درون این hook قرار دهیم:

  const filteredIngredientsHandler = useCallback(filteredIngredients => {
    setUserIngredients(filteredIngredients);
  }, []);

همانطور که در کد بالا مشاهده می کنید من تمام تابع filteredIngredientsHandler را درون callback قرار داده ام و سپس یک آرگومان دوم (یک آرایه خالی) را نیز به useCallback پاس داده ام. این آرایه وابستگی های تابع شما را مشخص می کند و از آنجایی که هیچ وابستگی برای این تابع وجود ندارد من آن را خالی گذاشته ام (دقیقا مانند جلسات قبل که از useEffect استفاده کرده ایم). در واقع setUserIngredients یک تابع خاص است که از React پاس داده می شود و در این مورد استثناء می باشد بنابراین نیازی به ذکر آن نیست اما اگر بخواهید می توانید این کار را نیز انجام دهید:

  const filteredIngredientsHandler = useCallback(filteredIngredients => {
    setUserIngredients(filteredIngredients);
  }, [setUserIngredients]);

دو کد بالا تفاوتی با هم ندارند.

استفاده از useCallback باعث می شود با رندر شدن دوباره کامپوننت ingredients.js، تابع filteredIngredientsHandler دوباره ساخته نشود (بلکه در حافظه کش می شود). بنابراین چیزی که به onLoadIngredients پاس داده می شود همان تابع قدیمی است و دیگر شاهد حلقه بی نهایت نیستیم.

اگر کد بالا را ذخیره کرده و به مرورگر بروید، دیگر حلقه ای را نمی بینیم اما یک بار رندر اضافی را داریم. اگر در مرورگر به سربرگ network بروید و صفحه را رفرش کنید متوجه خواهید شد که دو درخواست برای دریافت ingredients داریم که یکی از آن ها اضافی است! می دانید چرا؟ یکی از این درخواست ها در فایل Search.js (درون useEffect) ارسال شده و دیگری در فایل Ingredients.js (باز هم درون useEffect). از آنجایی که ما یک بار محتویات را در Search.js دریافت می کنیم دیگر نیازی به useEffect دوم در فایل Ingredients.js نیست بنابراین آن را حذف کنید (کل useEffect را حذف کنید). حالا مشکل ما حل می شود.

مشکل بعدی ما این است که اگر به سربرگ console در مرورگر بروید متوجه خواهید شد که با هر بار تایپ کردن کاربر (فشردن هر کلید کیبورد) یک درخواست جدید را به Firebase ارسال می کنیم. روش بهتر این است که از یک تایمر استفاده کنیم تا اگر کاربر چند لحظه چیزی تایپ نکرد بفهمیم که منتظر دریافت پاسخ بوده و فقط آن موقع درخواست را ارسال کنیم. فایل Search.js را باز کنید و در ابتدای useEffect آن از setTimeout استفاده می کنیم و تمام منطق ارسال درخواست را درون آن قرار می دهیم:

  useEffect(() => {
    setTimeout(() => {
      const query =
        enteredFilter.length === 0
          ? ''
          : `?orderBy="title"&equalTo="${enteredFilter}"`;
      fetch('https://react-hooks-update.firebaseio.com/ingredients.json' + query)
        .then(response => response.json())
        .then(responseData => {
          const loadedIngredients = [];
          for (const key in responseData) {
            loadedIngredients.push({
              id: key,
              title: responseData[key].title,
              amount: responseData[key].amount
            });
          }
          onLoadIngredients(loadedIngredients);
        });
    }, 500);
  }, [enteredFilter, onLoadIngredients]);

بدین صورت ارسال درخواست پس از نیم ثانیه وقفه کاربر ارسال خواهد شد. در حال حاضر فقط یک وقفه ایجاد کرده ایم و بهتر است که مقدار وارد شده را با مقدار قبلی مقایسه کنیم تا اگر کاربر پس از وقفه همان مقدار قبلی را جست و جو کرده بود، درخواست را ارسال کنیم اما اگر مقدار تغییر کرده بود یعنی کاربر هنوز در حال تایپ است بنابراین درخواستی ارسال نمی کنیم. برای این کار از یک شرط if استفاده می کنیم و enteredFilter را بررسی می کنیم اما باید بدانید که enteredFilter برابر مقدار جدیدی که کاربر تایپ کرده نمی باشد بلکه برابر مقداری است که کاربر 500 میلی ثانیه قبل تایپ کرده است (زمانی که اجرای کد به setTimeout می رسد مقادیر درون آن مانند enteredFilter قفل می شوند). مشکل اینجاست که چطور مقدار فعلی را بگیریم تا با مقدار تایپ شده در نیم ثانیه قبل مقایسه کنیم؟

برای این کار از یک hook دیگر به نام useRef استفاده می کنیم:

import React, { useState, useEffect, useRef } from 'react';

حالا می توانیم از یک reference استفاده کنیم:

const Search = React.memo(props => {
  const { onLoadIngredients } = props;
  const [enteredFilter, setEnteredFilter] = useState('');
  const inputRef = useRef();
// بقیه کدها //

سپس آن را به یک عنصر DOM متصل می کنیم که برای ما input می باشد:

<input
    ref={inputRef}
    type="text"
    value={enteredFilter}
    onChange={event => setEnteredFilter(event.target.value)}
/>

این prop (یعنی ref) یک prop خاص است که توسط react ارائه می شود و نام آن انتخابی نیست و باید به reference شما اشاره کند که همان inputRef ما می باشد. حالا می توانیم مقدار جدید و قدیمی را مقایسه کنیم:

useEffect(() => {
    setTimeout(() => {
      if (enteredFilter === inputRef.current.value) {
        const query =
          enteredFilter.length === 0
            ? ''
            : `?orderBy="title"&equalTo="${enteredFilter}"`;
        fetch(
          'https://react-hooks-update.firebaseio.com/ingredients.json' + query
        )
          .then(response => response.json())
          .then(responseData => {
            const loadedIngredients = [];
            for (const key in responseData) {
              loadedIngredients.push({
                id: key,
                title: responseData[key].title,
                amount: responseData[key].amount
              });
            }
            onLoadIngredients(loadedIngredients);
          });
      }
    }, 500);
  }, [enteredFilter, onLoadIngredients]);

در کد بالا شرط if را می بینید که enteredFilter را با مقدار جدید مقایسه کرده ایم. توجه داشته باشید که current یک خصوصیت از ref ها است و ما آن را تعریف نکرده ایم. از آنجایی که این ref را به input خود متصل کرده ایم، inputRef.current همان input را به ما می دهد و با اضافه کردن Value مقدار آن را دریافت کرده ایم.

نکته مهم اینجاست که با انجام این کار inputRef نیز به وابستگی های این useEffect اضافه می شود چرا که از آن استفاده کرده ایم بنابراین آن را هم به لیست وابستگی ها اضافه می کنیم:

// بقیه کدها //
              loadedIngredients.push({
                id: key,
                title: responseData[key].title,
                amount: responseData[key].amount
              });
            }
            onLoadIngredients(loadedIngredients);
          });
      }
    }, 500);
}, [enteredFilter, onLoadIngredients, inputRef]);

مشکل بعدی ما این است که هر بار در input خود تایپ کنیم، useEffect دوباره اجرا می شود و یک setTimeout جدید ساخته می شود. بنابراین اگر در حال تایپ باشیم با فشردن هر کلید کیبورد یک تایمر جداگانه داریم که به طور مستقل کار می کند. باید کاری کنیم که به جای چندین تایمر فقط یک تایمر نهایی داشته باشیم (تایمر های قبلی را پاک کنیم).

خوشبختانه setTimeout یک ارجاع به خودش را به شما می دهد بنابراین می توانیم آن را درون یک متغیر قرار بدهیم:

  useEffect(() => {
    const timer = setTimeout(() => {
      if (enteredFilter === inputRef.current.value) {
// بقیه کدها //

همچنین useEffect قابلیت این را دارد که چیزی را return کند. در واقع درون useEffect و درون تابعی که به آن پاس داده ایم (اما بیرون از شرط if و setTimeout) می توانیم یک تابع را return کنیم (حتما باید تابع باشد) تا تایمر قبلی را حذف کند. این تابع قبل از اجرای دوباره useEffect شده و effect قبلی را پاک می کند:

  useEffect(() => {
    const timer = setTimeout(() => {
      if (enteredFilter === inputRef.current.value) {
        const query =
          enteredFilter.length === 0
            ? ''
            : `?orderBy="title"&equalTo="${enteredFilter}"`;
        fetch(
          'https://react-hooks-update.firebaseio.com/ingredients.json' + query
        )
          .then(response => response.json())
          .then(responseData => {
            const loadedIngredients = [];
            for (const key in responseData) {
              loadedIngredients.push({
                id: key,
                title: responseData[key].title,
                amount: responseData[key].amount
              });
            }
            onLoadIngredients(loadedIngredients);
          });
      }
    }, 500);
    return () => {
      clearTimeout(timer);
    };
  }, [enteredFilter, onLoadIngredients, inputRef]);

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

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

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.