JavaScript 防抖与节流详解

小飞兽 JavaScript 199 次阅读 2026-06-04

JavaScript 防抖与节流详解

引言

在前端开发中,DOM 事件监听是再常见不过的操作。用户的每一次点击、输入、滚动、窗口调整大小等行为,都会触发大量事件。如果事件处理函数涉及复杂的计算、昂贵的 API 调用或频繁的 DOM 操作,这些频繁触发的事件将导致严重的性能问题,甚至造成页面卡顿。防抖(Debounce)和节流(Throttle)作为两种经典的性能优化技术,能够有效控制事件处理函数的执行频率,是每个前端开发者必须掌握的技能。本文将系统讲解这两种技术的原理、实现方式以及在实际开发中的应用场景。

防抖(Debounce)

防抖的原理

防抖的核心思想是:当事件持续触发时,事件处理函数不会被立即执行,而是等待一个静默期。只有当用户停止触发事件超过指定时间后,事件处理函数才会被执行。如果在等待期间事件再次被触发,计时器会重新开始计时。这个过程就像我们等公交车:只要不断有人上车,司机就会一直等待,直到没有人再上车,才关门发车。

防抖的代码实现

#### 基础版本

function debounce(func, wait) {
    let timeout;
    return function(...args) {
        const context = this;
        clearTimeout(timeout);
        timeout = setTimeout(() => {
            func.apply(context, args);
        }, wait);
    };
}

使用示例:

function handleSearch(keyword) {
    console.log(`搜索关键词: ${keyword}`);
}

const debouncedSearch = debounce(handleSearch, 300);

// 模拟用户快速输入
debouncedSearch('a');
debouncedSearch('ab');
debouncedSearch('abc');
debouncedSearch('abcd');

// 只会在停止输入 300ms 后执行一次
// 输出: 搜索关键词: abcd

#### 立即执行版本

有时我们需要在事件触发时立即执行一次,然后进入防抖等待:

function debounce(func, wait, immediate = false) {
    let timeout;
    return function(...args) {
        const context = this;
        clearTimeout(timeout);
        
        if (immediate) {
            // 如果已经执行过,timeout 存在则表示在等待期内
            const callNow = !timeout;
            timeout = setTimeout(() => {
                timeout = null;
            }, wait);
            if (callNow) {
                func.apply(context, args);
            }
        } else {
            timeout = setTimeout(() => {
                func.apply(context, args);
            }, wait);
        }
    };
}

运行效果:

const handleInput = debounce((value) => {
    console.log(`最终输入值: ${value}`);
}, 300, true);

handleInput('a');      // 立即执行: 最终输入值: a
handleInput('ab');     // 在 300ms 内,不执行
handleInput('abc');    // 在 300ms 内,不执行
// 300ms 后 timeout 清除,下次调用会立即执行
handleInput('abcd');   // 立即执行: 最终输入值: abcd

#### 带取消功能的版本

function debounce(func, wait, immediate = false) {
    let timeout;
    
    const debounced = function(...args) {
        const context = this;
        clearTimeout(timeout);
        
        if (immediate) {
            const callNow = !timeout;
            timeout = setTimeout(() => timeout = null, wait);
            if (callNow) func.apply(context, args);
        } else {
            timeout = setTimeout(() => func.apply(context, args), wait);
        }
    };
    
    debounced.cancel = function() {
        clearTimeout(timeout);
        timeout = null;
    };
    
    return debounced;
}

节流(Throttle)

节流的原理

节流的核心思想是:限制事件处理函数的执行频率。无论事件触发多么频繁,事件处理函数在单位时间内只会执行一次。这就像水龙头滴水:不管你怎么拧动,每次只能滴出一滴水。节流确保了函数调用的稳定性,不会因为事件过于频繁而导致性能问题。

节流的代码实现

#### 时间戳版本

function throttle(func, wait) {
    let previous = 0;
    return function(...args) {
        const now = Date.now();
        const context = this;
        // remaining 是距离下一次执行还需要等待的时间
        const remaining = wait - (now - previous);
        
        if (remaining <= 0 || remaining > wait) {
            func.apply(context, args);
            previous = now;
        }
    };
}

