MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript中的防抖与节流技术:改善用户体验

2021-09-251.9k 阅读

一、理解 JavaScript 中的事件触发频率问题

在前端开发中,我们经常会遇到一些频繁触发的事件,比如 scroll(滚动事件)、resize(窗口大小改变事件)、input(输入框输入事件)等。以 scroll 事件为例,当用户滚动页面时,这个事件会在滚动过程中连续不断地触发。如果我们在这个事件的回调函数中执行一些较为复杂的操作,比如进行 DOM 操作、数据请求等,就可能会导致性能问题。

假设我们有一个页面,当用户滚动到页面底部时,需要加载更多的数据。如果直接在 scroll 事件的回调函数中去判断是否滚动到了底部并进行数据加载,由于 scroll 事件触发非常频繁,每次触发都去判断和加载数据,不仅会增加服务器的负担,还可能会因为短时间内多次请求数据而导致页面卡顿。同样,resize 事件在窗口大小改变过程中也会高频触发,如果在回调中进行复杂的布局调整等操作,也容易引发性能问题。

二、防抖(Debounce)技术

2.1 防抖的概念

防抖的核心思想是:当一个事件被触发后,在指定的时间内如果该事件再次被触发,那么就重新开始计时,直到在指定时间内没有再次触发该事件,才执行对应的操作。可以想象成一个人在等公交车,他在站牌下等车,如果车一直不来,他会一直等下去(计时)。但如果在等车的过程中,他看到又有一辆同线路的车来了(事件再次触发),他就会重新开始等车的计时(重新计时),只有当在规定时间内没有再看到同线路的车(在指定时间内事件没有再次触发),他才会上车(执行操作)。

2.2 实现简单的防抖函数

下面我们通过代码来实现一个简单的防抖函数:

function debounce(func, delay) {
    let timer = null;
    return function() {
        const context = this;
        const args = arguments;
        clearTimeout(timer);
        timer = setTimeout(() => {
            func.apply(context, args);
        }, delay);
    };
}

在这段代码中,debounce 函数接收两个参数,func 是我们要延迟执行的函数,delay 是延迟的时间。内部通过 let timer = null 定义了一个定时器变量 timer。返回的匿名函数中,首先获取了函数执行的上下文 this 赋值给 context,以及获取传入的参数 arguments 赋值给 args。然后通过 clearTimeout(timer) 清除之前可能存在的定时器,重新设置一个新的定时器 setTimeout,在延迟 delay 时间后执行 func.apply(context, args),这里使用 apply 方法来确保 func 函数在正确的上下文中执行,并传递正确的参数。

2.3 防抖函数在实际场景中的应用

2.3.1 搜索框输入联想

假设我们有一个搜索框,当用户输入内容时,需要根据输入的内容实时显示搜索联想结果。如果每次用户输入一个字符都立即向服务器发送请求获取联想结果,会导致频繁的网络请求,增加服务器负担,同时也可能因为请求过于频繁而使页面出现卡顿。我们可以使用防抖技术来优化这个过程。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>搜索框防抖示例</title>
</head>

<body>
    <input type="text" id="searchInput" placeholder="请输入搜索内容">
    <div id="result"></div>
    <script>
        function searchSuggestions(query) {
            // 模拟向服务器发送请求获取联想结果
            return new Promise((resolve) => {
                setTimeout(() => {
                    resolve(`联想结果:${query}`);
                }, 1000);
            });
        }

        function displayResults(results) {
            const resultDiv = document.getElementById('result');
            resultDiv.textContent = results;
        }

        const debouncedSearch = debounce(async function() {
            const query = this.value;
            if (query) {
                const results = await searchSuggestions(query);
                displayResults(results);
            } else {
                const resultDiv = document.getElementById('result');
                resultDiv.textContent = '';
            }
        }, 300);

        const searchInput = document.getElementById('searchInput');
        searchInput.addEventListener('input', debouncedSearch);
    </script>
</body>

</html>

在上述代码中,searchSuggestions 函数模拟了向服务器发送请求获取联想结果的过程,通过 setTimeout 模拟了网络延迟。displayResults 函数用于将获取到的联想结果显示在页面上。debouncedSearch 是经过防抖处理的函数,当用户在搜索框输入内容时,只有在停止输入 300 毫秒后,才会执行 debouncedSearch 函数中的逻辑,从而避免了频繁的网络请求。

2.3.2 窗口大小改变时的布局调整

当窗口大小改变时,我们可能需要对页面的布局进行调整。如果直接在 resize 事件的回调函数中进行布局调整,由于 resize 事件触发频繁,会导致性能问题。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>窗口大小改变防抖示例</title>
    <style>
        body {
            margin: 0;
            padding: 0;
        }

        #content {
            width: 50%;
            height: 200px;
            background-color: lightblue;
            margin: 50px auto;
        }
    </style>
</head>

<body>
    <div id="content"></div>
    <script>
        function adjustLayout() {
            const content = document.getElementById('content');
            const windowWidth = window.innerWidth;
            if (windowWidth < 600) {
                content.style.width = '80%';
            } else {
                content.style.width = '50%';
            }
        }

        const debouncedAdjust = debounce(adjustLayout, 300);

        window.addEventListener('resize', debouncedAdjust);
    </script>
</body>

</html>

在这段代码中,adjustLayout 函数根据窗口的宽度来调整 #content 元素的宽度,实现简单的响应式布局。通过对 adjustLayout 函数进行防抖处理,在窗口大小改变时,只有在停止改变 300 毫秒后,才会执行布局调整操作,提升了性能。

2.4 防抖的优缺点

2.4.1 优点

  • 减少不必要的操作:有效避免了频繁触发事件导致的大量重复操作,比如在搜索框输入联想场景中,减少了不必要的网络请求;在窗口大小改变布局调整场景中,减少了频繁的 DOM 操作,从而提升了性能。
  • 提升用户体验:避免了因为频繁操作可能导致的页面卡顿,使用户操作更加流畅。

2.4.2 缺点

  • 延迟性:由于存在延迟执行的特性,在一些对实时性要求极高的场景下可能不太适用。比如在一些实时绘图的应用中,如果使用防抖技术,用户绘制线条时可能会感觉到明显的延迟。

三、节流(Throttle)技术

3.1 节流的概念

节流的核心思想是:在一定时间内,无论事件触发多么频繁,都只执行一次操作。可以想象成一个水龙头,我们通过调节水龙头的阀门,使得水在一定时间内只能流出一定量。即使我们快速地开关水龙头,在规定时间内也只能流出固定量的水。在事件触发的场景中,就是在指定的时间间隔内,无论事件触发了多少次,都只执行一次我们定义的操作。

3.2 实现简单的节流函数

下面是一个简单的节流函数实现:

function throttle(func, delay) {
    let lastTime = 0;
    return function() {
        const context = this;
        const args = arguments;
        const now = new Date().getTime();
        if (now - lastTime >= delay) {
            func.apply(context, args);
            lastTime = now;
        }
    };
}

在这段代码中,throttle 函数接收 funcdelay 两个参数,func 是要节流执行的函数,delay 是时间间隔。内部通过 let lastTime = 0 记录上一次执行函数的时间。返回的匿名函数中,获取当前时间 now = new Date().getTime(),然后判断当前时间与上一次执行时间的间隔是否大于等于 delay,如果满足条件,则执行 func.apply(context, args),并更新 lastTime 为当前时间。

3.3 节流函数在实际场景中的应用

3.3.1 滚动加载更多

在页面滚动加载更多数据的场景中,如果使用防抖技术,可能会因为用户滚动速度较快,导致加载更多数据不及时。而节流技术可以在用户滚动过程中,按照一定的时间间隔来加载更多数据,保证了数据加载的及时性和性能的平衡。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>滚动加载更多节流示例</title>
    <style>
        body {
            margin: 0;
            padding: 0;
        }

        #list {
            list-style-type: none;
            padding: 0;
        }

        #list li {
            padding: 10px;
            border-bottom: 1px solid #ccc;
        }
    </style>
</head>

<body>
    <ul id="list"></ul>
    <script>
        function loadMoreData() {
            const list = document.getElementById('list');
            const newItem = document.createElement('li');
            newItem.textContent = `新数据项 ${new Date().getTime()}`;
            list.appendChild(newItem);
        }

        const throttledLoadMore = throttle(loadMoreData, 1000);

        window.addEventListener('scroll', throttledLoadMore);
    </script>
</body>

</html>

在上述代码中,loadMoreData 函数模拟了加载更多数据并将其添加到页面列表中的操作。通过对 loadMoreData 函数进行节流处理,在用户滚动页面时,每 1000 毫秒执行一次 loadMoreData 函数,避免了频繁加载数据导致的性能问题,同时又能保证用户在滚动过程中有新数据不断加载。

3.3.2 按钮点击限制

在一些场景中,我们可能需要限制按钮的点击频率,防止用户因为误操作或者恶意点击导致多次重复提交等问题。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>按钮点击节流示例</title>
</head>

<body>
    <button id="submitButton">提交</button>
    <script>
        function submitForm() {
            console.log('表单提交成功');
        }

        const throttledSubmit = throttle(submitForm, 2000);

        const submitButton = document.getElementById('submitButton');
        submitButton.addEventListener('click', throttledSubmit);
    </script>
</body>

</html>

在这段代码中,submitForm 函数模拟了表单提交的操作。通过对 submitForm 函数进行节流处理,按钮点击后,每 2000 毫秒才能再次触发 submitForm 函数,有效限制了按钮的点击频率,避免了多次重复提交。

3.4 节流的优缺点

3.4.1 优点

  • 控制执行频率:能够按照设定的时间间隔来执行操作,保证了操作的执行频率在可控范围内,在需要一定实时性又要避免过度频繁操作的场景中非常适用,如滚动加载更多数据。
  • 提升性能:避免了事件高频触发导致的性能问题,例如在按钮点击限制场景中,防止了因为频繁点击带来的性能开销。

3.4.2 缺点

  • 无法完全避免不必要操作:虽然节流控制了操作的执行频率,但在某些情况下,可能还是会执行一些不必要的操作。比如在滚动加载更多数据场景中,如果用户快速滚动了一段距离,但由于节流的时间间隔设置,可能会加载一些用户实际上不会看到的数据。

四、防抖与节流的选择

4.1 根据场景需求选择

  • 实时性要求不高的场景:如果对实时性要求不高,更注重减少不必要的操作,那么防抖技术是比较好的选择。例如搜索框输入联想场景,用户更关心最终输入完成后的联想结果,而不是输入过程中实时的结果,所以使用防抖可以有效减少网络请求次数,提升性能。
  • 实时性要求较高的场景:当对实时性有一定要求,且需要控制操作频率时,节流技术更为合适。比如在页面滚动加载更多数据场景中,用户希望在滚动过程中能不断加载新数据,同时又要避免因为频繁加载导致性能问题,节流技术就能很好地满足这种需求。

4.2 根据事件触发频率选择

  • 高频触发事件:对于触发频率极高的事件,如 scrollresize 等,如果希望事件停止触发一段时间后再执行操作,防抖是较好的选择;如果希望在事件高频触发过程中按一定频率执行操作,节流则更为合适。
  • 低频触发事件:对于低频触发事件,如按钮点击等,使用节流可以限制点击频率,防止误操作或恶意点击;而防抖在此类场景下可能不太适用,因为低频触发事件本身不会带来频繁操作的性能问题。

4.3 综合考虑性能和用户体验

在选择防抖还是节流时,需要综合考虑性能和用户体验。防抖技术通过延迟操作减少了不必要的执行,能有效提升性能,但可能会带来一定的延迟感;节流技术虽然保证了一定的实时性,但可能会执行一些不必要的操作。在实际开发中,要根据具体业务场景,在性能和用户体验之间找到一个平衡点。例如在一些对交互流畅性要求较高的应用中,可能需要更注重减少延迟感,此时节流可能更合适;而在一些对性能优化要求极高的后台管理系统等应用中,防抖可能更能满足需求。

五、优化防抖与节流函数

5.1 防抖函数的优化

