Skip to main content
JavaScript 5 mins read Devs2.org

JavaScript Debouncing & Throttling: Tối Ưu Hiệu Suất và Trải Nghiệm Người Dùng

Tìm hiểu debouncing và throttling trong JavaScript — hai kỹ thuật tối ưu hiệu suất không thể thiếu giúp kiểm soát tần suất thực thi hàm, giảm tải CPU và cải thiện trải nghiệm người dùng trên web.

#JavaScript #Debouncing #Throttling #Performance #Web Development #Frontend #Optimization

JavaScript Debouncing & Throttling

Debouncing và Throttling là hai kỹ thuật kiểm soát tần suất thực thi không thể thiếu trong JavaScript. Bất kỳ web developer nào từng xử lý sự kiện scroll, resize, hoặc input search đều biết rằng — nếu không kiểm soát, trình duyệt sẽ bị quá tải bởi hàng trăm lần gọi hàm mỗi giây.

Trong bài viết này, bạn sẽ hiểu rõ:

  • Debounce là gì, Throttle là gì và sự khác biệt cốt lõi
  • Cách tự tay implement từ phiên bản cơ bản đến nâng cao
  • Khi nào dùng cái nào với ví dụ thực tế
  • Các edge case và best practices cho production
  • So sánh với Lodash và các thư viện phổ biến

1. Vấn Đề: Event Fires Quá Nhiều

Hãy nhìn vào đoạn code đơn giản này:

const searchInput = document.getElementById('search');
searchInput.addEventListener('input', (e) => {
  console.log('Gọi API tìm kiếm:', e.target.value);
  // fetch(`/api/search?q=${e.target.value}`)
});

Khi người dùng gõ “javascript” (12 ký tự), sự kiện input kích hoạt 12 lần — mỗi lần gõ một chữ. Nếu bạn gọi API trong đó, đó là 12 request chỉ trong 1-2 giây.

Với các sự kiện như scroll hoặc mousemove, con số có thể lên đến 60-100 lần/giây.

Debounce và Throttle giúp bạn giảm số lần đó xuống mức hợp lý — mà không làm mất trải nghiệm người dùng.


2. Debounce — “Đợi Xong Rồi Chạy”

Debounce trì hoãn việc thực thi một hàm cho đến khi một khoảng thời gian yên tĩnh trôi qua kể từ lần kích hoạt cuối cùng.

2.1. Debounce Cơ Bản

function debounce(func, delay) {
  let timeoutId;
  
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

Cách hoạt động:

  1. Khi sự kiện xảy ra, setTimeout được đặt với thời gian delay
  2. Nếu sự kiện xảy ra lần nữa trước khi delay kết thúc → clearTimeout xoá lần trước và đặt lại setTimeout mới
  3. Hàm chỉ thực thi khi không có sự kiện mới nào trong delay ms

2.2. Ví Dụ Thực Tế: Search Autocomplete

const debouncedSearch = debounce(async (query) => {
  const results = await fetch(`/api/search?q=${query}`);
  showResults(await results.json());
}, 300);

searchInput.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

Giờ đây, khi người dùng gõ “javascript”, API chỉ được gọi một lần duy nhất — 300ms sau khi họ ngừng gõ.

2.3. Debounce Với Leading Option

Đôi khi bạn muốn hàm chạy ngay lần đầu tiên, rồi mới debounce các lần sau. Ví dụ: nút “Save” tránh spam click.

function debounce(func, delay, { leading = false } = {}) {
  let timeoutId;
  let isLeadingCalled = false;

  return function(...args) {
    if (leading && !isLeadingCalled) {
      func.apply(this, args);
      isLeadingCalled = true;
    }
    
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      if (leading) {
        isLeadingCalled = false;
      } else {
        func.apply(this, args);
      }
    }, delay);
  };
}

3. Throttle — “Chạy Đều Đặn Theo Chu Kỳ”

Throttle đảm bảo một hàm chỉ chạy tối đa một lần trong một khoảng thời gian nhất định, bất kể nó được gọi bao nhiêu lần.

3.1. Throttle Cơ Bản (Dùng Timestamp)

function throttle(func, limit) {
  let lastCall = 0;
  
  return function(...args) {
    const now = Date.now();
    if (now - lastCall >= limit) {
      lastCall = now;
      func.apply(this, args);
    }
  };
}

3.2. Throttle Dùng setTimeout

function throttle(func, limit) {
  let isThrottled = false;
  
  return function(...args) {
    if (isThrottled) return;
    
    isThrottled = true;
    func.apply(this, args);
    
    setTimeout(() => {
      isThrottled = false;
    }, limit);
  };
}

3.3. Ví Dụ Thực Tế: Infinite Scroll

