توسعه فرانت‌اند

Debounce و Throttle: کنترل نرخ اجرا

راهنمای Debounce و Throttle با دو مثال کامل HTML و JavaScript؛ رفتار Leading و Trailing، لغو تایمر، پاسخ قدیمی جست‌وجو و انتخاب فاصله مناسب.

1404/12/09 3 دقیقه مطالعه 1,392 بازدید
Debounce و Throttle: کنترل نرخ اجرا

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

انتخاب درست از این سؤال شروع می‌شود: آیا نتیجه را پس از مکث می‌خواهیم یا در طول فعالیت هم باید به‌روز شود؟ در این راهنما دو پیاده‌سازی آموزشی و دو فایل مستقل HTML می‌بینید. مثال‌ها بدون کتابخانه و بدون API بیرونی اجرا می‌شوند. زمان‌های انتخاب‌شده برای نمایش رفتارند و مقدار بهینه همگانی محسوب نمی‌شوند.

Debounce منتظر مکث می‌ماند

در Debounce معمول با اجرای انتهایی، هر فراخوانی یک انتظار تازه می‌سازد. اگر پیش از پایان آن فراخوانی دیگری برسد، انتظار قبلی کنار می‌رود. تابع پس از گذشت زمان مشخص از آخرین فراخوانی اجرا می‌شود. در جست‌وجو، این یعنی متن کاربر فوری تغییر می‌کند، اما پردازش یا ارسال درخواست تا مکث کوتاه عقب می‌افتد.

تعریف Debounce در MDN همین تجمیع فراخوانی‌های نزدیک را توضیح می‌دهد. با این حال، همه پیاده‌سازی‌ها فقط در انتها اجرا نمی‌کنند؛ رفتار Leading و Trailing قابل تنظیم است. پس وقتی می‌گوییم «Debounce»، باید مشخص کنیم منظور کدام رفتار است.

اگر جریان رویداد هیچ‌وقت مکث کافی نداشته باشد، نسخه انتهایی ساده ممکن است همچنان منتظر بماند. برای ذخیره خودکار یک متن طولانی، این نکته مهم است. بعضی کتابخانه‌ها گزینه maxWait دارند تا انتظار بی‌نهایت ادامه پیدا نکند. این گزینه در تابع آموزشی این مقاله وجود ندارد؛ قابلیت یک نسخه ساده را با کتابخانه کامل برابر نگیرید.

Throttle فاصله اجراها را محدود می‌کند

Throttle به فعالیت اجازه می‌دهد ادامه پیدا کند، اما تعداد اجرای تابع را با یک فاصله محدود می‌کند. ممکن است اولین فراخوانی فوری اجرا شود و آخرین مقدار هم در اجرای انتهایی حفظ شود. جزئیات به پیاده‌سازی وابسته‌اند. اگر فقط فراخوانی‌های داخل فاصله را دور بریزیم، شاید آخرین موقعیت اشاره‌گر یا آخرین مقدار ورودی نمایش داده نشود.

MDN تفاوت اصلی را در محدودکردن فعالیت پیوسته توضیح می‌دهد. Throttle به معنی اجرای دقیق و تضمین‌شده در هر N میلی‌ثانیه نیست. تایمر مرورگر ممکن است به‌خاطر کارهای دیگر یا شرایط صفحه دیرتر اجرا شود. همچنین نباید بدون تعریف پیاده‌سازی آن را با محدودیت «یک اجرا در هر بازه تقویمی ثابت» یکی دانست.

نیازانتخاب اولیه برای بررسی
جست‌وجو پس از مکث تایپDebounce انتهایی
به‌روزرسانی دوره‌ای در فعالیت پیوستهThrottle با رفتار ابتدا و انتهای مشخص
آخرین تغییر اندازه پس از توقفDebounce
حرکت دیداری هماهنگ با فریمبررسی requestAnimationFrame به‌عنوان مسئله‌ای جدا

Leading و Trailing یعنی چه؟

