前端面试必备题——手写防抖节流

这是手写题合集的一个片段,发出来是因为我觉得很久没发文章了,至于手写题合集啥时候能发出来,我也不知道

四、手写防抖函数

在事件被频繁触发时,只执行一次(或特定时机执行一次)你的函数操作,避免重复执行。这就是防抖

具体的防抖又分为两种一种叫做默认防抖,另一种叫做立即执行防抖,你可能没有听说过,因为这是我编的两个名字,但是这两种防抖是确实存在的,放心使用

1.默认防抖

我们可以拿我们输入框的联想功能来举例,我们在输入框并不是每输入一个字符都会立即触发联想功能的,大概是下面的流程------

1.输入了一个R,输入之后会开始计时3s(这里是假设3s,实际的时间要短),开始数了,3,2....
2.还没有数到1呢,这时输入了一个e,就又要重新开始计时,3,....
3.这次还没有数到2呢,用户又输入了一个a,重新开始计时3,2....
4.就这样,在输入了完整的React后,用户停止了输入,1,2,3!
5.倒计时结束了!执行联想功能!

大家也可以想一想,如果每输入一个字符都要触发输入框联想功能的话,以你的打字速度,1s就要触发近10次联想功能 ,这样太消耗性能 了,所以这种情况我们就要进行默认防抖

2.立即执行防抖

现在想象你在电商网站搜索商品 ,你想买一个"最新款的华为手机" ,你一边思考一边在搜索框里输入关键字,先敲了"华为"两个字,然后停顿了一下想了一下,又敲了"手机"两个字,感觉不太精确,又敲了"最新款"三个字 。你如果这个搜索框输入进行了默认防抖 ,意味着你需要完全停止输入,等待300ms(这里的300ms也是假设)后,才能看到搜索结果。这对于用户体验来说,等待时间较长,不够实时。

那么我们就要进行另外一种防抖的方法,立即执行防抖,以提供更好的用户体验。

执行逻辑如下

1.用户开始思考和输入,想买"华为手机"
2.用户开始在搜索框里输入:
3.第一次输入"华"字,会立即触发一次搜索请求,展示与"华"相关的模糊匹配结果。
4.接下来用户马上输入"为"字。此时,由于"华"字触发了立即搜索后,系统进入了一个预设的冷却期(例如300ms),在这个冷却期内,所有的输入(包括"为")都不会立即触发新的搜索请求。但是,这次"为"字的输入会重置(延长)这个冷却期计时器。
5.同理,用户在冷却期内接着输入"手"、"机"、"最新"、"款"等字时,每一次输入都会不断重置并延长冷却计时器。
6.只有当用户完成输入,"最新款的华为手机"后,并且在300ms冷却期内不再有任何新的输入时,也就是计时器自然走完,才会再次在冷却结束后立即触发一次包含最终完整关键词的搜索请求,并显示最精准的搜索结果。

当然,你也可以看作你在玩手机游戏打怪时的连击点释放:你每次攻击都会积累连击点,当你积攒到足够的连击点后(相当于停顿下来),你的大招就会立即释放。如果你在攒点过程中不断攻击,那么积累大招的时机就会不断延后,直到你停止攻击并满足条件后,大招才会瞬发。

注;这里有默认防抖的"延长冷却机制"(就是300,200...300..300,200,100!这种机制,每一次操作都会延长等待)

知道了防抖的逻辑之后,我们就可以开始手写代码了,当然,还有一些前置的知识

setTimeout(() => {fun}, time)

这里并不全面的讲setTimeout,只讲setTimeout知识中和本题有关的本部分

setTimeout 是 JavaScript 中用于在指定延迟时间后执行一段代码的内置函数

  1. 基本功能

    接收两个必填参数一个回调函数(延迟后要执行的代码)和一个延迟时间(毫秒),作用是让回调函数在延迟时间过后被执行。

  2. 执行机制

    调用 setTimeout 时,回调函数不会立即执行,而是被放入宏任务队列。JavaScript 引擎会先执行完当前所有同步代码,再检查宏任务队列,当延迟时间已到且没有更早的任务时,才执行该回调。因此,实际执行时间可能比设定的延迟时间长(受同步代码执行时长或其他任务影响)。

  3. 返回值与取消
    返回一个数字类型的定时器 ID,可用于通过 clearTimeout 函数取消该定时器 (在回调执行前调用 clearTimeout(ID),会阻止回调执行)。

  4. this 指向
    回调函数中的 this 指向全局对象(浏览器中为 window,Node.js 中为 global),除非使用箭头函数(继承外部作用域的 this)或手动绑定。

  5. 延迟时间特性
    设定的延迟时间是 "最小延迟",而非精确时间。 浏览器中最小延迟通常为 4 毫秒(嵌套层级过深时可能更大),若传入 0,则会按最小延迟处理。

