本文已参与「[掘力星计划](https://juejin.cn/post/7012210233804079141/ "https://juejin.cn/post/7012210233804079141/")」,赢取创作大礼包,挑战创作激励金。

见一.png

> 这是第 117 篇不掺水的原创,想获取更多原创好文,请搜索公众号关注我们吧~ 本文首发于政采云前端博客:防抖节流场景及应用open in new window

防抖节流场景及应用

背景

在日常开发中,我们会经常遇到搜索查询,用户在输入过程中会触发 Input 值的改变而持续触发函数调用。或者在用户在商品搜索页滑动浏览商品时,如果我们监听了窗口滚动来发送埋点请求的话,就会频繁触发接口调用。但有时候我们并不希望用户的持续操作过程中,会频繁触发接口的调用。而为了限制这种短时间内高频触发函数调用情况发生,我们可以借助防抖和节流。

函数防抖和节流,都是通过控制事件触发频率达到优化函数执行效率的效果。我们先通过下面这张图直观的看一下常规、防抖和节流的区别。 image-20210614222321441

图中横坐标是时间轴,可以看到防抖是在事件停止触发一段时间后执行回调,节流是在事件持续触发时以一定的时间间隔去定时执行回调。

防抖与节流场景分析

防抖

防抖,顾名思义,防止抖动。用于将用户的操作行为触发转换为程序行为触发,防止用户操作的结果抖动。一段时间内,事件在我们规定的间隔 n 秒内多次执行,回调只会执行一次。

特点:等待某种操作停止后,加以间隔进行操作

  • 持续触发不执行
  • 不触发的一段时间之后再执行

应用场景:

mousemove 鼠标滑动事件

// 首次不立即执行
function debounce(func, wait) {
    let timer;

    return function () {
        const context = this;
        const args = arguments;

        clearTimeout(timer);
        timer = setTimeout(function(){
            func.apply(context, args)
        }, wait);
    }
}
function getUserAction(e) {
  // container 为示例代码容器
  container.innerHTML = `${e.clientX},${e.clientY}`;
};

container.onmousemove = debounce(getUserAction, 1000);

未添加防抖效果如图,鼠标滑动过程中,x 和 y 坐标在不断变化展示。

添加防抖效果后如图,鼠标停止滑动后 1000ms,x 和 y 轴坐标被更新。

接下来我们来看一个业务中经常会遇到的例子,Select 去服务端动态搜索功能。而其与上面场景的区别在于第一次是否执行

// 立刻执行第一次函数,给用户展示默认的 m 条数据,等到用户手动输入停止触发 n 秒后,再重新执行
function debounce(func, wait, immediate) {
    let timer;
  	let localImmediate = immediate;

    return function () {
        const context = this;
        const args = arguments;

        if (localImmediate) {
          // 标记为,用于标记第一次是否立即执行
          localImmediate = false;
          func.apply(context, args);
        }
      	clearTimeout(timer);
        timer = setTimeout(function(){
            func.apply(context, args)
        }, wait);
    }
}

function fetchData(vaule) {
   // 调用接口请求数据
}
debounce(fetchData, n);
  • 以上是通过防抖实现了用户停止输入 n 秒后,去服务端请求数据,但是有可能用户输入杭州 后触发了第一次搜索。随后又输入了 市体育馆 ,此时触发了第二次搜索。

    此时页面上显示的情况可能有两种:

    • 第一个搜索结果返回的比第二次快,会先显示 杭州 的搜索结果,再显示 杭州市体育馆 的搜索结果。效果如图:
    • 第一个搜索结果返回的比第二次慢,会先显示 杭州市体育馆 的搜索结果,再显示 杭州 的搜索结果。效果如图:

    其实无论是第一种情况还是第二种情况都不太好,我们希望的是会直接显示一次 杭州市体育馆 的搜索结果。

    那么如何去处理呢?我们可以简单的设置一个变量来标记最后一次请求,只有在当前接口请求的标记等于最新的标记时才把返回结果展示给用户。

/**
 * 每次调用 fetchData 方法,更新全局变量 this.lastFetchId 并赋值给内部变量 fetchId。
 * 每个 fetchData 方法内部逻辑,在接口成功返回后判断内部变量 fetchId 是否与全局变量
 * this.lastFetchId 是否相等,若相等才进行赋值,反之不改变数据。
 */

// 全局变量,标记最新的请求 id,每次调用 fetchData 时更新
this.lastFetchId = 0; 
function fetchData(value) {
    const { searchField, params = {}, url, dataKey } = this.props;
  	const [data, setData] = useState([]);
  	const [fetching, setFetching] = useState(false);
  
    this.lastFetchId += 1;
  	// 每个方法调用的内部变量 fetchId 
    const fetchId = this.lastFetchId;

  	setData([]);
  	setFetching(true);
 
    const postValue = typeof value === 'string' ? value : '';
    params[searchField] = postValue;
  
    request(url, {
      method: 'post',
      data: params,
    }).then((res) => {
      const { success, result } = res || {};
      // 如果不是最新请求,那么不进行结果赋值
      if (fetchId !== this.lastFetchId) {
        return;
      }
      if (success && Array.isArray(result)) {
        setData(result);
  			setFetching(false);
      }
    });
  };

以上例子可以看出防抖避免了误把一次操作认为多次操作,限制了事件执行的上限,即停止触发后 n 秒才去执行。同样的场景可能还有登录注册等表单提交操作用户点击过快触发多次请求、富文本编辑器邮件等编辑内容实时保存等。

节流

节流,顾名思义,控制流量。用于用户在与页面交互时控制事件发生的频率,一般场景是单位的时间或其它间隔内定时执行操作。一段时间内,事件在每次到达我们规定的间隔 n 秒时触发一次。

特点:每等待某种间隔后,进行操作

  • 持续触发并不会执行多次
  • 到一定时间 / 其它间隔 ( 如滑动的高度 )再去执行

应用场景 ( 注:因以下例子涉及公司业务内容,不进行实际页面截图展示 ):

  • 埋点场景。商品搜索列表、商品橱窗等,用户滑动时 定时 / 定滑动的高度 发送埋点请求
// 不立即执行,在 n 秒后第一次执行事件,事件停止触发后会再执行一次
// 假设设置的时间间隔为 1s,如果在第 6.8s 停止触发,那么在第 6s 时执行一次,
// 第 7s 时会再继续执行最后一次
function throttle(func, wait) {
    var timer;
    return function() {
        var context = this;
        var args = arguments;
   
        if (!timer) {
            timer = setTimeout(function(){
                timer = null;
                func.apply(context, args)
            }, wait)
        }

    }
}
function sendData(vaule) {
   // 调用接口发送数据
}
throttle(sendData, n);

如图,按照固定间隔发送埋点请求

  • 运维系统查看应用运行日志时,每 n 秒刷新一次
// 立即执行,在 n 秒后第一次执行事件,事件停止触发后会不会再执行
// 特点:假设设置的时间间隔为 1s,如果在第 6.8s 停止触发,那么在第 6s 时执行最后一次,之后不会再执行
function throttle(func, wait) {
    var previous = 0;

    return function() {
        // 隐式转换
        var now = +new Date();
        var context = this;
        var args = arguments;
        if (now - previous > wait) {
            previous = now;
            func.apply(context, args);
        }
    }
}
function fetchLogData(vaule) {
   // 调用接口获取日志数据
}
throttle(fetchLogData, n);

如图,按照固定间隔拉取运行日志

以上例子可以看出节流控制事件触发的频率,同时限制了事件执行的上限和下限,即事件触发过程中每间隔 n 秒去执行。同样的场景可能还有 scroll mousemove 等更加频繁触发的事件、浏览器进度条位置计算、input 动态搜索等。

Lodash 防抖节流源码分析

上文介绍了防抖节流的基础实现和应用场景,便于我们理解和使用。而实际业务场景使用中,我们更多的会选择成熟的第三方库来达到防抖和节流的效果。目前常用的有 LodashUnderscore.js 等,我们来分析下 Lodash 提供的防抖节流方法实现。

防抖:Lodash 实现防抖的核心思想在于不去频繁管理定时器,而是实现了 shouldInvoke 来判断是否应该执行 func 函数,只有在对外提供的 cancel 方法取消延迟时才取消定时器。

下文在函数执行模块详细介绍了 shouldInvoke 内部实现逻辑,在定时器开关和入口函数中调用来决定是否应该执行 func 函数。具体内容我们可以看下面对源代码的部分注解。我们拆解为四大模块来分析:基础定义、定时器开关、函数执行、对外回调。

基本定义 ( 含整体结构 )

以下是 Lodash 实现防抖的整体代码结构,入口函数定义了一些定时器相关和函数执行相关的变量。一共 10 个变量,其中 maxWait、timerId、lastCallTime、lastInvokeTime、leading、maxing、trailing 7 个时间相关的变量是实现定时器开关和函数执行模块的重要支撑。

import isObject from './isObject.js'
import root from './.internal/root.js'

function debounce(func, wait, options) {
  /** ======  基础定义 ====== */
  
  let lastArgs, // 上一次执行 debounced 的 arguments 
    lastThis, // 上一次的 this 
    maxWait, // 最大等待时间,保证大于设置的最大间隔后一定会执行,用于实现节流效果 
    result, // 函数 func 执行后的返回值 
    timerId, // 定时器 ID 
    lastCallTime // 上一次调用 debounce 的时间 

  let lastInvokeTime = 0 // 上一次执行 func 的时间,用于实现节流效果 
  let leading = false // 延迟前即第一次触发 
  let maxing = false // 是否设置了最大等待时间 maxWait,多用于实现节流效果 
  let trailing = true // 延迟后即最后一次触发 

  // Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
  const useRAF = (!wait && wait !== 0 && typeof root.requestAnimationFrame === 'function')

  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  // 隐式转换
  wait = +wait || 0
  /**
   * isObject 判断是否是一个对象
   * function isObject(value) {
   *   const type = typeof value
   *   return value != null && (type == 'object' || type == 'function')
   * }
  */
  if (isObject(options)) {
    leading = !!options.leading
    maxing = 'maxWait' in options
    // maxWait 取 maxWait 和 wait 中最大值,为实现节流效果,需保证 maxWait 的实际值大于 wait 
    maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }


	/** ======  定时器开关 ====== */
  
  // 设置定时器
  function startTimer(pendingFunc, wait) {}

  // 取消定时器
  function cancelTimer(id) {}

  // 计算仍需等待的时间
  function remainingWait(time) {}

  // 定时器回调
  function timerExpired() {}
  
  
  /** ======  函数执行 ====== */
  
  // 延迟前
  function leadingEdge(time) {}

  // 延迟后回调
  function trailingEdge(time) {}
  
  // 执行 func 函数
  function invokeFunc(time) {}
  
  // 判断此时是否应该执行 func 函数
  function shouldInvoke(time) {}
  

  /** ======  对外回调 ====== */
  
  // 取消延迟
  function cancel() {}

  // 立即调用
  function flush() {}

  // 判断是否在定时中
  function pending() {}

  // 入口函数
  function debounced(...args) {}
  debounced.cancel = cancel
  debounced.flush = flush
  debounced.pending = pending
  return debounced
}

export default debounce

定时器开关

	/** ======  定时器开关 ====== */
  
  // 设置定时器
  function startTimer(pendingFunc, wait) {
    if (useRAF) {
      // 没设置 wait 或设置 wait 为 0 时调用 window.requestAnimationFrame()。
      // 要求浏览器在下次重绘之前调用指定的回调函数更新动画
      root.cancelAnimationFrame(timerId)
      return root.requestAnimationFrame(pendingFunc)
    }
    return setTimeout(pendingFunc, wait)
  }

  // 取消定时器
  function cancelTimer(id) {
    if (useRAF) {
      return root.cancelAnimationFrame(id)
    }
    clearTimeout(id)
  }

  // 计算仍需等待的时间
  function remainingWait(time) {
    // 当前时间与上一次调用 debounce 的间隔
    const timeSinceLastCall = time - lastCallTime
    // 当前时间与上一次执行 func 的间隔
    const timeSinceLastInvoke = time - lastInvokeTime
    // 剩余等待时间
    const timeWaiting = wait - timeSinceLastCall

    // 是否设置了最大等待时间 ( 是否设置为节流 )
    // 否:剩余等待时间
    // 是:剩余等待时间 和 当前时间与上一次执行 func 的间隔 中的最小值
      
    return maxing
      ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting
  }

  // 定时器回调
  function timerExpired() {
    const time = Date.now()
    // 应该执行 func 函数时,执行延迟后回调
    if (shouldInvoke(time)) {
      return trailingEdge(time)
    }
    // 计算仍需等待的时间,重置定时器
    timerId = startTimer(timerExpired, remainingWait(time))
  }
  

函数执行

  /** ======  函数执行 ====== */
  
  // 延迟前
  function leadingEdge(time) {
    // 设置上次执行 func 函数的时间
    lastInvokeTime = time
    // 设置定时器
    timerId = startTimer(timerExpired, wait)
    // 如果设置了 leading 则立即执行 func 函数一次
    return leading ? invokeFunc(time) : result
  }

  // 延迟后回调
  function trailingEdge(time) {
    timerId = undefined

    // trailing 延迟后继续触发一次 
    // lastArgs 标记着 debounce 至少执行过一次
    if (trailing && lastArgs) {
      return invokeFunc(time)
    }
    // 重置参数
    lastArgs = lastThis = undefined
    return result
  }
  
  // 执行 func 函数
  function invokeFunc(time) {
    const args = lastArgs
    const thisArg = lastThis

    lastArgs = lastThis = undefined
    lastInvokeTime = time
    result = func.apply(thisArg, args)
    return result
  }
  
  // 判断此时是否应该执行 func 函数
  function shouldInvoke(time) {
    // 当前时间与上一次调用 debounce 的间隔
    const timeSinceLastCall = time - lastCallTime
    // 当前时间与上一次执行 func 的间隔
    const timeSinceLastInvoke = time - lastInvokeTime

    // 首次调用
    // 超出等待时间间隔 wait
    // 系统时间发生了变更
    // 超出最长等待时间 maxWait
    return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
      (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
  }
  

对外回调

 /** ======  对外回调 ====== */

  // 取消延迟
  function cancel() {
    // 取消定时器
    if (timerId !== undefined) {
      cancelTimer(timerId)
    }
    // 重置参数
    lastInvokeTime = 0
    lastArgs = lastCallTime = lastThis = timerId = undefined
  }

  // 立即调用
  function flush() {
    return timerId === undefined ? result : trailingEdge(Date.now())
  }

  // 判断是否在定时中
  function pending() {
    return timerId !== undefined
  }

节流:Lodash 中节流函数的实现简洁,直接调用防抖函数,通过设置入参的 maxWait 达到节流效果。

function throttle(func, wait, options) {
  let leading = true
  let trailing = true

  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  if (isObject(options)) {
    leading = 'leading' in options ? !!options.leading : leading
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }
  return debounce(func, wait, {
    leading,
    trailing,
    'maxWait': wait
  })
}

export default throttle

以上是对 Lodash 防抖和节流实现的简要分析,实际业务场景中一般直接使用其提供的防抖节流方法即可。如果需要更多定制化的功能其可能未实现或者不支持配置的,可以考虑结合对其源码的理解自行实现,以满足实际业务需求。

防抖和节流区别,根据实际业务场景去使用哪一个

可视化比较,在线查看open in new window ( 注:取自司徒正美 - 函数防抖与函数节流open in new window )

防抖可能用于无法预知的用户主动行为,如用户输入内容去服务端动态搜索结果。用户打字的速度等是无法预知的,具有非规律性。

节流可能用于一些非用户主动行为或者可预知的用户主动行为,如用户滑动商品橱窗时发送埋点请求、滑动固定的高度是已知的逻辑,具有规律性。

总结

借用防抖和节流的思想,来控制函数执行的时机,可以节约性能,避免页面卡顿等带来不好的用户体验。防抖和节流的概念相似不易区分,文中上述内容已经从笔者自己对防抖和节流理解的角度进行了介绍。

而初识者可能会在《JavaScript 高级程序设计》或者其它笔者的技术文章中看到不同的理解和介绍,可能会看到 《JavaScript 高级程序设计》中的 throttle 其实是 debounce、动态搜索应该用防抖和实时搜索应该用节流等不同的观点和论据。希望大家能在有自己对防抖和节流的理解后,根据实际的应用场景和需求细节去决定使用防抖和节流,选用更合理更合适的方法。

参考文献

司徒正美 - 函数防抖与函数节流open in new window

lodash防抖节流源码理解open in new window

函数的防抖和节流是个啥???open in new window

JavaScript 专题之跟着 underscore 学防抖open in new window

JavaScript 专题之跟着 underscore 学节流open in new window

Lodash 防抖和节流是如何实现的open in new window

推荐阅读

电商最小存货 - SKU 和 算法实现open in new window

你需要知道的项目管理知识open in new window

如何从 0 到 1 搭建代码全局检索系统open in new window

如何搭建适合自己团队的构建部署平台open in new window

开源作品

  • 政采云前端小报

开源地址 www.zoo.team/openweekly/open in new window (小报官网首页有微信交流群)

  • skuDemo

开源地址 https://github.com/zcy-inc/skuPathFinder-back/open in new window

招贤纳士

政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 50 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。

如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5 年工作时间 3 年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com