Debounce و Throttle در جاوا اسکریپت — به زبان ساده

۱۵۷ بازدید
آخرین به‌روزرسانی: ۷ شهریور ۱۴۰۲
زمان مطالعه: ۶ دقیقه

آیا تاکنون این تجربه را داشته‌اید که صفحه وب در زمان اسکرول کردن، بسیار کُند شود؟ زمانی که از انیمیشن‌های canvas در محیط داشبورد استفاده می‌کنیم، مصرف CPU برای نمایش روان انیمیشن‌ها در هر ثانیه بسیار بالا می‌رود. این وضعیت در صورتی که بخواهید در زمان حرکت ماوس روی این نمودارها یک tooltip نیز نمایش پیدا کند، وخامت بسیار بیشتری پیدا می‌یابد. یکی از راه‌حل‌های این وضعیت، استفاده از Debounce و Throttle در جاوا اسکریپت است که در این راهنما بررسی می‌کنیم. اگر با مفاهیم تابع‌های ناهمگام و طرز کار HOF آشنا باشید، درک مطالبی که در ادامه می‌آید بسیار آسان‌تر خواهد بود.

بررسی یک مثال

فرض کنید می‌خواهید از طریق یک فراخوانی API به دنبال نام یک کشور بگردید. یک کادر ورودی روی صفحه وجود دارد که می‌توانید نام کشور را در آن وارد کنید. زمانی که رشته را در آن وارد کردید، اپلیکیشن اقدام به فراخوانی API کرده و همه کشورهایی که با رشته ورودی تطبیق یابند، بارگذاری می‌کند.

وقتی کاربران موبایل یا افراد تنبل تلاش کنند این قابلیت را امتحان کنند، به صورت زیر خواهد بود:

Debounce و Throttle در جاوا اسکریپت

عبارت روی صفحه به این نکته اشاره دارد که چه تعداد درخواست‌های HTTP به سرور ارسال شده است. توجه کنید که هر بار که یک حرف کیبورد را می‌زنید، حتی زمانی که کلید بک‌اسپیس را ‌می‌زنید عدد این عبارت افزایش می‌یابد. این وضعیت در اپلیکیشن ساده ما شاید نامناسب به نظر نرسد، اما تصور کنید روی اپلیکیشن بزرگی مانند AirBnb کار می‌کنید، در این وضعیت ارسال درخواست‌ها در هر بار که کاربر یک کلید کیبورد را می‌زند یا کار دیگری انجام می‌دهد، انجام می‌شود که موجب کندی عملکرد خواهد شد.

Debounce و Throttle

برای درک مفهوم حقیقی Debounce و Throttle بهتر است ابتدا ایده راه‌حل مشکل فوق را بررسی کنیم. مشکل اصلی این است که درخواست‌های http زیادی ارسال می‌شوند. فرض کنید می‌خواهید به دنبال عبارت Canada بگردید اما صفحه وب به نوعی به جهت ارسال درخواست‌های C، ‌Ca، Can و Cana و غیره فریز شده است. این وضعیت خوبی نیست. بنابراین باید تعداد درخواست‌ها کاهش یابد.

Debounce و Throttle در واقع صرفاً نام‌هایی برای شیوه کاهش این درخواست‌ها هستند. نکته مشترک Debounce و Throttle یک مفهوم ساده است: زمانی که انگشت روی کلید می‌رود تا زمانی که تأیید نشده است، درخواستی ارسال نمی‌شود.

Debounce و Throttle در جاوا اسکریپت

به شکل فوق توجه کنید. در این تصویر می‌بینید که بسته به حالت‌های مختلف چه اتفاقاتی می‌افتد. در حالت Normal هر بار که کلیدی فشرده شود، یک درخواست ارسال می‌شود. در حالت Throttle یک درخواست نخستین بار در طی دوره ارسال می‌شود و دیگر هیچ درخواستی تا پایان دوره ارسال نمی‌شود. هنگامی که دوره زمانی خاتمه یابد، یک درخواست جدید مجدداً ارسال می‌شود. در حالت Debounce یک Callback استفاده می‌شود و در طی چند میلی‌ثانیه فراخوانی می‌شود و تنها درخواستی را ارسال می‌کند که در طی دوره زمانی درخواست دیگری اضافه نشده باشد. برای مثال به زمانی که حرف n در کلمه Canada وارد می‌شود دقت کنید. یک درخواست دیگر اضافه شده است و درخواست قبلی اضافه شده زمانی که a وارد شود نادیده گرفته می‌شود. با این منطق، اگر همه درخواست‌ها درون بازه زمانی اضافه شوند، تنها درخواست آخر اجرا خواهد شد.