手写实现防抖

javascript 复制代码
/**
 * 防抖函数
 * @param {Function} func - 需要防抖的函数
 * @param {number} wait - 延迟时间(毫秒)
 * @param {boolean} [immediate=false] - 是否立即执行(true:触发时立即执行,false:延迟后执行)
 * @returns {Function} 防抖处理后的函数
 */
function debounce(func, wait, immediate = false) {
  let timer = null; // 定时器标识

  // 返回包装后的函数
  const debounced = function(...args) {
    const context = this; // 保存原函数的this指向

    // 如果已有定时器,清除它(重新计时)
    if (timer) clearTimeout(timer);

    // 立即执行逻辑
    if (immediate) {
      // 首次触发或定时器已执行完,才执行
      const callNow = !timer;
      // 设定定时器,wait时间后清空timer(允许下次立即执行)
      timer = setTimeout(() => {
        timer = null;
      }, wait);
      // 立即执行原函数
      if (callNow) func.apply(context, args);
    } else {
      // 延迟执行逻辑:重新设定定时器,wait时间后执行
      timer = setTimeout(() => {
        func.apply(context, args); // 绑定this和参数
        timer = null; // 执行后清空定时器
      }, wait);
    }
  };

  // 提供取消防抖的方法
  debounced.cancel = function() {
    clearTimeout(timer);
    timer = null;
  };

  return debounced;
}

// 测试示例
function handleInput(value) {
  console.log('处理输入:', value);
}

// 延迟执行版(输入结束后1000ms执行)
const debouncedInput = debounce(handleInput, 1000);
// 立即执行版(输入时立即执行,后续1000ms内输入不重复执行)
const immediateInput = debounce(handleInput, 1000, true);

// 模拟频繁触发
debouncedInput('a');
debouncedInput('ab');
debouncedInput('abc'); // 1000ms后仅执行此调用

下面进行一些代码中难理解的地方的讲解

如何控制立即执行防抖还是默认防抖?

代码中是通过immediate来控制采取哪种防抖的,默认immediate = false为默认防抖,反之是立即执行防抖。

ini 复制代码
 if (immediate) {
      // 首次触发或定时器已执行完,才执行
      const callNow = !timer;
      // 设定定时器,wait时间后清空timer(允许下次立即执行)
      timer = setTimeout(() => {
        timer = null;
      }, wait);
      // 立即执行原函数
      if (callNow) func.apply(context, args);
    } else {
      // 延迟执行逻辑:重新设定定时器,wait时间后执行
      timer = setTimeout(() => {
        func.apply(context, args); // 绑定this和参数
        timer = null; // 执行后清空定时器
      }, wait);
    }

我们可以看你到,立即执行防抖的核心逻辑如下

ini 复制代码
 const callNow = !timer;
      // 设定定时器,wait时间后清空timer(允许下次立即执行)
      timer = setTimeout(() => {
        timer = null;
      }, wait);
      // 立即执行原函数
      if (callNow) func.apply(context, args);

默认防抖的核心逻辑如下

ini 复制代码
 timer = setTimeout(() => {
        func.apply(context, args); // 绑定this和参数
        timer = null; // 执行后清空定时器
      }, wait);

立即执行防抖逻辑

ini 复制代码
 const callNow = !timer;
      // 设定定时器,wait时间后清空timer(允许下次立即执行)
      timer = setTimeout(() => {
        timer = null;
      }, wait);
      // 立即执行原函数
      if (callNow) func.apply(context, args);

默认的timer值为null,!null的值即为true,如果之前没有执行则timer为true,则执行下面的逻辑

  • 1.timer值赋为setTimeout的ID(不再为null)
  • 2.执行func.apply(context, args) 我们再次执行函数的条件有两个
  • 1.再次点击按钮,触发debounced
  • 2.timer的值为null(callNow值为true)

点击的条件我们不用管,因为这时我们肯定点击的很快,那么第二个条件如何满足呢?

就是要执行完下面的定时器

ini 复制代码
timer = setTimeout(() => {
        timer = null;
      }, wait);