Leading یعنی اجازه اجرای ابتدای یک نوبت فعالیت؛ Trailing یعنی اجرای مرتبط با انتهای انتظار یا آخرین مقدار نگه‌داشته‌شده. برای دکمه‌ای که بازخورد فوری لازم دارد، شروع سریع ممکن است مفید باشد. برای جست‌وجویی که فقط متن نهایی را می‌خواهد، اجرای انتهایی معمولاً گزینه‌ای برای بررسی است. ترکیب این دو را با نیاز محصول انتخاب کنید.

در کتابخانه‌ها، فعال‌بودن هر دو گزینه الزاماً به معنی دو اجرا برای یک فراخوانی تنها نیست. قرارداد دقیق را بخوانید. مستندات Lodash گزینه‌های leading، trailing و maxWait و روش‌های cancel و flush را توضیح می‌دهد. نسخه و رفتار مستند کتابخانه مورد استفاده را مرجع بگیرید، نه نام تابع را به‌تنهایی.

مثال کامل Debounce برای جست‌وجوی محلی

کد زیر را در فایلی مثل debounce-search.html ذخیره و در مرورگر باز کنید. HTML، ورودی، فهرست و تابع همگی در همان فایل هستند. جست‌وجو روی یک آرایه کوچک انجام می‌شود تا بدون سرور بتوان رفتار را دید. در هر مکث، آخرین متن پردازش می‌شود و پاک‌کردن ورودی فوراً فهرست کامل را برمی‌گرداند.

<!doctype html>
<html lang="fa" dir="rtl">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>نمونه Debounce</title>
<body>
  <label for="query">جست‌وجوی موضوع</label>
  <input id="query" type="search" autocomplete="off">
  <p id="status" role="status"></p>
  <ul id="results"></ul>
  <script>
    function debounce(fn, wait) {
      let timer = null;
      function wrapped(...args) {
        clearTimeout(timer);
        const context = this;
        timer = setTimeout(() => {
          timer = null;
          fn.apply(context, args);
        }, wait);
      }
      wrapped.cancel = () => {
        clearTimeout(timer);
        timer = null;
      };
      return wrapped;
    }

    const topics = ["طراحی رابط", "تایپوگرافی فارسی", "JavaScript", "CSS Grid", "طراحی وبلاگ"];
    const input = document.querySelector("#query");
    const status = document.querySelector("#status");
    const results = document.querySelector("#results");
    let composing = false;

    function render(query) {
      const matches = topics.filter(topic =>
        topic.toLowerCase().includes(query.toLowerCase())
      );
      results.replaceChildren();
      for (const topic of matches) {
        const item = document.createElement("li");
        item.textContent = topic;
        results.append(item);
      }
      status.textContent = `${matches.length} نتیجه پیدا شد.`;
    }

    const search = debounce(render, 300);
    function handleInput() {
      if (composing) return;
      const query = input.value.trim();
      if (!query) {
        search.cancel();
        render("");
        return;
      }
      status.textContent = "منتظر پایان تایپ…";
      search(query);
    }
    input.addEventListener("input", handleInput);
    input.addEventListener("compositionstart", () => {
      composing = true;
      search.cancel();
    });
    input.addEventListener("compositionend", () => {
      composing = false;
      handleInput();
    });
    window.addEventListener("pagehide", () => search.cancel());
    render("");
  </script>
</body>
</html>

تابع wrapped از یک تایمر استفاده می‌کند و آرگومان‌های آخرین فراخوانی را به تابع اصلی می‌دهد. context نیز حفظ می‌شود تا برای تابعی که به this نیاز دارد، رفتار فراخوانی از بین نرود. cancel فقط اجرای زمان‌بندی‌شده را کنار می‌گذارد. این مثال Promise برنمی‌گرداند و نباید آن را مانند تابع async که نتیجه نهایی را قابل await می‌کند استفاده کنید.

رویدادهای composition نیز در نمونه مدیریت شده‌اند تا هنگام ترکیب متن با روش‌های ورودی، جست‌وجو روی مقدار میانی انجام نشود. این با ایجاد تأخیر در خود ورودی فرق دارد. آنچه عقب می‌افتد کار جانبی است؛ نمایش متن تایپ‌شده نباید برای رسیدن تایمر متوقف شود.

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