بررسی کد حالت Throttle

اینک نوبت به نوشتن کد رسیده است

import axios from 'axios

import axios from 'axios';
const search = async (city) =>
  await axios.get(`https://restcountries.eu/rest/v2/name/${city}`)

search تابعی است که نام شهر را با استفاده از API به همین نام ارسال می‌کند. از API واقعی restful برای تست این مثال در عمل استفاده کرده‌ایم. تنها کاری که باید انجام دهیم این است که کادر City را با هر نامی که دوست دارید، پر کنید. ابتدا کد را بررسی می‌کنیم:

const throttle = (delay, fn) => {
  let inThrottle = false;

  return args => {
    if (inThrottle) {
      return;
    }

    inThrottle = true;
    fn(args);
    setTimeout(() => {
      inThrottle = false;
    }, delay);
  };
};

تابع بی‌نام یک HOF (تابع مرتبه بالاتر) است که تابع دیگری را بازگشت می‌دهد. زمانی که این تابع برای نخستین بار فراخوانی می‌شود، inThrottle مقدار false می‌گیرد. سپس زمانی که تابع بازگشتی دوباره فراخوانی می‌شود inThrottle مقدار true می‌گیرد و تابع callback یعنی fn اجرا می‌شود. در ادامه inThrottle دوباره در طی زمان delay میلی‌ثانیه مقدار false می‌گیرد. در این زمان در عین این که inThrottle مقدار true دارد، هیچ کدام از توابع بازگشتی از throttle نمی‌توانند اجرا شوند.

Debounce و Throttle در جاوا اسکریپت

چنان که در تصویر فوق می‌بینید، حتی اگر انگشت خود را روی دکمه نگه دارید، امکان اجرای تابع را نمی‌دهد مگر این که inThrottle مقدار false بگیرد. سپس متد جدید برای درخواست کردن به صورت زیر درمی‌آید:

const sendRequestThrottle = throttle(500, search);
<input type="text" onChange={sendRequestThrottle} />

اما یک نکته هست که باید مراقب آن باشیم. متد HOF به نام throttle نباید در کامپوننت React قرار گیرد. زمانی که متغیرهای حالت که در کامپوننت A هستند، تغییر می‌یابند، A رندر مجدد می‌شود و. همه چیز از نو انتساب می‌یابد. اگر throttle هم رندر مجدد شود inThrottle دوباره مقدار false خواهد گرفت.

بررسی کد Debounce

اینک به بررسی Debounce می‌پردازیم. این کد کاملاً مشابه کد Throttle است و بخش غالب‌ آن را به سادگی درک می‌کنید.

const debounce = (delay, fn) => {
  let inDebounce = null;
  return args => {
    clearTimeout(inDebounce);
    inDebounce = setTimeout(() => fn(args), delay);
  }
}

می‌بینید که بسیار ساده است. تنها تفاوت با Throttle در این است که Debounce بررسی نمی‌کند، آیا inDebounce مقدار true دارد یا نه. اگر Callback درون دوره زمانی مورد نظر اجرا شده باشد، setTimeout قبلی لغو می‌شود و بی‌درنگ اجرا شده و یک setTimeout جدید ایجاد می‌کند. بنابراین اگر به فشردن کیبورد به صورت سریع ادامه بدهید، callback هرگز اجرا نخواهد شد

Debounce و Throttle در جاوا اسکریپت

اکنون می‌توانید فراخوانی‌های Debounce را بهتر درک کنید. تنها کاری که باید انجام دهید این است که callback خود را در DOM ثبت کنید:

const sendRequestDebounce = debounce(500, search);
<input type="text" onChange={sendRequestDebounce} />

Throttle و Debounce در Lodash

در اغلب موارد نیازی به ایجاد یک Throttle یا Debounce وجود ندارد، زیرا کتابخانه‌های سبک زیای وجود دارند که این قابلیت‌ها را عرضه می‌کنند. یک نمونه از آن کتابخانه Lodash است:

