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 2memoFoo(1, 2);memoFoo(2, 3); // log 2 3memoFoo(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' } 1memoBar({ name: 'a' }, 1);memoBar({ name: 'b' }, 1); // log { name: 'b' } 1memoBar({ 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) // 10cadd(1)(2)(3, 4) // 10cadd(1)(2, 3, 4) // 10cadd(1, 2, 3)(4) // 10, Partial applicationpartial(add, 1)(2, 3, 4) // 10partial(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函数篇的全部内容。行文不免有疏漏和谬误,还望读者批评指正。