Lodash 函数篇
本文从应用频率和实用性顺次递加的程序来聊一聊 Lodash 中对于函数的工具函数。对于大多数函数本文不会给出 Lodash 源码的残缺实现,而更多侧重于实现思路的探讨。
本文共 13874 字,浏览实现大概须要 28 分钟。
防抖 (debounce) 与节流(throttle)
debounce 函数能够算是前端最罕用的一个函数了,只有是有用户事件的中央可能都须要用到。其本质就是高阶函数(传入函数返回函数),利用闭包来寄存定时器 id 等状态。
照着这个思路咱们能够写出十分简洁的 debounce 函数, 惟一须要留神的就是传入函数的参数和 this 的指向:
function debounce(fn, wait) {if (typeof fn !== 'function') {throw new TypeError('Expected a function');
}
let result = null; // 存储函数执行后果,集体感觉这个性能比拟鸡肋后文中就不加了
let timerId = null;
function debounced(...args) {if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {
// 传入函数的参数和 this 的指向
result = fn.apply(this, args);
}, +wait);
return result;
}
return debounced;
}
throttle 函数也能够应用定时器实现,与 debounce 的区别就在于革除定时器的机会:debounce 函数在每次执行包装函数 (debounced) 时革除,而 throttle 函数在传入函数 (fn) 执行实现后革除。
function throttle(fn, wait) {if (typeof fn !== 'function') {throw new TypeError('Expected a function');
}
let timerId = null;
function throttled(...args) {if (timerId) return;
timerId = setTimeout(() => {fn.apply(this, args);
timerId = clearTimeout(timerId);
}, +wait);
}
return throttled;
}
throttle 函数还有一种应用工夫戳的写法,闭包中存储的不再是定时器 id 而是工夫戳:
function throttle(fn, wait) {if (typeof fn !== 'function') {throw new TypeError('Expected a function');
}
let lastInvokeTime = null; // 各位能够思考下初始值设为 null 和 Date.now()的区别
function throttled(...args) {// 这里可能会呈现批改零碎工夫,导致 Date.now()扭转的状况,集体感觉呈现的概率较小就先疏忽了这种状况了
if ((Date.now() - lastInvokeTime) < +wait) return;
fn.apply(this, args);
lastInvokeTime = Date.now();}
return throttled;
}
这两种写法尽管都能够实现节流的性能, 但还是有比拟大的区别:
- 用户事件首次触发时,定时器版本不会执行函数,工夫戳版本会执行。
- 用户事件最初一次被触发且期待 wait 工夫距离后,定时器版本会执行函数,工夫戳版本不会执行。
所以各位能够先想一想,如何做到管制首次触发和最初一次触发时函数是否执行?
function throttle(fn, wait, options) {if (typeof fn !== 'function') {throw new TypeError('Expected a function');
}
const leading = options?.leading === undefined ? true : !!options.leading; // 首次触发, 默认 true
const trailing = options?.trailing === undefined ? true : !!options.trailing; // 最初一次触发
// ???
}
笔者想到的有两种思路: 第一种思路十分暴力: 间接组合两个版本, trailing 应用定时器版本,!trailing 应用工夫戳版本;而后通过 debounce 来标识是否首次执行包装函数, 留神正文里的内容!
function throttle(fn, wait, options) {if (typeof fn !== 'function') {throw new TypeError('Expected a function');
}
const leading = options?.leading === undefined ? true : !!options.leading; // 首次, 默认 true
const trailing = options?.trailing === undefined ? true : !!options.trailing; // 最初一次
let timerId = null;
let lastInvokeTime = null;
let leadingTimerId = null; // 为 null 时示意首次执行包装函数
// 定时器版本
function timoutWrapper(...args) {if (timerId) return;
timerId = setTimeout(() => {fn.apply(this, args);
timerId = clearTimeout(timerId);
}, +wait);
}
// 工夫戳版本
function timestampWrapper(...args) {if ((Date.now() - lastInvokeTime) < +wait) return;
fn.apply(this, args);
lastInvokeTime = Date.now();}
return function throttled(...args) {if (trailing) {if (leading && !leadingTimerId) {fn.apply(this, args); // 定时器版本首次执行
}
timoutWrapper.apply(this, args);
} else {if (!leading && !leadingTimerId) {lastInvokeTime = Date.now(); // 工夫戳版本首次不执行
}
timestampWrapper.apply(this, args);
}
// 以防抖的形式标记首次执行标记位
if (leadingTimerId) clearTimeout(leadingTimerId);
leadingTimerId = setTimeout(() => {leadingTimerId = null;}, +wait);
};
}
第二种思路就是用工夫戳版本 +debounce,工夫戳版本能够管制首次,debounce 能够管制最初一次:
function throttle(fn, wait, options) {if (typeof fn !== 'function') {throw new TypeError('Expected a function');
}
const leading = options?.leading === undefined ? true : !!options.leading; // 首次触发, 默认 true
const trailing = options?.trailing === undefined ? true : !!options.trailing; // 最初一次触发
let lastInvokeTime = null;
let timerId = null;
const getRemainingWait = () => +wait - (Date.now() - lastInvokeTime);
function throttled(...args) {
// 工夫戳版本
if (getRemainingWait() <= 0) {if (!leading && !timerId) {}
else fn.apply(this, args);
lastInvokeTime = Date.now();}
// debounce
if (trailing) {if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {fn.apply(this, args);
lastInvokeTime = Date.now();
timerId = null;
}, getRemainingWait());
}
}
return throttled;
}
这种写法同样也是 Lodash 实现 throttle 的思路,不过这种思路有个小问题: 不能同时将 leading 和 trailing 设为 false, 从上文中的两个条件分支也能够看进去,同时设为 false 时 fn 将永远不会执行。
各位能够本人批改下 options 进行尝试:
function handleInput(evt) {console.warn(evt, this);
}
const handler = throttle(handleInput, 2e3, {
leading: true,
trailing: false,
});
input.addEventListener('input', handler);
let count = 0;
setInterval(() => {console.error(count);
count += 1;
}, 1e3);
各位本人进行尝试后可能会发现一个小细节: 下面两个实现在 trailing 为 true 且在输入框只输出一次时,最初一次也会执行。其实咱们能够增加一个参数,来管制是否用户事件仅触发一次时不执行最初一次,具体实现交给读者本人思考。
上面让咱们来看下 Lodash 的实现, 留神正文里的内容!
function isObject(value) {
const type = typeof value
return value != null && (type === 'object' || type === 'function')
}
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
})
}
function debounce(func, wait, options) {
let lastArgs,
lastThis,
maxWait,
timerId,
lastCallTime
let lastInvokeTime = 0
let leading = false
let maxing = false
let trailing = true
// Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
const useRAF = (!wait && wait !== 0 && typeof window.requestAnimationFrame === 'function')
if (typeof func !== 'function') {throw new TypeError('Expected a function')
}
wait = +wait || 0
if (isObject(options)) {
leading = !!options.leading
maxing = 'maxWait' in options
maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
trailing = 'trailing' in options ? !!options.trailing : trailing
}
function invokeFunc(time) {func.apply(lastThis, lastArgs)
lastArgs = lastThis = undefined
lastInvokeTime = time
}
function startTimer(pendingFunc, wait) {if (useRAF) {window.cancelAnimationFrame(timerId)
return window.requestAnimationFrame(pendingFunc)
}
return setTimeout(pendingFunc, wait)
}
function leadingEdge(time) {
// Reset any `maxWait` timer.
lastInvokeTime = time
// Start the timer for the trailing edge.
timerId = startTimer(timerExpired, wait)
// Invoke the leading edge.
if (leading) invokeFunc(time)
}
function remainingWait(time) {
const timeSinceLastInvoke = time - lastInvokeTime
const timeWaiting = wait - (time - lastCallTime)
return maxing
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting
}
function shouldInvoke(time) {
const timeSinceLastCall = time - lastCallTime
const timeSinceLastInvoke = time - lastInvokeTime
// Either this is the first call, activity has stopped and we're at the
// trailing edge, the system time has gone backwards and we're treating
// it as the trailing edge, or we've hit the `maxWait` limit.
return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
(timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
}
function timerExpired() {const time = Date.now()
// for throttle, 为 throttle 服务
if (shouldInvoke(time)) {
timerId = undefined
if (trailing && lastArgs) {invokeFunc(time)
}
lastArgs = lastThis = undefined
return
}
// for debounce, 为 debounce 服务
timerId = startTimer(timerExpired, remainingWait(time))
}
function debounced(...args) {const time = Date.now()
lastArgs = args
lastThis = this
lastCallTime = time // for debounce, 为 debounce 服务
// 工夫戳版本
if (shouldInvoke(time)) {if (timerId === undefined) {return leadingEdge(time) // 是否首次执行
}
if (maxing) {
// Handle invocations in a tight loop.
timerId = startTimer(timerExpired, wait)
return invokeFunc(time)
}
}
// debounce
if (timerId === undefined) {timerId = startTimer(timerExpired, wait)
}
}
return debounced
}
Lodash 的实现大体跟上文的工夫戳版本 +debounce 统一, 不过加了几个细节:
- 为了拆分函数,Lodash 将传入函数的参数和 this 的指向存在了闭包里。
- 当 wait 被显式设置为 0 时,
setTimeout
被requestAnimationFrame
所代替。
执行单次(once)
这个函数很简略,跟后面的 debounce 思路一样: 高阶函数,利用闭包存储状态。
function before(n, func) {
let result
if (typeof func !== 'function') {throw new TypeError('Expected a function')
}
return function(...args) {if (--n > 0) {result = func.apply(this, args)
}
if (n <= 1) {func = undefined}
return result
}
}
function once(func) {return before(2, func)
}
须要留神的是这里的 func 函数援用的重置,如果不重置 func 的话,当作为参数的 func 函数在某个时间段被革除则会导致肯定水平的内存透露,举个例子:
function before(n, func) {
let result
if (typeof func !== 'function') {throw new TypeError('Expected a function')
}
return function(...args) {
// 如果不重置 func,func 指向的内存区域始终不会被 GC,然而它其实并没有被应用到
console.warn(n, func);
if (--n > 0) {result = func.apply(this, args)
}
// if (n <= 1) {
// func = undefined
// }
return result
}
}
function foo() {console.warn('foo');
}
const bar = before(5, foo);
setInterval(() => {bar();
}, 1000);
setTimeout(() => {foo = null;}, 6000);
记忆化(memoize)
记忆化就是将函数执行的后果存储起来,原理依然是高阶函数 + 闭包, 先看下用法:
function foo(a, b) {console.warn('log', a, b);
return [a, b];
}
const memoFoo = memoize(foo);
memoFoo(1, 2); // log 1 2
memoFoo(1, 2);
memoFoo(2, 3); // log 2 3
memoFoo(1, 2);
memoFoo(2, 3);
// 将参数依据类型与构造转为字符串
function argsToString(...args) {return args.map((e) => {
const type = typeof e;
// null vs "null", 1 vs BigInt(1)
// ignore error object
return type === 'object'
? JSON.stringify(e)
: `${type}_${e.toString()}`;
}).join();}
const memoBar = memoize(foo, argsToString);
memoBar({name: 'a'}, 1); // log {name: 'a'} 1
memoBar({name: 'a'}, 1);
memoBar({name: 'b'}, 1); // log {name: 'b'} 1
memoBar({name: 'a'}, 1);
能够看到函数只会执行一次,雷同参数函数再次执行间接返回首次执行后果, 而对于援用类型的参数 Lodash 也提供了第二个参数来调整键值生成策略。
上面让咱们来看下 Lodash 的实现:
function memoize(func, resolver) {if (typeof func !== 'function' || (resolver != null && typeof resolver !== 'function')) {throw new TypeError('Expected a function')
}
const memoized = function(...args) {const key = resolver ? resolver.apply(this, args) : args[0]
const cache = memoized.cache
if (cache.has(key)) {return cache.get(key)
}
const result = func.apply(this, args)
memoized.cache = cache.set(key, result) || cache
return result
}
memoized.cache = new (memoize.Cache || Map)
return memoized
}
memoize.Cache = Map
用 Map
来寄存函数执行后果,须要留神的点在于 Map
的 key 如何抉择。能够看到 Lodash 的策略是: 不传 resolver 时应用第一个参数作为 key, 传了 resolver 则应用 resolver 的执行后果。
柯里化 (curry) 与偏函数利用(partial)
先来看下这两个函数的用法:
function add(a, b, c, d) {return a + b + c + d;}
const cadd = curry(add);
cadd(1)(2)(3)(4) // 10
cadd(1)(2)(3, 4) // 10
cadd(1)(2, 3, 4) // 10
cadd(1, 2, 3)(4) // 10, Partial application
partial(add, 1)(2, 3, 4) // 10
partial(add, 1, 2)(3, 4) // 10
回过头来看下维基百科对两个名词的定义:
柯里化(currying): 把承受多个参数的函数变换成承受一个繁多参数(最后函数的第一个参数)的函数,并且返回承受余下的参数而且返回后果的新函数
偏函数利用 (Partial application): 固定一个函数的一些参数,而后产生另一个更小元(更少参数) 的函数
从上文的执行后果咱们不难发现,Lodash 的柯里化实现主动进行了偏函数利用:繁多参数变为了多参数。
接下来让咱们想想如何实现这两个函数。实现 partial 函数的思路比较简单,一个拼接输出参数与输入函数参数的高阶函数:
function partial(func, ...args) {if (typeof func !== 'function') {throw new TypeError('Expected a function');
}
function wrapper(...wrapperArgs) {return func.apply(this, args.concat(wrapperArgs));
}
return wrapper;
}
curry 函数则须要利用递归的执行栈去拼接参数: 当传入总参数少于 func 传入参数时返回包装函数,否则返回执行后果
function curry(func) {if (typeof func !== 'function') {throw new TypeError('Expected a function');
}
function curried(...args) {if (args.length < func.length) {return function wrapper(...wrapperArgs) {return curried.apply(this, args.concat(wrapperArgs));
}
}
return func.apply(this, args);
};
return curried;
}
Lodash 将多个函数的创立 (partial, curry, bind 等) 都放在了一个公共函数 createWrap
函数中,而后加了多个 Flag 去辨别各个函数的逻辑,限于篇幅这里就不开展了。各位有趣味能够本人去看看: curry 函数, partial 函数
小结
总结一下本文的重点:
- 防抖 (debounce) 与节流 (throttle) 的实现:
- 须要留神传入函数的参数和 this 的指向。
- 节流能够通过工夫戳版本 +debounce 管制首次 / 最初一次触发时函数执行的逻辑。
- 实现节流 (throttle) 时须要留神的事项:
- leading 和 trailing 同时设为 false 时,是否须要执行函数。
- 为了拆分函数,能够将传入函数的参数和 this 的指向存在了闭包里。
- 当 wait 被显式设置为 0 时,
setTimeout
能够被requestAnimationFrame
所代替。
- 柯里化 (curry) 与偏函数利用 (partial) 的实现:
- partial 函数: 拼接输出参数与输入函数的参数。
- curry 函数: 利用递归的执行栈拼接参数。
好了,以上就是本文对于 Lodash 函数篇的全部内容。行文不免有疏漏和谬误,还望读者批评指正。