5.1.1 立即执行选项

在某些场景下,我们可能希望在事件触发时立即执行一次函数,然后再进行防抖处理。比如在搜索框输入场景中,用户输入第一个字符时,希望立即显示联想结果,之后再按照防抖规则延迟执行。我们可以对防抖函数进行如下优化:

function debounce(func, delay, immediate = false) {
    let timer = null;
    return function() {
        const context = this;
        const args = arguments;
        if (timer) {
            clearTimeout(timer);
        }
        if (immediate &&!timer) {
            func.apply(context, args);
        }
        timer = setTimeout(() => {
            if (!immediate) {
                func.apply(context, args);
            }
        }, delay);
    };
}

在这个优化后的防抖函数中,增加了一个 immediate 参数,默认为 false。当 immediatetrue 时,事件触发时如果 timernull(即首次触发),则立即执行 func 函数;之后的触发则按照正常的防抖逻辑处理。

5.1.2 取消防抖

有时候我们可能需要在特定情况下取消防抖操作,比如在用户离开某个页面时,需要取消正在等待执行的防抖函数。我们可以给防抖函数添加一个 cancel 方法来实现取消功能。

function debounce(func, delay, immediate = false) {
    let timer = null;
    const debounced = function() {
        const context = this;
        const args = arguments;
        if (timer) {
            clearTimeout(timer);
        }
        if (immediate &&!timer) {
            func.apply(context, args);
        }
        timer = setTimeout(() => {
            if (!immediate) {
                func.apply(context, args);
            }
        }, delay);
    };
    debounced.cancel = function() {
        if (timer) {
            clearTimeout(timer);
            timer = null;
        }
    };
    return debounced;
}

在上述代码中,给返回的 debounced 函数添加了一个 cancel 方法,调用该方法可以清除定时器,从而取消防抖操作。

5.2 节流函数的优化

5.2.1 时间戳与定时器结合

前面实现的节流函数是基于时间戳的方式,每次判断当前时间与上一次执行时间的间隔。我们还可以结合定时器来实现节流,这样可以更灵活地控制首次执行和末次执行的情况。

function throttle(func, delay) {
    let timer = null;
    let lastTime = 0;
    return function() {
        const context = this;
        const args = arguments;
        const now = new Date().getTime();
        if (!timer && now - lastTime >= delay) {
            func.apply(context, args);
            lastTime = now;
        } else if (!timer) {
            timer = setTimeout(() => {
                func.apply(context, args);
                lastTime = new Date().getTime();
                timer = null;
            }, delay - (now - lastTime));
        }
    };
}

在这个优化后的节流函数中,通过 timerlastTime 两个变量,结合时间戳和定时器来实现节流。当 now - lastTime >= delaytimernull 时,立即执行函数;否则,根据剩余时间设置定时器,保证在 delay 时间间隔内执行一次函数。

5.2.2 支持立即执行和 trailing 选项

类似于防抖函数的优化,节流函数也可以支持立即执行和 trailing 选项。立即执行选项用于在事件触发时立即执行一次函数,trailing 选项用于在节流结束时(即最后一次触发事件后的 delay 时间)再执行一次函数。

function throttle(func, delay, immediate = false, trailing = true) {
    let timer = null;
    let lastTime = 0;
    return function() {
        const context = this;
        const args = arguments;
        const now = new Date().getTime();
        if (immediate &&!timer && now - lastTime >= delay) {
            func.apply(context, args);
            lastTime = now;
        } else if (!timer) {
            timer = setTimeout(() => {
                if (trailing) {
                    func.apply(context, args);
                    lastTime = new Date().getTime();
                }
                timer = null;
            }, delay - (now - lastTime));
        }
    };
}

在这个优化后的节流函数中,增加了 immediatetrailing 两个参数。当 immediatetrue 时,满足条件会立即执行函数;当 trailingtrue 时,在节流结束时会执行一次函数。

六、总结防抖与节流的应用场景拓展

6.1 移动端触摸事件