مثال کامل Throttle با حفظ آخرین مقدار

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

<!doctype html>
<html lang="fa" dir="rtl">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>نمونه Throttle</title>
<body>
  <p>اشاره‌گر را داخل کادر حرکت دهید.</p>
  <div id="area" style="height:220px;border:2px solid #147d79"></div>
  <p id="position">هنوز حرکتی ثبت نشده است.</p>
  <script>
    function throttle(fn, wait) {
      let lastRun = -Infinity;
      let timer = null;
      let lastArgs;
      let lastContext;

      function invoke() {
        lastRun = performance.now();
        const args = lastArgs;
        const context = lastContext;
        lastArgs = lastContext = undefined;
        fn.apply(context, args);
      }

      function wrapped(...args) {
        lastArgs = args;
        lastContext = this;
        const remaining = wait - (performance.now() - lastRun);
        if (remaining <= 0) {
          clearTimeout(timer);
          timer = null;
          invoke();
        } else if (timer === null) {
          timer = setTimeout(() => {
            timer = null;
            invoke();
          }, remaining);
        }
      }
      wrapped.cancel = () => {
        clearTimeout(timer);
        timer = null;
        lastArgs = lastContext = undefined;
        lastRun = -Infinity;
      };
      return wrapped;
    }

    const area = document.querySelector("#area");
    const position = document.querySelector("#position");
    let executions = 0;
    const update = throttle((x, y) => {
      executions += 1;
      position.textContent = `مختصات: ${x}, ${y} | تعداد اجرا: ${executions}`;
    }, 200);
    area.addEventListener("pointermove", event => {
      update(event.clientX, event.clientY);
    });
    window.addEventListener("pagehide", () => update.cancel());
  </script>
</body>
</html>

در این نسخه، زمان از performance.now خوانده می‌شود و فاصله از آخرین اجرای واقعی محاسبه می‌شود. شمارنده تعداد اجرای تابع را نشان می‌دهد، نه تعداد رویدادهای ورودی. این تفاوت برای دیدن رفتار مهم است. تابع، رویداد کامل را ذخیره نمی‌کند؛ مختصات لازم همان لحظه خوانده و به آن داده می‌شوند.

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

در جست‌وجوی آنلاین، پاسخ قدیمی را مهار کنید

Debounce احتمال درخواست اضافی را کم می‌کند، اما ترتیب رسیدن پاسخ‌ها را کنترل نمی‌کند. ممکن است درخواست عبارت کوتاه‌تر دیرتر از درخواست تازه پاسخ بدهد و نتیجه قدیمی جای نتیجه جدید را بگیرد. این مسئله حتی با یک تأخیر مناسب همچنان ممکن است رخ دهد.

با تغییر ورودی، درخواست قبلی را در صورت امکان لغو کنید و اعتبار پاسخ را هم با یک شناسه افزایشی یا مقدار پرس‌وجوی فعلی بسنجید. طبق مستندات AbortController، این API می‌تواند عملیات سازگار از جمله Fetch را لغو کند. لغو سمت مرورگر الزاماً به معنی توقف کار انجام‌شده روی سرور نیست؛ وضعیت داده را نیز جدا مدیریت کنید.

در حالت بارگذاری، خطا و نبود نتیجه متن مناسب نشان دهید. پاسخ HTTP ناموفق را صرفاً چون Fetch پاسخ برگردانده موفق فرض نکنید. برای طراحی این وضعیت‌ها، راهنمای Empty State و برای نمایش فیلترهای فعال، Tag و Label System کاربرد دارند. وقتی ورودی خالی می‌شود، درخواست و تایمر قبلی نباید نتیجه منسوخ را دوباره نمایش دهند.

اشتباه‌های رایج در React، Vue و رویدادها

