这是我参与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 树较重,频繁的 getBoundingClientRect
、offsetWidth
、offsetHeight
将导致短时间内多次重绘,性能负担增大;如果获取尺寸后跟随的 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"));
复制代码
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;
)
假如需要在操作开始时马上给出反馈,设置 leading: true
即可
leading
和 trailing
同时为 true
时,只有在等待期间多次操作方法才会在结尾调用
除了配置之外,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);
});
复制代码
为了清晰说明问题,这里用一个 flexbox
容器包裹元素,来实现多个元素同时 resize
。
调整容器大小时,明明所有元素都有改变但只有最后一个元素显示更改后的宽度。
解释起来并不复杂,多个对象对应唯一一个debouncedResizeCallback
,debouncedResizeCallback
只会在最后调用一次,最后调用它的恰好是最后一个对象。
解决方法: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
函数。
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
函数。
解决方法: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);
});
复制代码
总结来说,with Cache 的方法本质上是将调用对象收集为一个整体,再将整体应用到最终操作中。
常见问题: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
。
基础实现
先准备一个直观的测试场景
使用 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;
}
复制代码
优化思路及性能图片引用自 modernjavascript.blogspot.com/2013/08/bui…
实现 options
实现 leading
和 trailing
,在代码中找到到 “起始” 和 “结束” 点,加入 leading
和 trailing
条件。
需要注意 leading
和 trailing
同时为 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;
}
复制代码
接着实现 maxWait
,maxWait
指的是 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
。产生了三种情况:
- 情况一:定时器回调是
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;
}
复制代码
简单说一说 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,
});
}
复制代码