运行效果:

const handleScroll = throttle((scrollY) => {
    console.log(`滚动位置: ${scrollY}`);
}, 200);

handleScroll(100);  // 立即执行
handleScroll(200);  // 立即执行
handleScroll(300);  // 立即执行
// 在 200ms 内的调用都会立即执行(因为 previous = 0)

#### 定时器版本

function throttle(func, wait) {
    let timeout;
    return function(...args) {
        const context = this;
        if (!timeout) {
            timeout = setTimeout(() => {
                timeout = null;
                func.apply(context, args);
            }, wait);
        }
    };
}

运行效果:

const handleScroll = throttle((scrollY) => {
    console.log(`滚动位置: ${scrollY}`);
}, 200);

handleScroll(100);  // 设置 timeout,200ms 后执行
handleScroll(200);  // timeout 存在,不执行
handleScroll(300);  // timeout 存在,不执行
// 200ms 后执行: 滚动位置: 100

#### 组合版本(性能最优)

function throttle(func, wait, options = { leading: true, trailing: true }) {
    let timeout = null;
    let previous = 0;
    const { leading, trailing } = options;
    
    return function(...args) {
        const context = this;
        const now = Date.now();
        
        if (!previous && !leading) previous = now;
        
        const remaining = wait - (now - previous);
        
        if (remaining <= 0 || remaining > wait) {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            previous = now;
            func.apply(context, args);
            previous = now;
        } else if (!timeout && trailing !== false) {
            timeout = setTimeout(() => {
                previous = !leading ? 0 : Date.now();
                timeout = null;
                func.apply(context, args);
            }, remaining);
        }
    };
}

防抖与节流的区别

核心区别

| 特性 | 防抖(Debounce) | 节流(Throttle) |
|------|-----------------|-----------------|
| 执行时机 | 事件停止触发后延迟执行 | 持续触发时固定频率执行 |
| 时间控制 | 最后一次触发有效 | 每段时间内至少执行一次 |
| 适用场景 | 用户输入确认、搜索建议 | 滚动监听、窗口调整、按钮防双击 |
| 典型延迟 | 较长(500ms-2000ms) | 较短(100ms-300ms) |

视觉对比

防抖示例(wait=300ms):
用户操作: --a--ab--abc--abcd--
执行时机: -----------------abcd

节流示例(wait=300ms):
用户操作: --a--ab--abc--abcd--
执行时机: --a-----------abcd--

实际应用场景

1. 搜索输入框(防抖)

const searchInput = document.getElementById('search');
const suggestions = document.getElementById('suggestions');

const fetchSuggestions = debounce(async (keyword) => {
    if (!keyword.trim()) {
        suggestions.innerHTML = '';
        return;
    }
    const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
    const results = await response.json();
    suggestions.innerHTML = results.map(r => `<li>${r}</li>`).join('');
}, 300);

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

效果: 用户输入时不会立即发起请求,只在用户停止输入 300ms 后才发起请求,减少服务器压力。

2. 窗口调整监听(节流)

const updateLayout = throttle(() => {
    console.log('重新计算布局...');
    // 执行复杂的布局计算
    const width = window.innerWidth;
    const height = window.innerHeight;
    document.body.style.fontSize = width < 768 ? '14px' : '16px';
}, 200);

window.addEventListener('resize', updateLayout);

效果: 窗口调整大小事件触发非常频繁,节流确保布局计算每隔 200ms 最多执行一次,避免频繁重排重绘。

3. 滚动到底部加载更多(节流)

const loadMore = throttle(async () => {
    if (isLoading || hasMore) return;
    isLoading = true;
    
    const response = await fetch(`/api/items?page=${currentPage}`);
    const data = await response.json();
    
    appendItems(data.items);
    currentPage++;
    isLoading = false;
    
    if (data.hasMore === false) {
        hasMore = true;
        window.removeEventListener('scroll', onScroll);
    }
}, 500);

window.addEventListener('scroll', onScroll);

4. 表单提交防抖(防止重复提交)

const submitForm = debounce(async (formData) => {
    const response = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(formData)
    });
    const result = await response.json();
    showResult(result);
}, 1000, true);