اگر با هر رویداد یا هر Render یک تابع Debounce تازه بسازید، تایمرها مستقل می‌شوند و رفتار مورد انتظار از بین می‌رود. تابع زمان‌بندی‌شده باید عمر مشخص داشته باشد. در کامپوننت، هویت تابع، داده‌ای که می‌خواند و زمان پاک‌سازی را هماهنگ کنید. حفظ هویت بدون توجه به داده‌های قدیمی Closure هم می‌تواند نتیجه اشتباه بدهد.

در خروج از کامپوننت، Listener و اجرای معلق را پاک کنید. اگر Listener را با یک تابع ثبت کرده‌اید، برای حذف به همان مرجع نیاز دارید. فقط حذف رویداد لزوماً تایمر قبلی را لغو نمی‌کند. همچنین مقداری را که لازم دارید زود بخوانید؛ به نگه‌داشتن یک شیء رویداد یا DOM که ممکن است تغییر کند وابسته نباشید.

برای فرم‌های مهم، ذخیره خودکار صرفاً به Debounce وابسته نباشد. باید روشن باشد کاربر چه زمانی داده را واقعاً ذخیره کرده و در خروج چه می‌شود. flush در بعضی کتابخانه‌ها اجرای معلق را جلو می‌اندازد، اما به‌تنهایی تضمین نمی‌کند درخواست شبکه قبل از بسته‌شدن صفحه تمام شود. سیاست ذخیره و بازخورد کاربر را جدا طراحی کنید.

Throttle با requestAnimationFrame یکی نیست

requestAnimationFrame برای هماهنگ‌کردن کار دیداری با چرخه نمایش کاربرد دارد؛ Throttle معمولاً فاصله زمانی تعریف‌شده‌ای برای اجرا می‌گذارد. اولی الزاماً نرخ یک گزارش شبکه یا محاسبه غیرنمایشی را مطابق نیاز شما محدود نمی‌کند. در مقابل، اجرای دیداری هر چندصد میلی‌ثانیه ممکن است حرکت را ناپیوسته نشان دهد. ابتدا هدف را مشخص کنید: نمایش، محاسبه یا ارسال داده.

اگر هر اجرای تابع همچنان سنگین است، کمتر اجراکردن فقط بخشی از مشکل را کم می‌کند. حجم کار، تغییرهای غیرضروری DOM و محاسبات را بررسی کنید. راهنمای PageSpeed Insights تفاوت نشانه‌های پاسخ‌گویی و گزارش آزمایشگاهی را توضیح می‌دهد. Debounce و Throttle به‌تنهایی تضمین بهبود INP نیستند.

فاصله مناسب را با نیاز واقعی انتخاب کنید

برای عدد انتظار، به هزینه اجرا، سرعت پاسخ مورد نیاز و نوع تعامل نگاه کنید. تأخیر کوتاه ممکن است درخواست زیادی بسازد و تأخیر بلند هم می‌تواند حس کندی بدهد. عددهای ۳۰۰ و ۲۰۰ میلی‌ثانیه در مثال‌های این مقاله برای آموزش انتخاب شده‌اند؛ آن‌ها را بدون بررسی در همه پروژه‌ها کپی نکنید.

محدودکردن اجرای سمت کاربر، Rate Limit امنیتی سرور نیست. کاربر یا برنامه دیگری می‌تواند مستقیماً API را فراخوانی کند. محدودیت درخواست، مجوز و اعتبارسنجی باید در سمت مناسب اعمال شوند. برای فهم تفاوت اعتبارسنجی داده و زمان‌بندی اجرا، راهنمای Zod می‌تواند مفید باشد؛ هیچ‌یک جای دیگری را نمی‌گیرد.

پیش از استفاده، رفتار یک فراخوانی تنها، چند فراخوانی سریع، جریان پیوسته، توقف و cancel را مشخص کنید. نتیجه مطلوب آخرین مقدار است یا اجرای فوری اول؟ با پاسخ روشن به این سؤال، انتخاب بین Debounce و Throttle ساده‌تر می‌شود. ابزار درست، کاری را که نیاز نداریم کم می‌کند و نتیجه‌ای را که کاربر لازم دارد نگه می‌دارد.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!