const throttledScroll = throttle(() => {
  const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
  if (scrollTop + clientHeight >= scrollHeight - 100) {
    loadMoreContent();
  }
}, 200);

window.addEventListener('scroll', throttledScroll);

Không có throttle, sự kiện scroll kích hoạt hàng chục lần mỗi giây. Với limit: 200ms, hàm chỉ chạy tối đa 5 lần/giây — vừa đủ để UI responsive, vừa giảm tải CPU.

3.4. Throttle Với Trailing Call

Throttle cơ bản có thể “bỏ lỡ” lần gọi cuối. Trailing throttle đảm bảo lần cuối luôn được thực thi:

function throttle(func, limit) {
  let lastCall = 0;
  let timeoutId;
  
  return function(...args) {
    const now = Date.now();
    const remaining = limit - (now - lastCall);
    const context = this;
    
    clearTimeout(timeoutId);
    
    if (remaining <= 0) {
      lastCall = now;
      func.apply(context, args);
    } else {
      timeoutId = setTimeout(() => {
        lastCall = Date.now();
        func.apply(context, args);
      }, remaining);
    }
  };
}

4. Debounce vs Throttle: So Sánh Trực Quan

Tiêu chíDebounceThrottle
Cơ chếĐợi yên tĩnh rồi chạyChạy theo chu kỳ đều đặn
Số lần chạy1 lần sau khi kết thúcNhiều lần, cách đều nhau
Khi scroll liên tục 5sChạy 1 lần sau 5sChạy ~5-10 lần (tuỳ limit)
Khi gõ “hello” nhanhChạy 1 lầnChạy 2-3 lần
Phù hợpAutocomplete, validateScroll, resize, animation

Minh Họa Bằng Code

// Giả lập gọi hàm liên tục
function simulateCalls(fn, label, times = 10, interval = 50) {
  console.log(`\n=== ${label} ===`);
  for (let i = 0; i < times; i++) {
    setTimeout(() => fn(i), i * interval);
  }
}

// Raw function
const rawFn = (i) => console.log(`Raw: ${i}`);

// Debounced
const debouncedFn = debounce((i) => console.log(`Debounced: ${i}`), 300);

// Throttled
const throttledFn = throttle((i) => console.log(`Throttled: ${i}`), 200);

Kết quả mong đợi:

  • Raw: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 (cả 10 lần)
  • Debounced: 9 (chỉ lần cuối, 300ms sau khi kết thúc)
  • Throttled: 0, 2, 4, 6, 8 (5 lần, cách nhau 200ms)

5. Ứng Dụng Thực Tế Chi Tiết

5.1. AutoSave Form

const saveForm = debounce(async () => {
  const formData = new FormData(document.getElementById('myForm'));
  await fetch('/api/draft', {
    method: 'POST',
    body: formData
  });
  console.log('Draft saved at', new Date().toLocaleTimeString());
}, 1000);

// Mỗi lần người dùng thay đổi form, auto-save được lên lịch lại
document.getElementById('myForm').addEventListener('input', saveForm);

5.2. Resize Handler — Cập Nhật UI

const updateLayout = throttle(() => {
  const width = window.innerWidth;
  if (width < 768) {
    activateMobileLayout();
  } else if (width < 1024) {
    activateTabletLayout();
  } else {
    activateDesktopLayout();
  }
}, 100);

window.addEventListener('resize', updateLayout);

5.3. Button Chống Spam Click

const handleSubmit = debounce(async () => {
  const btn = document.getElementById('submitBtn');
  btn.disabled = true;
  btn.textContent = 'Đang xử lý...';
  
  try {
    await fetch('/api/submit', { method: 'POST' });
    alert('Thành công!');
  } finally {
    btn.disabled = false;
    btn.textContent = 'Gửi';
  }
}, 500, { leading: true });

document.getElementById('submitBtn').addEventListener('click', handleSubmit);

5.4. Analytics Tracking

