Google 搜 debounce 的第一条搜索结果,就介绍了 debounce 在前端中应用最广的一个场景: 截屏2020-10-15 下午1.34.29.png

在准备面试题的过程中大家应该也经常看到手写 debounce 函数的文章,所以关于 debounce 是什么就不做说明了,先写一个简单的实现:

function debounce(fn, wait) {
  let timeout = null;

  return function () {
    // I never like this.
    let context = this;
    let args = arguments;
    if (timeout) {
      clearTimeout(timeout)
    }
    timeout = setTimeout(function () {
      timeout = null;
      fn.apply(context, args)
    }, wait)
  }
}

在我参与过的项目中,类似的实现已经能够满足需求了,如果➡️函数满天飞的话,this 可能甚至都不需要考虑。这也是我一直避免在公司项目中使用 lodash 之类的库的原因——当你可以花5分钟的时间手写一个实现的时候,为什么还要引入一个超过 50KB 的库来拖慢你的页面加载速度呢?

我的需求虽然满足了,但是大家可能还有其它需求。俗话说三人行,必有我师焉;遇到困难,先找找有没有类似的实现。

underscore.js

underscore.js 是绝对的老牌类库了,09年就发布了 0.1.0 版本,函数数量从最初的 40+,现在也增长到了 100+。但其实 debounce 实现一直没有过多的变化。

先来看 v1.2.0,这也是最初加入 debounce 函数的版本

// Internal function used to implement `_.throttle` and `_.debounce`.
var limit = function(func, wait, debounce) {
  var timeout;
  return function() {
    var context = this, args = arguments;
    var throttler = function() {
      timeout = null;
      func.apply(context, args);
    };
    if (debounce) clearTimeout(timeout);
    if (debounce || !timeout) timeout = setTimeout(throttler, wait);
  };
};

_.throttle = function(func, wait) {
  return limit(func, wait, false);
};

_.debounce = function(func, wait) {
  return limit(func, wait, true);
};

这个版本使用了一个 limit 函数,我的理解是:既然加入了 debounce,不如一起加入 throttle,这两个函数只有细微的区别,所以使用了同一个函数,通过不同参数控制。

而现在的版本 v1.11.0 里的 debounce 实现是这样的:

import restArguments from './restArguments.js';
import delay from './delay.js';

export default function debounce(func, wait, immediate) {
  var timeout, result;

  var later = function(context, args) {
    timeout = null;
    if (args) result = func.apply(context, args);
  };

  var debounced = restArguments(function(args) {
    if (timeout) clearTimeout(timeout);
    if (immediate) {
      var callNow = !timeout;
      timeout = setTimeout(later, wait);
      if (callNow) result = func.apply(this, args);
    } else {
      timeout = delay(later, wait, this, args);
    }

    return result;
  });

  debounced.cancel = function() {
    clearTimeout(timeout);
    timeout = null;
  };

  return debounced;
}

这个版本与最初版本有三个区别,分别是:

  1. 加入了 immediate 参数,控制第一次事件是否调用
  2. 支持 cancel
  3. 支持返回值

新增加的前两个特性很好理解,比如页面输入搜索,第一次要尽快返回结果,immediate 参数就很有用;而用户输入完立马离开页面,cancel 可以取消多余的调用,节省资源。倒是支持返回值这一点让我觉得很诧异,因为在常规操作里,debounce 函数几乎不需要关注返回值,但是 underscore.js 实现了这点必定是因为有人有这个需求。

lodash

lodash 现在应该是前端使用最广泛的库之一了,你可以在各种打包工具、框架、CLI 工具中搜索到它的身影。

lodash 在第一个版本 0.1.0 中就加入了 debounce 的支持,同时支持 immediate 参数,cancel 的支持在 3.0.0 版本中加入。

而在 lodash 最新版本 4.17.9 中,debounce 可以说是另一个函数也不为过,在修改了具体实现的同时,支持的特性也非常多,与此相对的是整个函数实现已经超过了 120 行。