function debounce(func, wait, options) {
  let lastArgs,
    lastThis,
    maxWait,
    result,
    timerId,
    lastCallTime

  let lastInvokeTime = 0
  let leading = false
  let maxing = false
  let trailing = true

  // Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
  const useRAF = (!wait && wait !== 0 && typeof root.requestAnimationFrame === 'function')

  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  wait = +wait || 0
  if (isObject(options)) {
    leading = !!options.leading
    maxing = 'maxWait' in options
    maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }

  function invokeFunc(time) {
    const args = lastArgs
    const thisArg = lastThis

    lastArgs = lastThis = undefined
    lastInvokeTime = time
    result = func.apply(thisArg, args)
    return result
  }

  function startTimer(pendingFunc, wait) {
    if (useRAF) {
      root.cancelAnimationFrame(timerId)
      return root.requestAnimationFrame(pendingFunc)
    }
    return setTimeout(pendingFunc, wait)
  }

  function cancelTimer(id) {
    if (useRAF) {
      return root.cancelAnimationFrame(id)
    }
    clearTimeout(id)
  }

  function leadingEdge(time) {
    // Reset any `maxWait` timer.
    lastInvokeTime = time
    // Start the timer for the trailing edge.
    timerId = startTimer(timerExpired, wait)
    // Invoke the leading edge.
    return leading ? invokeFunc(time) : result
  }

  function remainingWait(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime
    const timeWaiting = wait - timeSinceLastCall

    return maxing
      ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting
  }

  function shouldInvoke(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime

    // Either this is the first call, activity has stopped and we're at the
    // trailing edge, the system time has gone backwards and we're treating
    // it as the trailing edge, or we've hit the `maxWait` limit.
    return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
      (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
  }

  function timerExpired() {
    const time = Date.now()
    if (shouldInvoke(time)) {
      return trailingEdge(time)
    }
    // Restart the timer.
    timerId = startTimer(timerExpired, remainingWait(time))
  }

  function trailingEdge(time) {
    timerId = undefined

    // Only invoke if we have `lastArgs` which means `func` has been
    // debounced at least once.
    if (trailing && lastArgs) {
      return invokeFunc(time)
    }
    lastArgs = lastThis = undefined
    return result
  }

  function cancel() {
    if (timerId !== undefined) {
      cancelTimer(timerId)
    }
    lastInvokeTime = 0
    lastArgs = lastCallTime = lastThis = timerId = undefined
  }

  function flush() {
    return timerId === undefined ? result : trailingEdge(Date.now())
  }

  function pending() {
    return timerId !== undefined
  }

  function debounced(...args) {
    const time = Date.now()
    const isInvoking = shouldInvoke(time)

    lastArgs = args
    lastThis = this
    lastCallTime = time

    if (isInvoking) {
      if (timerId === undefined) {
        return leadingEdge(lastCallTime)
      }
      if (maxing) {
        // Handle invocations in a tight loop.
        timerId = startTimer(timerExpired, wait)
        return invokeFunc(lastCallTime)
      }
    }
    if (timerId === undefined) {
      timerId = startTimer(timerExpired, wait)
    }
    return result
  }
  debounced.cancel = cancel
  debounced.flush = flush
  debounced.pending = pending
  return debounced
}

کد آن کاملاً طولانی است، اما کافی است به تابع debounced که یک HOF است توجه کنید:

function debounce() {
  let lastCallTime;
  ...
  function debounced(...args) {
    ...
  }
  return debounced;
}

Debounce یک تابع جدید به نام debounced بازگشت می‌دهد که در عمل اجرا خواهد شد. درون این debounced از استفاده می‌کنیم. بنابراین شاید متوجه شوید که debounce در Lodash اقدام به مقایسه زمان قبلی (که تابع قبلاً فراخوانی شده) با زمان جاری (که تابع فعلاً فراخوانی شده) می‌کند.

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

const throttled = function(delay, fn) {
    let lastCall = 0;
    return (...args) => {
        let context = this;
        let current = new Date().getTime();
        
        if (current - lastCall < delay) {
            return;
        }
        lastCall = current;
        
        return fn.apply(context, ...args);
    };
};

البته شما می‌توانید در صورت علاقه، خودتان این کارکردها را بسازید، اما به خاطر داشته باشید که نکته مهم این است که اجرای تابع را تا نقطه بعدی به تأخیر بیندازید.

سخن پایانی

Debounce و Throttle هر دو به دلیل نیاز به ایجاد تأخیر در اجرای تابع به دلیل کاهش تعداد ارسال درخواست‌های HTTP از سوی کاربر ساخته شده‌اند. امروزه این دو روش‌های مهم برای بهبود وب محسوب می‌شوند. می‌تواند از هر کدام از آن‌ها در کارهای مختلف که نیاز به ایجاد تأخیر دارند، مانند رویداد اسکرول کردن استفاده کنید.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۲ نفر
آیا این مطلب برای شما مفید بود؟
شما قبلا رای داده‌اید!
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
better-programming

نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *