我所了解的 debounce

这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

文章大纲

  • 基础使用
    • debounce 使用演示
    • lodash.debounce
  • 常见问题:传入不同参数
    • 解决方法:memoize
    • 解决方法:with cache
  • 常见问题:React 组件中正确使用 debounce
  • 实现 lodash.debounce
    • 基础实现
    • 性能优化
    • 实现 options
  • 简单说一说 throttle

基础使用

电梯设备检测到有人进入电梯时,会重新设定一个固定时间再关闭电梯门。

debounce 和电梯等待有着类似的逻辑。

debounce 返回一个 debounced 函数,该函数被调用时,会重新延迟指定时间再执行 func

debounce 能有效避免短时间内的连续触发,在触发告一段落后再执行逻辑,常常搭配 element resize、input keydown 等事件监听使用。

debounce 使用演示

举个简单的例子来演示:元素更改大小后获取尺寸,再进行后续操作

<div class="box"></div>
复制代码
.box {
  width: 100px;
  height: 100px;
  background-color: green;
  resize: both;
  overflow: auto;
}

.box::after {
  content: attr(data-size);
}
复制代码
function getElementSizeInfo(target) {
  const { width, height } = target.getBoundingClientRect();
  const { offsetWidth, offsetHeight } = target;

  return {
    width,
    height,
    offsetWidth,
    offsetHeight,
  };
}

const resizeObserver = new ResizeObserver((entries) => {
  // resizeObserverCallback
  for (const entry of entries) {
    const sizeInfo = getElementSizeInfo(entry.target);
    entry.target.dataset.size = sizeInfo.offsetWidth;
    // ...
  }
});

resizeObserver.observe(document.querySelector(".box"));
复制代码

例子中使用 ResizeObserver 来监听元素尺寸调整,在拖拽调整大小时会多次触发 resizeObserverCallback 逻辑。

这里的例子很简单,不会导致卡帧。但是如果 DOM 树较重,频繁的 getBoundingClientRectoffsetWidthoffsetHeight 将导致短时间内多次重绘,性能负担增大;如果获取尺寸后跟随的 JS 计算逻辑非常耗时,Script 将会长时间占用主线程,短时间内多次调用将导致拖拽操作无法连续。

要优化上述体验,容易想到把耗时逻辑扔到连续操作结束后再进行。

那么怎么确定连续操作已经结束呢?似乎并没有什么 Web API 可用,那么只能使用 debounce 来模拟了。

预估连续操作的时间间隔为 n 秒,一次操作后等待 n 秒,观察到有新操作进入则说明是连续操作,继续观察 n 秒等待下一次操作。

debounce 简单改写代码

function getElementSizeInfo(target) {
  const { width, height } = target.getBoundingClientRect();
  const { offsetWidth, offsetHeight } = target;

  return {
    width,
    height,
    offsetWidth,
    offsetHeight,
  };
}

function resizeCallback(target) {
  const sizeInfo = getElementSizeInfo(target);
  target.dataset.size = sizeInfo.offsetWidth;
  // ...
}

// 设置 800 ms 的观察时间
const debouncedResizeCallback = _.debounce(resizeCallback, 800);

const resizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    debouncedResizeCallback(entry.target);
  }
});

resizeObserver.observe(document.querySelector(".box"));
复制代码

codepen.io/curly210102…

lodash.debounce

在日常开发中,我们常常使用 JavaScript 实用工具库 lodash 提供的 debounce 方法。来看一看 lodash.debounce 配备的功能。

_.debounce(
  func,
  [(wait = 0)],
  [
    (options = {
      leading: false,
      trailing: true,
      maxWait: 0,
    }),
  ]
);
复制代码

除了基础参数之外,lodash.debounce 还提供了一个 options 配置

  • leading: 是否在连续操作开头调用
  • trailing: 是否在连续操作结尾调用
  • maxWait: 一次连续操作的最长持续时间

默认情况自然是结尾调用( leading: false; trailing: true;

f5699e5a6de86ba26e3560ccf00697c9cea7584243563eaa45a635f3427fe132.png

假如需要在操作开始时马上给出反馈,设置 leading: true 即可

leadingtrailing 同时为 true 时,只有在等待期间多次操作方法才会在结尾调用

b2fa005deeba40f1c2b51c2f4d0f67271b45ff03090d78bf7eaa3d36e0115ea4.png

除了配置之外,lodash.debounce 还为 debounced 函数提供了两个手动操控方法:

  • debounced.cancel(): 取消本次连续操作的尾部调用
  • debounced.flush(): 立即执行本次连续操作

常见问题:传入不同参数

大多数使用场景中, debounced 函数应对的是单一对象的重复操作。

那么 debounce 能够应对多个对象么?

回到 ResizeObserver 的例子,上面我们只监听了单个元素,现在加入多个元素。

<div class="container">
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
</div>
复制代码
.container {
  display: flex;
  overflow: auto;
  resize: both;
  width: 800px;
  border: 1px solid #333;
}
.box {
  width: 100px;
  height: 100px;
  background-color: green;
  margin: 10px;
  flex: 1;
}

.box::after {
  content: attr(data-size);
}
复制代码
// 省略重复代码...

const debouncedResizeCallback = _.debounce(resizeCallback, 800);

const resizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    debouncedResizeCallback(entry.target);
  }
});

document.querySelectorAll(".box").forEach((el) => {
  resizeObserver.observe(el);
});
复制代码

codepen.io/curly210102…

为了清晰说明问题,这里用一个 flexbox 容器包裹元素,来实现多个元素同时 resize

调整容器大小时,明明所有元素都有改变但只有最后一个元素显示更改后的宽度。

解释起来并不复杂,多个对象对应唯一一个debouncedResizeCallbackdebouncedResizeCallback 只会在最后调用一次,最后调用它的恰好是最后一个对象。

解决方法:memoize

解决起来也简单,给每个元素都提供一个 debouncedResizeCallback 就好了。

const memories = new WeakMap();
const debouncedResizeCallback = (obj) => {
  if (!memories.has(obj)) {
    memories.set(obj, _.debounce(resizeCallback, 800));
  }
  memories.get(obj)(obj);
};
复制代码

使用 lodash 实现的简化写法

const debouncedResizeCallback = _.wrap(
  _.memoize(() => _.debounce(resizeCallback)),
  (getMemoizedFunc, obj) => getMemoizedFunc(obj)(obj)
);
复制代码

_.wrap 创建一个包装函数,第二个参数为包装函数主体。将第一个参数作为包装函数的第一个参数带入,也就是把 _.memoize(...) 部分作为 getMemoizedFunc 带入函数,相当于

const debouncedResizeCallback = (obj) =>
  _.memoize(() => _.debounce(resizeCallback))(obj)(obj);
复制代码

_.memoize 则是返回一个缓存创建/读取函数,_.memoize(() => _.debounce(resizeCallback))(obj) 读取返回 obj 对应的 debounced 函数。

codepen.io/curly210102…

StackOverflow 上的这个例子 是 memoize 解决方法的另一个应用场景。

// 避免频繁更新同一 id 的数据
function save(obj) {
  console.log("saving", obj.name);
  // syncToServer(obj);
}

const saveDebounced = _.wrap(
  _.memoize(() => _.debounce(save), _.property("id")),
  (getMemoizedFunc, obj) => getMemoizedFunc(obj)(obj)
);

saveDebounced({ id: 1, name: "Jim" });
saveDebounced({ id: 2, name: "Jane" });
saveDebounced({ id: 1, name: "James" });
// → saving James
// → saving Jane
复制代码

总结来说,memoize 方法本质上是为多个对象各自分配 debounced 函数。

6c29d8442bd9ff1be7094ebaf5b5eae448ca9f9cf328e257f9b66d58060bbae0.png

解决方法:with cache

memoize 方法产生各自独立的 debounced 函数,这也会有一定的弊端。假如 debounce 包裹的操作比较重,调用一次涉及到大量重计算和重渲染。如果能将多个对象的调用收集到一起,统一为一次计算和渲染,可以帮助优化性能。

所谓收集也就是用一片 cache 空间来记录调用对象。

function getDebounceWithCache(callback, ...debounceParams) {
  const cache = new Set();
  const debounced = _.debounce(function () {
    callback(cache);
    cache.clear();
  }, ...debounceParams);

  return (items) => {
    items.forEach((item) => cache.add(item));
    debounced(cache);
  };
}

const debouncedResizeCallback = getDebounceWithCache(resizeCallback, 800);

function resizeCallback(targets) {
  targets.forEach((target) => {
    const sizeInfo = getElementSizeInfo(target);
    target.dataset.size = sizeInfo.offsetWidth;
  });
  // ...
}

const resizeObserver = new ResizeObserver((entries) => {
  debouncedResizeCallback(entries.map((entry) => entry.target));
});

document.querySelectorAll(".box").forEach((el) => {
  resizeObserver.observe(el);
});
复制代码

codepen.io/curly210102…

总结来说,with Cache 的方法本质上是将调用对象收集为一个整体,再将整体应用到最终操作中。

08760cdc653edc6b3241a23ceb1f5240c2d83ba0ae0489a4020c6986aa4a1e3d.png

常见问题:React 组件中正确使用 debounce

在 React 组件中定义 debounced 函数需要注意唯一性

类组件没什么坑,作为实例属性定义即可

import debounce from "lodash.debounce";
import React from "react";

export default class Component extends React.Component {
  state = {
    value: "",
  };
  constructor(props) {
    super(props);
    this.debouncedOnUpdate = debounce(this.onUpdate, 800);
  }

  componentWillUnmount() {
    this.debouncedOnUpdate.cancel();
  }

  onUpdate = (e) => {
    const target = e.target;
    const value = target.value;
    this.setState({
      value,
    });
  };

  render() {
    return (
      <div className="App">
        <input onKeyDown={this.debouncedOnUpdate} />
        <p>{this.state.value}</p>
      </div>
    );
  }
}
复制代码

函数组件需要避免 re-render 时重声明

import debounce from "lodash.debounce";
import React from "react";

export default function Component() {
  const [value, setValue] = React.useState("");
  const debouncedOnUpdateRef = React.useRef(null);

  React.useEffect(() => {
    const onUpdate = (e) => {
      const target = e.target;
      const value = target.value;
      setValue(value);
    };
    debouncedOnUpdateRef.current = debounce(onUpdate, 800);

    return () => {
      debouncedOnUpdateRef.current.cancel();
    };
  }, []);

  return (
    <div className="App">
      <input onKeyDown={debouncedOnUpdateRef.current} />
      <p>{value}</p>
    </div>
  );
}
复制代码

实现一个 debounce

这里以 lodash 源码 为参照逐步实现 debounce

基础实现

先准备一个直观的测试场景

c13b44d0856443ed1efaf57344d70a2a1b88a5f42246b3667908071e26cfe8fb.png

codepen.io/curly210102…

使用 clearTimeout + setTimeout 实现基础的 debounce,调用 debounce → 重新生成定时器 → 定时结束执行函数。

function debounce(
  func,
  wait = 0,
  options = {
    leading: false,
    trailing: true,
    maxWait: 0,
  }
) {
  let timer = null;
  let result = null;

  function debounced(...args) {
    if (timer) {
      clearTimeout(timer);
    }
    timer = setTimeout(() => {
      result = func.apply(this, args);
    }, wait);

    return result;
  }

  debounced.cancel = () => {};
  debounced.flush = () => {};

  return debounced;
}
复制代码

性能优化

clearTimeout + setTimeout 确实能快速实现基础功能,但存在优化空间。

例如现在有一个数组,添加元素时通过 debouncedSort 做排序。连续添加 100 个元素,debounced 函数需要在短时间内卸载重装定时器 100 次。这样的重复工作并没有多大意义,实际上一个定时器就可以搞定。

先设置一个定时器,它走它的不受触发操作干扰。到点后计算预计持续时间实际持续时间的差距,根据差值设置下一个定时器。

function debounce(
  func,
  wait = 0,
  options = {
    leading: false,
    trailing: true,
    maxWait: 0,
  }
) {
  let timer = null;
  let result = null;
  let lastCallTime = 0;
  let lastThis = null;
  let lastArgs = null;

  function later() {
    const last = Date.now() - lastCallTime;

    if (last >= wait) {
      result = func.apply(lastThis, lastArgs);
      timer = null;
    } else {
      timer = setTimeout(later, wait - last);
    }
  }

  function debounced(...args) {
    lastCallTime = Date.now();
    if (!timer) {
      timer = setTimeout(later, wait);
    }

    return result;
  }

  debounced.cancel = () => {};
  debounced.flush = () => {};

  return debounced;
}
复制代码

codepen.io/curly210102…

f342e954e5fdba358ccb025836b62c377d670beb958d881a43ade0f5811b187e.png

优化思路及性能图片引用自 modernjavascript.blogspot.com/2013/08/bui…

实现 options

实现 leadingtrailing,在代码中找到到 “起始” 和 “结束” 点,加入 leadingtrailing 条件。

需要注意 leadingtrailing 同时为 true 时,只有在等待期间多次触发操作才会在结尾调用。通过记录最近的参数 lastArgs 来实现。

function debounce(
  func,
  wait = 0,
  options = {
    leading: false,
    trailing: true,
    maxWait: 0,
  }
) {
  const leading = options.leading;
  const trailing = options.trailing;
  const hasMaxWait = "maxWait" in options;
  const maxWait = Math.max(options.maxWait, wait);
  let timer = null;
  let result = null;
  let lastCallTime = 0;
  let lastThis = null;
  let lastArgs = null;

  function later() {
    const last = Date.now() - lastCallTime;

    if (last >= wait) {
      // 结束
      timer = null;
      // 通过 lastArgs 确定是否有多次触发,应对 leading: true, trailing: true 的情况
      // this 有可能为 null/undefined 不能作为判断对象
      if (trailing && lastArgs) {
        invokeFunction();
      }
      lastArgs = lastThis = null;
    } else {
      timer = setTimeout(later, wait - last);
    }
  }

  function invokeFunction() {
    const thisArg = lastThis;
    const args = lastArgs;
    lastArgs = lastThis = null;
    result = func.apply(thisArg, args);
  }

  function debounced(...args) {
    lastCallTime = Date.now();
    lastThis = this;
    lastArgs = args;
    if (timer === null) {
      // 起始
      timer = setTimeout(later, wait);
      if (leading) {
        invokeFunction();
      }
    }

    return result;
  }

  debounced.cancel = () => {};
  debounced.flush = () => {};

  return debounced;
}
复制代码

codepen.io/curly210102…

接着实现 maxWaitmaxWait 指的是 func 两次执行间的最大间隔。

实现 maxWait 需要确定:

  • 如何判断已达到 maxWait
  • 何时进行判断

如何判断已达到?

记录第一次调用和上一次执行 func 的时间 lastInvokeTime,判断 Date.now() - lastInvokeTime >= maxWait

何时进行判断?

maxWait 需要借助定时器来定时观察,尝试对现有定时器进行改造。

由于 maxWait 大于等于 wait ,初始定时器可以直接用 wait,但后续的定时器的限时需要考虑 maxWait 剩余时长。

function remainingWait(time) {
  // 剩余等待时间
  const remainingWaitingTime = wait - (time - lastCallTime);
  // `func` 最大延时的剩余时间
  const remainingMaxWaitTime = maxWait - (time - lastInvokeTime);

  return hasMaxWait
    ? Math.min(remainingWaitingTime, remainingMaxWaitTime)
    : remainingWaitingTime;
}
复制代码

同时这个改动也带来一个问题,原本的延时是剩余等待时间,但现在变成 remainingWait。产生了三种情况:

b6986c0630578dfdf0899749ef597afb15a6327728a629339b486e1555772f64.png

  • 情况一:定时器回调是 wait 到点
  • 情况二:定时器回调是 maxWait 到点