但是如果wait时间还没有过,你就又点了一次按钮,就会重新产生一个setTimeout,这个setTimeout会重新开始计时wait,之前的哪个计时器呢?

我们可以看到手写代码的第16行

scss 复制代码
if (timer) clearTimeout(timer);

没错,之前的定时器已经被我们清除了。

所以只有当我们不再继续点击按钮(不再继续产生新的定时器(不再删除之前的计时器)),过去了wait时间才会再次执行函数

默认防抖

嗯,默认防抖的逻辑我认为知识立即执行防抖的一部分,也是十分的简单,让我们把注意力转移到下一个问题,下一个问题可谓是防抖函数的点睛之笔------闭包!

为何上次的防抖函数的timer值会保存下来?

相信大家都意识到了,我们的setTimeOut每次都会换一个新的,但是timer貌似还是会'继承'上次执行防抖函数的值,这是为何?

这就涉及到了一个知识点------闭包,观察一下我们的timer是在哪里定义的?是在debounce中,而我们返回的防抖函数debounced引用了外部的变量(也就是在debounce中的),所以这个变量不会销毁,而且会被一直'继承下去',而我们的setTimeOut就没有这么好运了。

为何要单独设置this的指向

在 JavaScript 中,函数内部的 this 值取决于函数被调用的方式,而非定义的位置。

防抖函数通过 setTimeout 延迟执行,而定时器中的函数默认在全局上下文中调用,但是我们要将this指向目标函数的上下文环境,所以要显示绑定this环境

五、手写节流函数

现在想象你在抢国庆节回家的火车票马上就要到抢票的时间了10,9,8,7.....疯狂的去点击抢票的按钮 ,你如果这个按钮进行了默认防抖(或者立即执行防抖) ,会在你第一次点击抢票按钮之后的3s内你不再点击该按钮才给你执行抢票的逻辑吗(这是因为连续的点击会重置冷却时间)这显然是不合理的

那么我们就要进行另外一种方法,节流

执行逻辑如下

1.马上开始抢票了,5,4,3,2,1!
2.可以抢票了,狂点抢票按钮
3.第一次点击抢票按钮,会立即执行你的抢票逻辑,但是你肯定不会就点一次,你肯定会狂点第二次,第三次第四次...
4.但是是不会响应你的狂点的,第一次执行之后会进入一个3s(这里的3s也是假设)的冷却期,该期间的一切点击都没有用
5.3s冷却器过了你再次点击会再次立即执行抢票逻辑,之后就又有一个3s的冷却....

当然,你也可以看作你打游火影时的技能冷却,超哥的小神罗冷却是5s,你小神罗顶掉对面的散后对面再交熊猫你就没法用小神罗顶了,因为在冷却,但是5s过后就又可以顶其他的东西了,所以小神罗不要随便交

注:这里并没有防抖的"延长冷却机制"(就是3,2...3..3,2,1!这种机制)

节流的模式主要有两种,一种是时间戳节流,另一种是定时器节流,接下来分别介绍一下两种节流模式

时间戳节流

我称之为立即执行节流,当然,可以忽略这个称呼,这是我编的

该节流的特点是,首次触发节流函数会立即执行目标函数,然后等待delay时间,在delay期间触发节流函数,并不会触发目标函数,等到delay时间过去之后才可以再次触发目标函数

多说无益,上代码

ini 复制代码
function throttle(fn, delay) {
  let lastTime = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= delay) {
      fn.apply(this, args);
      lastTime = now;
    }
  };
}

这个代码很简单,但我这里做些许的解释

1.基准时间为lastTime,这个可以是第一次触发防抖函数的时间,也可以是上一次执行过目标函数的时间

2.每次触发节流函数都会更新当前的时间const now = Date.now();,当第一次触发时if中的条件是(当前时间戳)-0>=0true,所以会立即执行

3.之后的delay时间内不会触发目标函数,delay时间过去再次触发防抖函数,就会将这时的时间now设置为基准时间lastTime

......

定时器节流

我称之为延迟节流,这个也可以忽略...

该节流的特点是,首次触发节流函数并不会立即执行目标函数,然后等待delay时间,在delay期间触发节流函数,并不会触发目标函数,等到delay时间过去之后才可以首次执行目标函数

下面是代码实现

ini 复制代码
function throttle(fn, delay) {
  let timer = null;
  return function(...args) {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null;
      }, delay);
    }
  };
}

1.首次触发节流函数timer=null,所以!timer为true,那么会设置一个定时器,ID'存到timer之中,接下来的delay时间中其不再为null,!timer值为false,不会在设置新的定时器