在移动端开发中,触摸事件如 touchstarttouchmovetouchend 等也经常会遇到触发频率高的问题。例如在一个地图应用中,用户通过手指在屏幕上滑动来移动地图,如果直接在 touchmove 事件回调中实时更新地图位置,可能会导致性能问题。此时可以使用节流技术,按照一定的时间间隔来更新地图位置,既能保证地图的流畅移动,又能避免性能开销。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>移动端触摸事件节流示例</title>
    <style>
        #map {
            width: 100%;
            height: 400px;
            background-color: lightgray;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <script>
        function updateMapPosition(event) {
            const map = document.getElementById('map');
            map.style.transform = `translateX(${event.touches[0].clientX}px) translateY(${event.touches[0].clientY}px)`;
        }

        const throttledUpdate = throttle(updateMapPosition, 100);

        const mapElement = document.getElementById('map');
        mapElement.addEventListener('touchmove', throttledUpdate);
    </script>
</body>

</html>

在上述代码中,updateMapPosition 函数根据触摸点的位置来更新地图的位置。通过对 updateMapPosition 函数进行节流处理,在 touchmove 事件触发时,每 100 毫秒执行一次更新操作,保证了地图移动的流畅性。

6.2 游戏开发中的事件处理

在 JavaScript 游戏开发中,也会经常用到防抖和节流技术。比如在一个射击游戏中,玩家点击射击按钮发射子弹,如果不进行限制,玩家可能会快速连续点击导致子弹发射过于频繁,影响游戏平衡。可以使用节流技术来限制射击频率,保证游戏的公平性和可玩性。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>游戏射击按钮节流示例</title>
</head>

<body>
    <button id="shootButton">射击</button>
    <script>
        function shoot() {
            console.log('发射子弹');
        }

        const throttledShoot = throttle(shoot, 500);

        const shootButton = document.getElementById('shootButton');
        shootButton.addEventListener('click', throttledShoot);
    </script>
</body>

</html>

在这段代码中,shoot 函数模拟了发射子弹的操作。通过对 shoot 函数进行节流处理,按钮点击后,每 500 毫秒才能再次发射子弹,限制了射击频率。

6.3 表单验证与提交

在表单验证和提交过程中,防抖和节流也能发挥作用。比如在表单输入验证中,当用户在输入框中输入内容时,实时验证输入是否合法。如果使用防抖技术,在用户停止输入一段时间后再进行验证,可以减少不必要的验证操作,提升性能。而在表单提交按钮点击时,使用节流技术可以防止用户多次点击提交按钮,避免重复提交表单数据。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>表单验证与提交示例</title>
</head>

<body>
    <form id="myForm">
        <label for="username">用户名:</label>
        <input type="text" id="username" required>
        <br>
        <label for="password">密码:</label>
        <input type="password" id="password" required>
        <br>
        <button type="submit">提交</button>
    </form>
    <script>
        function validateInput() {
            const username = document.getElementById('username').value;
            const password = document.getElementById('password').value;
            if (username && password) {
                console.log('输入合法');
            } else {
                console.log('输入不合法');
            }
        }

        const debouncedValidate = debounce(validateInput, 300);

        const usernameInput = document.getElementById('username');
        const passwordInput = document.getElementById('password');
        usernameInput.addEventListener('input', debouncedValidate);
        passwordInput.addEventListener('input', debouncedValidate);

        function submitForm(event) {
            event.preventDefault();
            console.log('表单提交成功');
        }

        const throttledSubmit = throttle(submitForm, 2000);

        const form = document.getElementById('myForm');
        form.addEventListener('submit', throttledSubmit);
    </script>
</body>

</html>

在上述代码中,validateInput 函数用于验证用户名和密码输入是否合法,通过防抖处理,在用户停止输入 300 毫秒后进行验证。submitForm 函数用于处理表单提交,通过节流处理,限制表单提交按钮每 2000 毫秒只能点击一次,避免重复提交。

通过对防抖与节流技术在不同场景下的应用分析,我们可以看到这两种技术在优化用户体验和提升性能方面具有重要作用。在实际开发中,我们需要根据具体的业务需求和场景特点,合理选择和应用防抖与节流技术,以打造更加流畅、高效的 Web 应用。