Debounce函数及lodash库的一些思考

hybrid-native开发或者reat-native开发时,不可避免涉及到js脚本编写。对于大前端来说,对于es6/es7的语法熟悉是必备素质。这里介绍一个前端常用的工具库lodash。

遇到的问题

今天在优化大搜索页面时碰到一个问题,feedback返回用户反馈搜索页面卡顿。先定位到原先的代码:是在 < TextInput > 的onchange回调中,请求suggest接口返回模糊搜索结果。但是在IOS设备上会有一个问题:键盘输入拼音时系统会把未输入完的结果录到input组建中,导致onchange回调多次调用,suggest接口频繁请求,hint内容不断刷新,造成页面卡顿的同时,也增加了接口的负担。

优化点

经过分析,我认为用户使用模糊搜索具备 幂等性 .即在一段时间内用户输入的key word应返回相同的suggest。不应频繁调用接口。
基于以上思想,很自然想到setTimeout()函数,用户输入停止后设置一个延时再请求网络,但是直接在回调里setTimeout这个做法十分hardcode。于是逛了一下万能的gayHub,发现了一个不错的解决方案:使用debounce函数去除抖动。

lodash介入

这里的debounce函数属于鼎鼎大名的lodash库(https://github.com/lodash/lodash)
debounce函数的官方文档

lodash官方文档
lodash中文文档

debounce 与 throttle

debounce(防抖):当调用函数n秒后,才会执行该动作,若在这n秒内又调用该函数则将取消前一次并重新计算执行时间,举个简单的例子,我们要根据用户输入做suggest,每当用户按下键盘的时候都可以取消前一次,并且只关心最后一次输入的时间就行了。

throttle(节流):将一个函数的调用频率限制在一定阈值内,例如 1s 内一个函数不能被调用两次。
这里拿出debounce的源码解析一下:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
function debounce(func, wait, options) {
let lastArgs,
lastThis,
maxWait,
result,
timerId,
lastCallTime

// 参数初始化
let lastInvokeTime = 0 // func 上一次执行的时间
let leading = false
let maxing = false
let trailing = true

// 基本的类型判断和处理
if (typeof func != 'function') {
throw new TypeError('Expected a function')
}
wait = +wait || 0
if (isObject(options)) {
// 对配置的一些初始化
}

function invokeFunc(time) {
const args = lastArgs
const thisArg = lastThis

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

function leadingEdge(time) {
// Reset any `maxWait` timer.
lastInvokeTime = time
// 为 trailing edge 触发函数调用设定定时器
timerId = setTimeout(timerExpired, wait)
// leading = true 执行函数
return leading ? invokeFunc(time) : result
}

function remainingWait(time) {
const timeSinceLastCall = time - lastCallTime // 距离上次debounced函数被调用的时间
const timeSinceLastInvoke = time - lastInvokeTime // 距离上次函数被执行的时间
const timeWaiting = wait - timeSinceLastCall // 用 wait 减去 timeSinceLastCall 计算出下一次trailing的位置

// 两种情况
// 有maxing:比较出下一次maxing和下一次trailing的最小值,作为下一次函数要执行的时间
// 无maxing:在下一次trailing时执行 timerExpired
return maxing
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting
}

// 根据时间判断 func 能否被执行
function shouldInvoke(time) {
const timeSinceLastCall = time - lastCallTime
const timeSinceLastInvoke = time - lastInvokeTime

// 几种满足条件的情况
return (lastCallTime === undefined //首次
|| (timeSinceLastCall >= wait) // 距离上次被调用已经超过 wait
|| (timeSinceLastCall < 0) //系统时间倒退
|| (maxing && timeSinceLastInvoke >= maxWait)) //超过最大等待时间
}

function timerExpired() {
const time = Date.now()
// 在 trailing edge 且时间符合条件时,调用 trailingEdge函数,否则重启定时器
if (shouldInvoke(time)) {
return trailingEdge(time)
}
// 重启定时器,保证下一次时延的末尾触发
timerId = setTimeout(timerExpired, remainingWait(time))
}

function trailingEdge(time) {
timerId = undefined

// 有lastArgs才执行,意味着只有 func 已经被 debounced 过一次以后才会在 trailing edge 执行
if (trailing && lastArgs) {
return invokeFunc(time)
}
// 每次 trailingEdge 都会清除 lastArgs 和 lastThis,目的是避免最后一次函数被执行了两次
// 举个例子:最后一次函数执行的时候,可能恰巧是前一次的 trailing edge,函数被调用,而这个函数又需要在自己时延的 trailing edge 触发,导致触发多次
lastArgs = lastThis = undefined
return result
}

function cancel() {}

function flush() {}

function pending() {}

function debounced(...args) {
const time = Date.now()
const isInvoking = shouldInvoke(time) //是否满足时间条件

lastArgs = args
lastThis = this
lastCallTime = time //函数被调用的时间

if (isInvoking) {
if (timerId === undefined) { // 无timerId的情况有两种:1.首次调用 2.trailingEdge执行过函数
return leadingEdge(lastCallTime)
}
if (maxing) {
// Handle invocations in a tight loop.
timerId = setTimeout(timerExpired, wait)
return invokeFunc(lastCallTime)
}
}
// 负责一种case:trailing 为 true 的情况下,在前一个 wait 的 trailingEdge 已经执行了函数;
// 而这次函数被调用时 shouldInvoke 不满足条件,因此要设置定时器,在本次的 trailingEdge 保证函数被执行
if (timerId === undefined) {
timerId = setTimeout(timerExpired, wait)
}
return result
}
debounced.cancel = cancel
debounced.flush = flush
debounced.pending = pending
return debounced
}

首次进入函数时因为 lastCallTime === undefined 并且 timerId === undefined,所以会执行 leadingEdge,如果此时 leading 为 true 的话,就会执行 func。同时,这里会设置一个定时器,在等待 wait(s) 后会执行 timerExpired,timerExpired 的主要作用就是触发 trailing。

如果在还未到 wait 的时候就再次调用了函数的话,会更新 lastCallTime,并且因为此时 isInvoking 不满足条件,所以这次什么也不会执行。

时间到达 wait 时,就会执行我们一开始设定的定时器timerExpired,此时因为time-lastCallTime < wait,所以不会执行 trailingEdge。

这时又会新增一个定时器,下一次执行的时间是 remainingWait,这里会根据是否有 maxwait 来作区分:

如果没有 maxwait,定时器的时间是 wait - timeSinceLastCall,保证下一次 trailing 的执行。

如果有 maxing,会比较出下一次 maxing 和下一次 trailing 的最小值,作为下一次函数要执行的时间。

最后,如果不再有函数调用,就会在定时器结束时执行 trailingEdge。

lodash其他常用函数:

遍历对象类型:
_.forEach(obj, (value, key) => { console.log(value) })

遍历和过滤的快捷方式:
从一组对象中摘取出某个属性的值:

1
2
3
4
5
let arr = [{ n: 1 }, { n: 2 }]
// ES6
arr.map((obj) => obj.n)
// Lodash
_.map(arr, 'n')

当对象类型的嵌套层级很多时,Lodash 的快捷方式就更实用了:

1
2
3
4
5
6
7
8
let arr = [
{ a: [ { n: 1 } ]},
{ b: [ { n: 1 } ]}
]
// ES6
arr.map((obj) => obj.a[0].n) // TypeError: 属性 'a' 在 arr[1] 中未定义
// Lodash
_.map(arr, 'a[0].n') // => [1, undefined]

可以看到,Lodash 的快捷方式还对 null 值做了容错处理。此外还有过滤快捷方式,以下是从 Lodash 官方文档中摘取的示例代码:

1
2
3
4
5
6
7
8
9
10
let users = [
{ 'user': 'barney', 'age': 36, 'active': true },
{ 'user': 'fred', 'age': 40, 'active': false }
];
// ES6
users.filter((o) => o.active)
// Lodash
_.filter(users, 'active')
_.filter(users, ['active', true])
_.filter(users, {'active': true, 'age': 36})

链式调用和惰性求值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
et lines = `
an apple orange the grape
banana an apple melon
an orange banana apple
`.split('\n')

_.chain(lines)
.flatMap(line => line.split(/\s+/))
.filter(word => word.length > 3)
.groupBy(_.identity)
.mapValues(_.size)
.forEach((count, word) => { console.log(word, count) })

// apple 3
// orange 2
// grape 1
// banana 2
// melon 1

解构赋值和箭头函数:
ES6 引入了解构赋值、箭头函数等新的语言特性,可以用来替换 Lodash:

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
31
32
33
34
// Lodash
_.head([1, 2, 3]) // => 1
_.tail([1, 2, 3]) // => [2, 3]
// ES6 解构赋值(destructuring syntax)
const [head, ...tail] = [1, 2, 3]

// Lodash
let say = _.rest((who, fruits) => who + ' likes ' + fruits.join(','))
say('Jerry', 'apple', 'grape')
// ES6 spread syntax
say = (who, ...fruits) => who + ' likes ' + fruits.join(',')
say('Mary', 'banana', 'orange')

// Lodash
_.constant(1)() // => 1
_.identity(2) // => 2
// ES6
(x => (() => x))(1)() // => 1
(x => x)(2) // => 2

// 偏应用(Partial application)
let add = (a, b) => a + b
// Lodash
let add1 = _.partial(add, 1)
// ES6
add1 = b => add(1, b)

// 柯里化(Curry)
// Lodash
let curriedAdd = _.curry(add)
let add1 = curriedAdd(1)
// ES6
curriedAdd = a => b => a + b
add1 = curriedAdd(1)

一些参考信息:

10 Lodash Features You Can Replace with ES6
Does ES6 Mean The End Of Underscore / Lodash?

龙颜大悦,朕要赏赐!