注意最后一种情况,trailing: false 时,maxWait 到点并不会执行 func。因为在这一点无法判断后续是否有操作,无法确定最近的操作是否在尾部(trailing: false 尾部不执行),只能被动等待下一次调用再执行 func

聚焦到执行 func 的两个切入点:「调用 debounced 函数」 和 「定时器回调」。

两个切入点并没有绝对的先后关系,可以说是互相平行运作。一个切入点执行了 func 另一个切入点应该跳过执行。所以给他们都加上 shouldInvoke 判断,避免混乱。

function shouldInvoke(time) {
  return (
    time - lastCallTime >= wait ||
    (hasMaxWait && time - lastInvokeTime >= maxWait)
  );
}
复制代码

最终代码

function debounce(
  func,
  wait = 0,
  options = {
    leading: false,
    trailing: true,
    maxWait: 0,
  }
) {
  const leading = options.leading;
  const trailing = options.trailing;
  const hasMaxWait = "maxWait" in options;
  const maxWait = Math.max(options.maxWait, wait);
  let timer = null;
  let result = null;
  let lastCallTime = 0;
  let lastInvokeTime = 0;
  let lastThis = null;
  let lastArgs = null;

  function later() {
    const time = Date.now();

    if (shouldInvoke(time)) {
      // 结束
      timer = null;
      if (trailing && lastArgs) {
        invokeFunction(time);
      }
      lastArgs = lastThis = null;
    } else {
      timer = setTimeout(later, remainingTime(time));
    }
    return result;
  }

  function shouldInvoke(time) {
    return (
      time - lastCallTime >= wait ||
      (hasMaxWait && time - lastInvokeTime >= maxWait)
    );
  }

  function remainingTime(time) {
    const last = time - lastCallTime;
    const lastInvoke = time - lastInvokeTime;
    const remainingWaitingTime = wait - last;

    return hasMaxWait
      ? Math.min(remainingWaitingTime, maxWait - lastInvoke)
      : remainingWaitingTime;
  }

  function invokeFunction(time) {
    const thisArg = lastThis;
    const args = lastArgs;
    lastInvokeTime = time;
    lastArgs = lastThis = null;
    result = func.apply(thisArg, args);
  }

  function debounced(...args) {
    const time = Date.now();
    const needInvoke = shouldInvoke(time);
    lastCallTime = time;
    lastThis = this;
    lastArgs = args;

    if (needInvoke) {
      if (timer === null) {
        lastInvokeTime = time;
        timer = setTimeout(later, wait);
        if (leading) {
          invokeFunction(time);
        }
        return result;
      }
      if (hasMaxWait) {
        timer = setTimeout(later, wait);
        invokeFunction(time);
        return result;
      }
    }

    if (timer === null) {
      timer = setTimeout(later, wait);
    }
    return result;
  }

  debounced.cancel = () => {};
  debounced.flush = () => {};

  return debounced;
}
复制代码

codepen.io/curly210102…

简单说一说 throttle

debounce 用于防抖,throttle 用于节流。

节流即在指定时间内最多只执行一次。

简单的节流实现

function throttle(fn, wait) {
  let lastCallTime = 0;
  return function (...args) {
    const time = Date.now();
    if (time - lastCallTime >= wait) {
      fn.apply(this, args);
    }
    lastCallTime = time;
  };
}
复制代码
function throttle(fn, wait) {
  let isLocked = false;
  return function (...args) {
    if (!isLocked) {
      return;
    }

    isLocked = true;
    fn.apply(this, args);
    setTimeout(() => {
      isLocked = false;
    }, wait);
  };
}
复制代码

lodash 里使用 debounce 来实现 throttle

_.throttle(
  func,
  [(wait = 0)],
  [
    (options = {
      leading: true, // 在节流开始前是否执行
      trailing: true, // 在节流结束后是否执行
    }),
  ]
);
复制代码
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,
  });
}
复制代码

Guess you like

Origin juejin.im/post/7034377678601846820