submitBtn.addEventListener('click', () => {
    submitBtn.disabled = true;
    submitForm(getFormData());
    setTimeout(() => submitBtn.disabled = false, 1000);
});

5. 按钮防双击(立即执行防抖)

const submitOrder = debounce(() => {
    console.log('订单提交中...');
    // 执行订单提交逻辑
}, 2000, true);

confirmBtn.addEventListener('click', () => {
    submitOrder();
});

常见问题与解决方案

Q1: 防抖后函数返回的 Promise 如何处理?

默认防抖实现无法获取异步函数的返回值:

// 改进版:返回 Promise
function debounce(func, wait, immediate = false) {
    let timeout;
    let resultPromise;
    
    return function(...args) {
        const context = this;
        
        return new Promise((resolve, reject) => {
            clearTimeout(timeout);
            
            const execute = () => {
                try {
                    const result = func.apply(context, args);
                    resolve(result);
                } catch (e) {
                    reject(e);
                }
            };
            
            if (immediate) {
                const callNow = !timeout;
                timeout = setTimeout(() => timeout = null, wait);
                if (callNow) execute();
            } else {
                timeout = setTimeout(execute, wait);
            }
        });
    };
}

// 使用
const search = debounce(async (q) => {
    return await fetch(`/api/search?q=${q}`).then(r => r.json());
}, 300);

search('javascript').then(results => console.log(results));

Q2: 事件监听中使用 <code>addEventListener</code> 的 this 问题

const handleClick = throttle(function() {
    console.log('点击了,当前元素:', this.id);
}, 500);

// 正确:保持 this 绑定
button.addEventListener('click', handleClick);

// 错误:this 会丢失
button.addEventListener('click', () => handleClick());

Q3: 取消已经安排的函数执行

const handleSearch = debounce((q) => {
    console.log(`搜索: ${q}`);
}, 300);

// 在某些场景下需要取消
cancelBtn.addEventListener('click', () => {
    handleSearch.cancel(); // 取消待执行的搜索
    console.log('搜索已取消');
});

Q4: 第一次触发时立即执行还是最后一次?

选择取决于业务场景:

  • <strong>搜索输入</strong>:通常用普通防抖(最后一次)

  • <strong>按钮防抖</strong>:通常用立即执行防抖(第一次)

  • <strong>滚动加载</strong>:通常用节流

Q5: 第三方库 vs 手写实现

| 方案 | 优点 | 缺点 |
|------|------|------|
| 手写实现 | 无依赖、定制灵活、轻量 | 需要自己处理边界情况 |
| lodash.debounce | 功能完善、经过大量验证 | 包体积增加(约 3KB) |
| underscore.debounce | 经典库、兼容性好 | 同样增加包体积 |

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

// tree-shaking 支持,按需引入
import debounce from 'lodash.debounce';

延伸阅读

1. Lodash 官方文档 - Debounce & Throttle
https://lodash.com/docs/4.17.15#debounce
https://lodash.com/docs/4.17.15#throttle

2. MDN - setTimeout
https://developer.mozilla.org/zh-CN/docs/Web/API/Window/setTimeout

3. CSS-Tricks - Debouncing and Throttling
https://css-tricks.com/debouncing-throttling-explained-examples/

4. JavaScript 事件节流防抖的区别与实现
https://github.com/mqyqingfeng/Blog

5. You Might Not Need Underscore
https://youmightnotneed.com/lodash/

6. Web Performance Working Draft - Event Timing
https://www.w3.org/TR/event-timing/

7. Google Web Fundamentals - Rendering Performance
https://developers.google.com/web/fundamentals/performance/rendering

总结

防抖与节流是前端性能优化的两大利器。防抖适合「等待用户安静下来」的场景,如搜索输入、窗口调整结束确认;节流适合「限制执行频率」的场景,如滚动监听、频繁点击防护。选择哪种技术取决于具体的业务需求和用户体验目标。理解其底层原理后,手写实现并不复杂,但在生产环境中,使用经过验证的 lodash 等库通常是更稳妥的选择。无论采用哪种方案,都应当配合 Chrome DevTools 等性能分析工具进行测试,确保优化真正带来了性能提升而非新的问题。