2.delay时间过后定时器中的回调函数会被触发(如何同步逻辑都执行完了,这里是另外的知识点,这里的时间我们忽略不计),这是首次触发目标函数

3.首次触发回调函数之后,会再次将timer设置为null,则会再次设置定时器

但是,搞懂了上述的节流知识,对于面试来说好不够,我们要搞清楚面试官的图谋(图谋这个词好像不是很合适,但是我不想改)

我们可以尝试这样问一下,体现出自己的专业性:"节流的核心是控制函数在固定时间内只执行一次 ,对吧?您希望这个节流函数支持哪些特性呢?比如是否需要首次触发立即执行(leading)最后一次触发是否延迟执行 (trailing),或者是否需要考虑上下文绑定(this 指向)和参数传递?"

非常的专业

那么我们在接下来的手写代码中就要体现这些考虑,不然就翻车了

完整手写代码

ini 复制代码
function throttle(fn, delay, { leading = true, trailing = true } = {}) {
  let lastTime = 0; // 记录上次执行时间
  let timer = null; // 定时器标识

  // 包装后的函数
  const throttled = function(...args) {
    const now = Date.now(); // 当前时间戳

    // 若首次触发且不需要立即执行,初始化lastTime
    if (!lastTime && !leading) lastTime = now;

    // 计算剩余时间(距离下次可执行的时间)
    const remaining = delay - (now - lastTime);

    // 情况1:超过间隔时间,立即执行
    if (remaining <= 0) {
      // 清除可能存在的定时器(避免trailing重复执行)
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(this, args); // 绑定this和参数
      lastTime = now; // 更新执行时间
    } 
    // 情况2:未超过间隔,且需要trailing执行,设置定时器
    else if (trailing && !timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        lastTime = leading ? Date.now() : 0; // 重置lastTime(配合leading)
        timer = null; // 清空定时器
      }, remaining);
    }
  };

  // 提供取消方法(可选,体现完整性)
  throttled.cancel = function() {
    if (timer) clearTimeout(timer);
    timer = null;
    lastTime = 0;
  };

  return throttled;
}

这里讲一些细节

如何控制首次触发立即执行

如果不是首次触发会执行下面的语句,if (!lastTime && !leading) lastTime = now;,会将基准时间设置为当前时间按戳,那么 const remaining = delay - (now - lastTime);就会等于delay-((当前时间戳)-(当前时间戳)),包大于0不会立即执行

如果是首次执行,那么就是remaining=delay-((当前时间戳)-(0)),包小于零

注:当前时间戳是一个很大的数字,类似1620000000000,不等于0

如何控制最后一次延迟执行

首先我们要搞懂什么叫'最后一次触发',首先必须时连续点击的最后一次点击,而且该连续点击的间隔要小于delay,知道这个概念可以方便我们更好的理解最后一次延迟执行的概念

下面我们来分析,我们连续点击了3次该节流按钮(delay=300)

1.第一次点击按钮(t=1000),立即执行了,很好

2.第二次点击(t=1100),进入了else if (trailing && !timer)逻辑(因为此时timer为空),设置了一个定时器,里面的回调函数会在remaining后执行(remaining=300-(1100-1000)=200)

3.第三次点击(t=1200),不会进入else if (trailing && !timer)逻辑(因为此时timer不为空),此时的remaining=100>0也不会进入if (remaining <= 0)逻辑,所以这次点击什么都不会执行,这时还剩100,我们设置的定时器的回调函数就会执行了

4.t=1300时,我们的回调函数执行了,而且因为期间并没有触发防抖函数,所以就算此时的remaining<0也不会触发if (remaining <= 0)的逻辑

纵观上面的执行过程,哪一次点击是最后一次点击?

第三次

延迟了吗?延迟了多长时间?

延迟了,延迟了100(1300-1200=100)

现在我们再来深究一下'最后一次延迟执行'的含义。

我将其理解为,最后一次操作要执行,不要忽略。

如果没有else if (trailing && !timer)逻辑设置的定时器,那么就代表我们的最后一次操作被忽略了

那么我们如此的大费周章整这些限制是为什么呢?有什么应用场景?

这是我们给面试官秀肌肉的最后机会了

1、leading: true(首次触发立即执行)的应用场景

