腾讯面试官:手写一个节流防抖看看

引言

大家好啊,我是前端拿破轮。

今天和大家聊聊节流防抖

只要你是一位前端开发者,并且参加过面试,那么大概率对这两个东西并不陌生。很多面试中经常让我们手写实现节流防抖函数。今天拿破轮就和大家一起把节流防抖搞清楚,从此以后妈妈再也不用担心我的节流防抖了。

老规矩,带着问题来读文章,读完后大家可以回头再看看这几个问题解决没有,能否用自己的话解释清楚。

  1. 什么是节流防抖?
  2. 为什么需要节流防抖?
  3. 如何实现节流防抖?
  4. 节流防抖的最佳实践是什么?如何在项目中使用?

什么是节流防抖?

概念 定义 关键词
防抖 多次触发事件后,只在最后一次触发结束一段时间后执行回调 拖延执行
节流 在一定时间内,事件只能触发一次。 间隔执行

防抖(Debounce)

防抖是指在事件被触发n秒后,再执行回调。如果在n秒内又被触发,则重新计时。简单来说,就是"等你停下来再说"。

这么说好像有点抽象。我们来看一个大家都耳熟能详的例子。

王者荣耀的回城机制

当我们在游戏中点击回城按钮后,英雄会原地不动,等待下面的进度条完成,这就相当于回城事件被触发了 。但是此时回城还没有真正执行 ,得等待下面的进度条完成之后,才会真正回城。如果进度条还没有完成的时候,再次点击了回城按钮,那么之前的进度就会清空,重新开始新的进度条计时。直到最后一次点击回城按钮,并且后续没有再点击,才会在进度条结束后真正回城。

这就是防抖。当我们调用某个函数后,它不会立即执行,而是会等待一段时间,这个时间我们可以自由设置。在这个等待时间内,如果没有再次调用函数,则在等待时间结束后真正执行函数代码。如果在等待过程中再次调用了函数,则重新开始计时等待。

节流(Throttle)

节流是指规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,则只有第一次生效。简单来说,就是"按固定频率执行"。

怎么理解呢?

还是大家耳熟能详的王者荣耀,只不过与之类似的是王者荣耀的技能释放

当我们使用马可波罗狠狠地用一技能扫一梭子之后,一技能会陷入冷却,在冷却期间,无论我们再怎么点击,点击多少次,都无法再释放出技能。除非技能冷却结束,刷新了新的技能,我们才可以再次释放。

这就是节流。当我们调用某个函数后,在设置的一段时间内,如果多次调用,也只有第一次生效。除非超出当前设定的时间,才能再次调用。

相信通过上面的两个例子,大家对于什么是防抖和节流应该有了比较直观的认识,可以再好好体会一下。

为什么需要节流和防抖

节流和防抖的目的是差不多的,就是限制某些函数的调用频率。

我们下面以防抖为例,来看一下为什么需要防抖。

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <title>联想搜索 - 防抖对比</title>
  <style>
    body {
      font-family: sans-serif;
      padding: 20px;
    }

    input {
      width: 300px;
      padding: 8px;
      margin-bottom: 10px;
    }

    ul {
      margin-top: 5px;
      padding-left: 20px;
    }

    li {
      line-height: 1.6;
    }

    .section {
      margin-bottom: 40px;
    }

    #log {
      margin-top: 20px;
      font-family: monospace;
      background: #f9f9f9;
      padding: 10px;
      border: 1px solid #ccc;
      height: 100px;
      overflow: auto;
    }
  </style>
</head>

<body>
  <h1>联想搜索:防抖 vs 不防抖</h1>

  <div class="section">
    <h3>🔄 输入框(使用防抖)</h3>
    <input type="text" id="debounced-input" placeholder="输入关键词..." />
    <ul id="debounced-result"></ul>
  </div>

  <div class="section">
    <h3>⚡ 输入框(不使用防抖)</h3>
    <input type="text" id="normal-input" placeholder="输入关键词..." />
    <ul id="normal-result"></ul>
  </div>

  <div id="log"></div>

  <script>
    // 防抖函数
    function debounce(fn, delay) {
      let timer = null;
      return function (...args) {
        clearTimeout(timer);
        timer = setTimeout(() => {
          fn.apply(this, args);
        }, delay);
      };
    }

    function log(msg) {
      const logEl = document.getElementById('log');
      const timestamp = new Date().toLocaleTimeString();
      logEl.innerHTML += `[${timestamp}] ${msg}<br>`;
      logEl.scrollTop = logEl.scrollHeight;
    }

    // 请求函数
    async function fetchSuggestions(query, targetUl, label) {
      if (query === '') {
        targetUl.innerHTML = '';
        return;
      }

      const res = await fetch(`http://localhost:10003/search?q=${encodeURIComponent(query)}`);
      const data = await res.json();
      targetUl.innerHTML = data.map(item => `<li>${item}</li>`).join('');

      log(`${label} 触发请求,关键词:${query}`);
    }

    // 带防抖的输入框
    const debouncedInput = document.getElementById('debounced-input');
    const debouncedResult = document.getElementById('debounced-result');
    debouncedInput.addEventListener('input',
      debounce((e) => fetchSuggestions(e.target.value.trim(), debouncedResult, '✅ 防抖'), 300)
    );

    // 不带防抖的输入框
    const normalInput = document.getElementById('normal-input');
    const normalResult = document.getElementById('normal-result');
    normalInput.addEventListener('input',
      (e) => fetchSuggestions(e.target.value.trim(), normalResult, '❌ 无防抖')
    );
  </script>
</body>

</html>

上面是一个html文件,展示的是我们在使用搜索功能时一个很常见的场景。当我们在输入框中输入部分字符时,会在下面进行联想搜索可能的结果。

那这些可能的结果是怎么来的呢?通常需要利用Ajax向后端发送请求来获取。所以我们可以监听inputinput事件,当有输入变化时,向后端发送请求。下面是使用express书写的一个简单的后端服务器。

js 复制代码
import express from 'express';
import cors from 'cors';

const app = express();
const PORT = 10003;

app.use(cors());

const keywords = [
  'apple', 'application', 'apply', 'applet', 'banana', 'band', 'bank',
  'cat', 'car', 'cart', 'camera', 'code', 'coding', 'color',
];

app.get('/search', (req, res) => {
  const query = req.query.q?.toLowerCase() || '';
  const matched = keywords.filter((word) => word.includes(query));
  setTimeout(() => {
    res.json(matched);
  }, 300);
})

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
})

这种情况就是典型的需要进行防抖的场景。因为用户可能在很短的时间内快速输入多个字符,如果内容一有变化就发送请求,将会导致请求次数过于频繁,造成性能严重下降和请求浪费。

我们先来测试一下没有防抖的输入框。

我们可以看到,在没有防抖的情况下,当我们在输入框中写入apple这五个字符的过程中,整整发送了五个请求!这显然不合适。那为什么防抖可以解决呢?回顾防抖的概念,在触发函数后开始计时,如果在计时内再次触发,则之前的计时作废,重新开始计时。直到计时结束后,才会真正执行函数。

在我们输入框的场景中,如果用户正在快速输入,那么他就会不断地打断计时,导致计时重新开始,只有输入完成间隔一段时间后,才会真正发送请求。下面是使用了防抖的输入框:

我们在输入框中输入apple后,只在最后输入完之后发送了一次请求,达到了我们的目的。

节流的话是用在需要第一次就触发的场景。核心思想和防抖是类似的。这里不再赘述,大家感兴趣可以自己体验。

如何实现节流防抖

这里我们实现最经典的节流和防抖。两者都应该是一个高阶函数(HOF),也就是它们接受一些参数,返回值是一个函数

节流

经典节流通过时间戳来实现。第一次触发后立即执行,此后在我们设定的时间内触发无效,直到超出设定时间后,触发才会有新的执行。

js 复制代码
const throttle = (fn, delay) => {
  // 上一次调用的时间
  let lastTime = 0;
  return function (...args) {
    // 获取现在的时间戳
    const now = Date.now();
    // 如果当前时间距离上次调用的时间大于等于delay则执行
    if (now - lastTime >= delay) {
      // 本次调用时间会作为后续调用的lastTime
      lastTime = now;
      fn.apply(this, args);
    }
  }
}

这里一定要注意两个点:

  1. 在进入if (now - lastTime >= delay)的判断分支之后,一定要记得更新lastTime
  2. 在调用fn时一定要将其this绑定到我们返回的函数上,否则会出现错误。我们这里使用的是apply进行绑定,所以传递的第二个参数值是一个数组。

防抖

经典的防抖通过计时器来实现。只有最后一次触发一段时间后才会执行。

js 复制代码
const debounce = (fn, delay) => {
  let timer = null;
  return function (...args) {
    // 清除之间的计时
    clearTimeout(timer);

    // 重新开始计时
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay)
  }
}