截屏2020-10-15 下午3.26.12.png

先贴一下代码:

function debounce(func, wait, options) {
  var lastArgs,
      lastThis,
      maxWait,
      result,
      timerId,
      lastCallTime,
      lastInvokeTime = 0,
      leading = false,
      maxing = false,
      trailing = true;

  if (typeof func != 'function') {
    throw new TypeError(FUNC_ERROR_TEXT);
  }
  wait = toNumber(wait) || 0;
  if (isObject(options)) {
    leading = !!options.leading;
    maxing = 'maxWait' in options;
    maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait;
    trailing = 'trailing' in options ? !!options.trailing : trailing;
  }

  function invokeFunc(time) {
    var args = lastArgs,
        thisArg = lastThis;

    lastArgs = lastThis = undefined;
    lastInvokeTime = time;
    result = func.apply(thisArg, args);
    return result;
  }

  function leadingEdge(time) {
    lastInvokeTime = time;
    timerId = setTimeout(timerExpired, wait);
    return leading ? invokeFunc(time) : result;
  }

  function remainingWait(time) {
    var timeSinceLastCall = time - lastCallTime,
        timeSinceLastInvoke = time - lastInvokeTime,
        timeWaiting = wait - timeSinceLastCall;

    return maxing
      ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting;
  }

  function shouldInvoke(time) {
    var timeSinceLastCall = time - lastCallTime,
        timeSinceLastInvoke = time - lastInvokeTime;

    return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
      (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait));
  }

  function timerExpired() {
    var time = now();
    if (shouldInvoke(time)) {
      return trailingEdge(time);
    }
    timerId = setTimeout(timerExpired, remainingWait(time));
  }

  function trailingEdge(time) {
    timerId = undefined;

    if (trailing && lastArgs) {
      return invokeFunc(time);
    }
    lastArgs = lastThis = undefined;
    return result;
  }

  function cancel() {
    if (timerId !== undefined) {
      clearTimeout(timerId);
    }
    lastInvokeTime = 0;
    lastArgs = lastCallTime = lastThis = timerId = undefined;
  }

  function flush() {
    return timerId === undefined ? result : trailingEdge(now());
  }

  function debounced() {
    var time = now(),
        isInvoking = shouldInvoke(time);

    lastArgs = arguments;
    lastThis = this;
    lastCallTime = time;

    if (isInvoking) {
      if (timerId === undefined) {
        return leadingEdge(lastCallTime);
      }
      if (maxing) {
        timerId = setTimeout(timerExpired, wait);
        return invokeFunc(lastCallTime);
      }
    }
    if (timerId === undefined) {
      timerId = setTimeout(timerExpired, wait);
    }
    return result;
  }
  debounced.cancel = cancel;
  debounced.flush = flush;
  return debounced;
}

看到这么一大堆的时候,我第一反应是:???。简简单单的 debounce 函数为什么变得那么陌生了?遇到这种情况不慌张,先来看注释:

/**
 * Creates a debounced function that delays invoking `func` until after `wait`
 * milliseconds have elapsed since the last time the debounced function was
 * invoked. The debounced function comes with a `cancel` method to cancel
 * delayed `func` invocations and a `flush` method to immediately invoke them.
 * Provide `options` to indicate whether `func` should be invoked on the
 * leading and/or trailing edge of the `wait` timeout. The `func` is invoked
 * with the last arguments provided to the debounced function. Subsequent
 * calls to the debounced function return the result of the last `func`
 * invocation.
 *
 * **Note:** If `leading` and `trailing` options are `true`, `func` is
 * invoked on the trailing edge of the timeout only if the debounced function
 * is invoked more than once during the `wait` timeout.
 *
 * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
 * until to the next tick, similar to `setTimeout` with a timeout of `0`.
 */

从注释里可以看到,lodashdebounce 函数除了支持 cancel 外,还支持:

  1. flush 立即执行在等待的函数;
  2. 支持配置项 options

再来看实现,debounced 函数定义的变量列表中 lastArgslastThisresult 很好猜,肯定与函数调用和函数返回结果相关;leading 参数类似 immediate,控制第一次是否调用函数;而 lastCallTimelastInvokeTimemaxWaittrailing 是什么?

debounce 函数除了变量定义、参数解析和 debounced 函数外,其余都是辅助函数定义,所以我们直接分析 debounced 函数的运行流程,做一个模拟,并用 chromeDevToolsperformance 来记录一下函数执行顺序:

截屏2020-10-15 下午5.25.59.png

上图是一个延时为 1s 的点击事件,其中 1、2、3 为连续点击,之后常理来说应该只有一次函数调用,但是实际上会发现,lodashdebounce 中,点击结束之后有两次 scripting 过程 4 和 5,而 4 恰好在 1 处的点击 1s 之后。把 4 处调用的函数展开之后:

截屏2020-10-15 下午5.38.05.png

这里执行了一次 timerExpired 之后又通过 remainingWait 设置了一个定时器,而这个定时器只用了 800ms 就触发了,再结合 debounced 函数里的 lastCallTime = time;remainingWait 里的判断,其实不难看出: debounced 函数在第一次调用的时候就设置了定时器调用 timeExpired,而在后续的调用中,不断更新 lastCallTimetimeExpired 函数在被触发的时候通过 lastCallTime 判断当前时间是否满足执行条件:如果不满足条件,再通过 remainingWait 获取到下一次调用的时间,同时设置一个新的定时器继续调用 timeExpired,循环往复,直到满足调用条件调用真正的函数

这样 lastCallTime 就可以理解了,再来看 lastInvokeTimemaxingmaxWaittrailing

trailing

trailing 参数只有 trailingEdge 用到了,而 trailingEdge 不难看出,是调用实际的函数。关于这句:

if (trailing && lastArgs) {
  return invokeFunc(time);
}

这里我的理解是:判断满足常规条件后是不是要调用函数。至于为什么要这一个参数我其实不太理解,因为如果这个参数为 false 的话,那么整个 debounce 函数就失去了它原本的意义。实际测试也发现,如果 trailing 设置为 false,那么函数自始至终都不会被触发执行。

lastInvokeTimemaxingmaxWait

从参数定义可以发现,maxing 依赖 maxWait,而 lastInvokeTimeremainingWaitshouldInvoke 判断中使用,而且跟 maxWait 成双成对出现,所以这三个参数很有可能是在一起提供另外一个功能。看一下 shouldInvoke 中的判断:

var timeSinceLastCall = time - lastCallTime,
    timeSinceLastInvoke = time - lastInvokeTime;

return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
  (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait));

这里最终返回的判断条件中,maxing && timeSinceLastInvoke >= maxWait 与其它条件是或的关系,所以它其实可以单独决定 shouldInvoke 的结果,maxing 的定义在最上层:

maxing = false
maxing = 'maxWait' in options;

如果 maxing && timeSinceLastInvoke >= maxWait 为 true,shouldInvoke 就会返回 true,此时函数就会进行一次调用。这么看来,maxWait 应该是表示函数一定时间内,至少应该调用一次。而这不正是 throttle 函数的调用规则么?再继续看 throttle 源码:

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

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

果然,lodash 正是用 leadingmaxWait 参数实现了 throttle

截屏2020-10-15 下午8.38.56.png

总结

其实看下来,我觉得只有 leadingresult 返回结果是比较关键的两个特性,lodashunderscore.js 很早都已经做了实现,但实际的应用中,至少在我所参与的项目中,还没有使用到这两个特性的地方,所以我自己的实现也没有考虑到。lodash 当中的 trailingmaxWait,更像是为 throttle 专门做的处理,而在 underscore.js 中,throttle 有专门的实现。

参考