// Gửi sự kiện scroll tracking, nhưng không quá 1 lần mỗi 2 giây
const trackScroll = throttle(() => {
  const scrollDepth = Math.round(
    (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
  );
  // Gửi depth lên analytics
  console.log(`Scroll depth: ${scrollDepth}%`);
}, 2000);

window.addEventListener('scroll', trackScroll);

6. Debounce/Throttle với Lodash (Production Ready)

Trong dự án thực tế, bạn có thể dùng Lodash — thư viện đã được tối ưu kỹ:

// Cài đặt
// npm install lodash-es

import { debounce, throttle } from 'lodash-es';

// Debounce với đầy đủ options
const search = debounce(
  async (query) => { /* fetch */ },
  300,
  { leading: false, trailing: true, maxWait: 1000 }
);

// Throttle với options
const scrollHandler = throttle(
  () => { /* handle scroll */ },
  200,
  { leading: true, trailing: true }
);

// Hủy debounce khi component unmount (React)
useEffect(() => {
  return () => search.cancel();
}, []);

Các options quan trọng của Lodash:

OptionMô tả
leadingChạy ngay lần đầu tiên
trailingChạy lần cuối sau khi kết thúc
maxWaitThời gian tối đa chờ (debounce) — đảm bảo hàm chạy ít nhất mỗi maxWait ms

7. Edge Cases và Best Practices

7.1. Giữ this Context

Luôn dùng func.apply(this, args) thay vì func(args) để giữ đúng ngữ cảnh this:

const obj = {
  name: 'MyComponent',
  log() { console.log(this.name); }
};

// ✅ Đúng
obj.log = debounce(obj.log, 100); // giữ this

// ❌ Sai
obj.log = debounce(() => obj.log(), 100); // mất this

7.2. Xoá bỏ Debounce Khi Component Unmount

Trong React/Vue, luôn huỷ debounce khi component unmount để tránh memory leak:

// React
useEffect(() => {
  const handleSearch = debounce((q) => fetchResults(q), 300);
  searchInput.addEventListener('input', handleSearch);
  return () => {
    searchInput.removeEventListener('input', handleSearch);
    handleSearch.cancel();
  };
}, []);

7.3. Không Phải Lúc Nào Cũng Cần

Debounce và throttle không phải là cây đũa thần. Cân nhắc:

  • requestAnimationFrame có thể thay thế throttle cho animation (60fps)
  • CSS will-change“ và pointer-events: none giảm tải GPU
  • Event delegation giảm số lượng listener

7.4. Debugging Debounce/Throttle

Khi debug, nhớ rằng debounce trì hoãn — nếu bạn set breakpoint, timeout cũng bị trì hoãn. Dùng console.log hoặc thêm flag:

function debounce(func, delay, { debug = false } = {}) {
  let timeoutId;
  return function(...args) {
    if (debug) console.log('Debounce called, resetting timer');
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      if (debug) console.log('Debounce executing');
      func.apply(this, args);
    }, delay);
  };
}

8. Performance So Sánh

Giả sử một sự kiện scroll kích hoạt 60 lần/giây trong 5 giây (300 lần):

Kỹ thuậtSố lần thực thiMức giảm
Không kiểm soát300—
Throttle 100ms~5083%
Throttle 200ms~2592%
Throttle 500ms~1097%
Debounce 300ms199.7%

Trong ứng dụng thực tế, kết hợp với passive event listeners:

// passive: true cho phép browser không chờ preventDefault
window.addEventListener('scroll', handler, { passive: true });

9. Tổng Kết

Kỹ thuậtTừ khoáUse case
DebounceĐợi rồi chạySearch, validate, auto-save
ThrottleChạy đều đặnScroll, resize, tracking
Debounce (leading)Chạy ngay, đợi sauButton anti-spam
Throttle (trailing)Chạy đều, bắt kịp cuốiAnimation, real-time UI

Code Mẫu Hoàn Chỉnh

// Debounce — tự viết
function debounce(func, delay = 300, { leading = false } = {}) {
  let timeoutId;
  let leadingCalled = false;
  
  const debounced = function(...args) {
    if (leading && !leadingCalled) {
      func.apply(this, args);
      leadingCalled = true;
    }
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      if (leading) leadingCalled = false;
      else func.apply(this, args);
    }, delay);
  };
  
  debounced.cancel = () => clearTimeout(timeoutId);
  return debounced;
}

// Throttle — tự viết
function throttle(func, limit = 200) {
  let inThrottle = false;
  let lastArgs, lastContext;
  
  const throttled = function(...args) {
    if (inThrottle) {
      lastArgs = args;
      lastContext = this;
      return;
    }
    func.apply(this, args);
    inThrottle = true;
    setTimeout(() => {
      inThrottle = false;
      if (lastArgs) {
        func.apply(lastContext, lastArgs);
        lastArgs = lastContext = null;
      }
    }, limit);
  };
  
  throttled.cancel = () => { inThrottle = false; lastArgs = null; };
  return throttled;
}

Hãy nhớ: không phải sự kiện nào cũng cần debounce/throttle. Chỉ áp dụng khi bạn thực sự gặp vấn đề về hiệu suất hoặc gọi API quá nhiều. Và khi áp dụng, hãy chọn đúng kỹ thuật — debounce cho “kết quả cuối cùng”, throttle cho “cập nhật liên tục”.


Bạn đã từng gặp tình huống scroll lag hoặc search bị chậm vì gọi API quá nhiều? Hãy thử áp dụng debounce và throttle ngay hôm nay!

Recently Used Tools