这里也有两个点需要注意:

  1. 每次函数调用时要先清空计时器
  2. setTimeout的回调函数必须使用箭头函数才能绑定到我们返回的函数的this。当然手动保存再赋值也是可以的。

节流和防抖的最佳实践是什么?如何在项目中使用

我们以React项目为例,说明在项目中具体怎么样使用节流和防抖比较好。

在React中,我们通常使用自定义Hooks来实现节流防抖,如下所示:

ts 复制代码
// useThrottle
import { useCallback, useRef } from 'react';

export const useThrottle = <T extends (...args: unknown[]) => unknown>(
  fn: T,
  delay: number
): ((...args: Parameters<T>) => void) => {
  const lastTime = useRef(0);

  const throttled = useCallback(
    (...args: Parameters<T>) => {
      const now = Date.now();
      if (now - lastTime.current >= delay) {
        lastTime.current = now;
        fn(...args);
      }
    },
    [fn, delay]
  );

  return throttled;
};
ts 复制代码
// useDebounce
import { useCallback, useEffect, useRef } from "react";

export const useDebounce = <T extends (...args: unknown[]) => unknown>(
  fn: T,
  delay: number
): ((...args: Parameters<T>) => void) => {
  const timer = useRef<ReturnType<typeof setTimeout>>(void 0);

  const debounced = useCallback((...args: Parameters<T>)=> {
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      fn(...args);
    }, delay);
  }, [fn, delay]);

  useEffect(() => {
    return () => clearTimeout(timer.current);
  }, [])

  return debounced;
};

注意在React的Hooks中不需要手动绑定this,因为函数式组件环境下不会使用this。

下面是在项目中具体的使用方式。

tsx 复制代码
import React, { useState } from 'react';
import { useDebounce } from './path-to-hooks'; // 假设你把它放在 hooks 文件夹下

export function SearchInput() {
  const [query, setQuery] = useState('');

  // 传入一个函数和防抖时间
  const debouncedSearch = useDebounce((value: string) => {
    console.log('搜索请求发送:', value);
    // 这里可以调用 API 请求接口
  }, 500);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
    debouncedSearch(e.target.value); // 输入时调用防抖函数
  };

  return (
    <input
      type="text"
      value={query}
      onChange={handleChange}
      placeholder="请输入搜索内容"
    />
  );
}
tsx 复制代码
import React, { useEffect } from 'react';
import { useThrottle } from './path-to-hooks';

export function ScrollTracker() {
  const handleScroll = useThrottle(() => {
    console.log('滚动事件触发', window.scrollY);
  }, 1000);

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [handleScroll]);

  return <div style={{ height: '200vh' }}>滚动页面查看控制台</div>;
}

总结

本文从是什么,为什么,怎么做,以及项目使用的最佳实践4个方面总结了节流和防抖的相关知识。其中在正常环境下手写节流防抖的实现函数是面试的常考题目,需要重点掌握。

关于this的绑定要注意,普通场景下需要进行this绑定,但是React的函数式组件Hooks中不需要。

好了,这篇文章就到这里啦,如果对您有所帮助,欢迎点赞,收藏,分享👍👍👍。您的认可是我更新的最大动力。由于笔者水平有限,难免有疏漏不足之处,欢迎各位大佬评论区指正。

往期推荐✨✨✨

我是前端拿破轮,关注我,一起学习前端知识,我们下期见!

相关推荐
秉承初心4 分钟前
Vue3与ES6+的现代化开发实践(AI)
前端·vue.js·es6
Spirited_Away10 分钟前
脚手架开发之多包管理(npm, yarn, pnpm workspaces)
前端·面试
tiantian_cool20 分钟前
Xcode 导入与使用 SVG 文件矢量图适配全流程
前端
小泥巴呀33 分钟前
手写一个简单的vue——响应系统1
前端·vue.js
ze_juejin36 分钟前
插件化和模块化的对比
前端
前端康师傅37 分钟前
网页为什么会白屏?
前端·http·面试
李剑一37 分钟前
Tauri2.0本地实现导入导出,有坑!
前端·vue.js
执行上下文38 分钟前
Element Plus Upload 添加支持拖拽排序~
前端·javascript·element
forever_Mamba38 分钟前
从重复到优雅:前端筛选逻辑的优化之旅
前端·javascript·性能优化
一个小浪吴呀39 分钟前
生死簿应用
前端