leading: true 表示连续触发事件时,第一次触发会立即执行函数 ,之后按节流间隔(如 300ms)限制执行频率。适用于需要 "快速响应初始动作" 的场景,避免用户操作后因节流延迟而感觉 "无反馈"。

  1. 页面滚动加载(无限滚动)
    当用户滚动到页面底部时,需要加载更多内容。首次触发滚动到底部的事件时,应立即执行加载逻辑(leading: true),避免延迟导致用户等待感。后续快速滚动时,按间隔限制加载频率(防止多次请求),但首次必须快速响应。
  2. 拖拽元素实时定位
    拖拽元素时,第一次拖动的瞬间需要立即更新元素位置(leading: true),让用户感受到 "拖拽即动" 的流畅性。后续拖动过程中按间隔更新位置(减少计算压力),但初始响应必须及时。
  3. 按钮连续点击(防重复提交)
    点击按钮提交表单时,首次点击应立即执行提交逻辑(leading: true),同时用节流限制后续短时间内的点击(防止重复提交)。用户能立即看到反馈(如 "提交中"),避免疑惑。

2、trailing: true(最后一次触发延迟执行)的应用场景

trailing: true 表示连续触发结束后,最后一次触发会在节流间隔结束后执行 ,确保最终状态被处理。适用于需要 "捕捉最终结果" 的场景,避免因节流限制丢失最后一次操作的影响。

  1. 搜索框实时联想
    用户快速输入关键词(如 "苹果手机"),过程中会连续触发输入事件。节流会限制联想请求的频率(如每 300ms 一次),但最后一次输入("手机")可能在间隔内,此时 trailing: true 会在 300ms 后执行联想请求,确保用最终关键词 "苹果手机" 发起搜索,而不是中间的 "苹果"。
  2. 窗口大小调整(resize 事件)
    用户拖动窗口边缘调整大小,会连续触发 resize 事件。节流限制每 500ms 处理一次,但最后一次调整的窗口尺寸才是用户想要的最终大小。trailing: true 会在停止拖动后 500ms 执行处理逻辑(如重新布局页面),确保用最终尺寸计算。
  3. 滑动进度条(视频进度拖拽)
    用户快速拖动视频进度条,过程中会连续触发进度更新事件。节流限制每 200ms 更新一次进度,但最后一次拖动的位置是用户想要的最终进度。trailing: true 会在停止拖动后 200ms 执行进度更新,确保跳转到用户最终选择的时间点。
  4. 鼠标跟随动画(如拖拽时的提示框)
    拖拽元素时,提示框需要跟随鼠标位置。连续拖动时按间隔更新位置,但最后一次拖动停止后,trailing: true 会确保提示框最终停在鼠标停止的位置,而不是中途的某个位置。

3、leadingtrailing 的配合选择

  • 同时开启(leading: true + trailing: true :适用于既需要初始响应,又需要最终状态的场景(如拖拽 + 定位)。但需注意:在节流间隔刚好等于触发间隔时,可能导致首尾各执行一次(需代码处理避免重复)。
  • 只开 leading:适用于 "初始响应优先,中间过程可简化,无需最终处理" 的场景(如按钮点击防重复)。
  • 只开 trailing:适用于 "过程不重要,最终结果才关键" 的场景(如搜索联想、窗口调整)。

那么让我们总结一下,leading和trailing的底层影响是什么

  • leading: true 解决 "初始响应不及时" 的问题,让用户第一时间感受到操作反馈;
  • trailing: true 解决 "最终状态丢失" 的问题,确保最后一次操作的结果被正确处理。

结语

OK,防抖节流掌握这么多,应该是足够了。

相关推荐
2301_781668614 小时前
前端基础 JS Vue3 Ajax
前端
上单带刀不带妹5 小时前
前端安全问题怎么解决
前端·安全
Fly-ping5 小时前
【前端】JavaScript 的事件循环 (Event Loop)
开发语言·前端·javascript
SunTecTec5 小时前
IDEA 类上方注释 签名
服务器·前端·intellij-idea
在逃的吗喽6 小时前
黑马头条项目详解
前端·javascript·ajax
袁煦丞6 小时前
有Nextcloud家庭共享不求人:cpolar内网穿透实验室第471个成功挑战
前端·程序员·远程工作
小磊哥er6 小时前
【前端工程化】前端项目开发过程中如何做好通知管理?
前端
拾光拾趣录6 小时前
一次“秒开”变成“转菊花”的线上事故
前端
你我约定有三7 小时前
前端笔记:同源策略、跨域问题
前端·笔记
JHCan3337 小时前
一个没有手动加分号引发的bug
前端·javascript·bug