本文最后更新于:2020年3月11日 早上
场景
网络上已经存在了大量的有关 防抖 和 节流 的文章,为何吾辈还要再写一篇呢?事实上,防抖和节流,吾辈在使用中发现了一些奇怪的问题,并经过了数次的修改,这里主要分享一下吾辈遇到的问题以及是如何解决的。
为什么要用防抖和节流?
因为某些函数触发/调用的频率过快,吾辈需要手动去限制其执行的频率。例如常见的监听滚动条的事件,如果没有防抖处理的话,并且,每次函数执行花费的时间超过了触发的间隔时间的话 – 页面就会卡顿。
演进
初始实现
我们先实现一个简单的去抖函数
1 2 3 4 5 6 7 8 9
| function debounce(delay, action) { let tId; return function (...args) { if (tId) clearTimeout(tId); tId = setTimeout(() => { action(...args); }, delay); }; }
|
测试一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); (async () => { let num = 0; const add = () => ++num;
add(); add(); console.log(num);
const fn = debounce(10, add); fn(); fn(); console.log(num); await wait(20); console.log(num); })();
|
好了,看来基本的效果是实现了的。包装过的函数 fn
调用了两次,却并没有立刻执行,而是等待时间间隔过去之后才最终执行了一次。
this 怎么办?
然而,上面的实现有一个致命的问题,没有处理 this
!当你用在原生的事件处理时或许还不觉得,然而,当你使用了 ES6 class
这类对 this
敏感的代码时,就一定会遇到 this
带来的问题。
例如下面使用 class
来声明一个计数器
1 2 3 4 5 6 7 8
| class Counter { constructor() { this.i = 0; } add() { this.i++; } }
|
我们可能想在 constructor
中添加新的属性 fn
1 2 3 4 5 6 7 8 9
| class Counter { constructor() { this.i = 0; this.fn = debounce(10, this.add); } add() { this.i++; } }
|
但很遗憾,这里的 this
绑定是有问题的,执行以下代码试试看
1 2
| const counter = new Counter(); counter.fn();
|
会抛出异常 Cannot read property 'i' of undefined
,究其原因就是 this
没有绑定,我们可以手动绑定 this .bind(this)
1 2 3 4 5 6 7 8 9
| class Counter { constructor() { this.i = 0; this.fn = debounce(10, this.add.bind(this)); } add() { this.i++; } }
|
但更好的方式是修改 debounce
,使其能够自动绑定 this
1 2 3 4 5 6 7 8 9
| function debounce(delay, action) { let tId; return function (...args) { if (tId) clearTimeout(tId); tId = setTimeout(() => { action.apply(this, args); }, delay); }; }
|
然后,代码将如同预期的运行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| (async () => { class Counter { constructor() { this.i = 0; this.fn = debounce(10, this.add); } add() { this.i++; } }
const counter = new Counter(); counter.add(); counter.add(); console.log(counter.i);
counter.fn(); counter.fn(); console.log(counter.i); await wait(20); console.log(counter.i); })();
|
返回值呢?
不知道你有没有发现,现在使用 debounce
包装的函数都没有返回值,是完全只有副作用的函数。然而,吾辈还是遇到了需要返回值的场景。
例如:输入停止后,使用 Ajax 请求后台数据判断是否已存在相同的数据。
修改 debounce
成会缓存上一次执行结果并且有初始结果参数的实现
1 2 3 4 5 6 7 8 9 10 11
| function debounce(delay, action, init = undefined) { let flag; let result = init; return function (...args) { if (flag) clearTimeout(flag); flag = setTimeout(() => { result = action.apply(this, args); }, delay); return result; }; }
|
调用代码变成了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| (async () => { class Counter { constructor() { this.i = 0; this.fn = debounce(10, this.add, 0); } add() { return ++this.i; } }
const counter = new Counter();
console.log(counter.add()); console.log(counter.add());
console.log(counter.fn()); console.log(counter.fn()); await wait(20); console.log(counter.fn()); })();
|
看起来很完美?然而,没有考虑到异步函数是个大失败!
尝试以下测试代码
1 2 3 4 5 6 7 8 9 10 11
| (async () => { const get = async (i) => i;
console.log(await get(1)); console.log(await get(2)); const fn = debounce(10, get, 0); fn(3).then((i) => console.log(i)); fn(4).then((i) => console.log(i)); await wait(20); fn(5).then((i) => console.log(i)); })();
|
会抛出异常 fn(...).then is not a function
,因为我们包装过后的函数是同步的,第一次返回的值并不是 Promise
类型。
除非我们修改默认值
1 2 3 4 5 6 7 8 9 10 11 12
| (async () => { const get = async (i) => i;
console.log(await get(1)); console.log(await get(2)); const fn = debounce(10, get, new Promise((resolve) => resolve(0))); fn(3).then((i) => console.log(i)); fn(4).then((i) => console.log(i)); await wait(20); fn(5).then((i) => console.log(i)); })();
|
支持有返回值的异步函数
支持异步有两种思路
- 将异步函数包装为同步函数
- 将包装后的函数异步化
第一种思路实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function debounce(delay, action, init = undefined) { let flag; let result = init; return function (...args) { if (flag) clearTimeout(flag); flag = setTimeout(() => { const temp = action.apply(this, args); if (temp instanceof Promise) { temp.then((res) => (result = res)); } else { result = temp; } }, delay); return result; }; }
|
调用方式和同步函数完全一样,当然,是支持异步函数的
1 2 3 4 5 6 7 8 9 10 11 12
| (async () => { const get = async (i) => i;
console.log(await get(1)); console.log(await get(2)); const fn = debounce(10, get, 0); console.log(fn(3)); console.log(fn(4)); await wait(20); console.log(fn(5)); })();
|
第二种思路实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const debounce = (delay, action, init = undefined) => { let flag; let result = init; return function (...args) { return new Promise((resolve) => { if (flag) clearTimeout(flag); flag = setTimeout(() => { result = action.apply(this, args); resolve(result); }, delay); setTimeout(() => { resolve(result); }, delay); }); }; };
|
调用方式支持异步的方式
1 2 3 4 5 6 7 8 9 10 11 12
| (async () => { const get = async (i) => i;
console.log(await get(1)); console.log(await get(2)); const fn = debounce(10, get, 0); fn(3).then((i) => console.log(i)); fn(4).then((i) => console.log(i)); await wait(20); fn(5).then((i) => console.log(i)); })();
|
可以看到,第一种思路带来的问题是返回值永远会是 旧的 返回值,第二种思路主要问题是将同步函数也给包装成了异步。利弊权衡之下,吾辈觉得第二种思路更加正确一些,毕竟使用场景本身不太可能必须是同步的操作。而且,原本 setTimeout
也是异步的,只是不需要返回值的时候并未意识到这点。
避免原函数信息丢失
后来,有人提出了一个问题,如果函数上面携带其他信息,例如类似于 jQuery
的 $
,既是一个函数,但也同时含有其他属性,如果使用 debounce
就找不到了呀
一开始吾辈立刻想到了复制函数上面的所有可遍历属性,然后想起了 ES6 的 Proxy
特性 – 这实在是太魔法了。使用 Proxy 解决这个问题将异常的简单 – 因为除了调用函数,其他的一切操作仍然指向原函数!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const debounce = (delay, action, init = undefined) => { let flag; let result = init; return new Proxy(action, { apply(target, thisArg, args) { return new Promise((resolve) => { if (flag) clearTimeout(flag); flag = setTimeout(() => { resolve((result = Reflect.apply(target, thisArg, args))); }, delay); setTimeout(() => { resolve(result); }, delay); }); }, }); };
|
测试一下
1 2 3 4 5 6 7 8
| (async () => { const get = async (i) => i; get.rx = "rx";
console.log(get.rx); const fn = debounce(10, get, 0); console.log(fn.rx); })();
|
实现节流
以这种思路实现一个节流函数 throttle
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
const throttle = (delay, action) => { let last = 0; let result; return new Proxy(action, { apply(target, thisArg, args) { return new Promise((resolve) => { const curr = Date.now(); if (curr - last > delay) { result = Reflect.apply(target, thisArg, args); last = curr; resolve(result); return; } resolve(result); }); }, }); };
|
总结
嘛,实际上这里的防抖和节流仍然是简单的实现,其他的像 取消防抖/强制刷新缓存 等功能尚未实现。当然,对于吾辈而言功能已然足够了,也被放到了公共的函数库 rx-util 中。