Lodash函数篇

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时,setTimeoutrequestAnimationFrame所代替。

执行单次(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函数

小结

总结一下本文的重点:

  1. 防抖(debounce)与节流(throttle)的实现:
  • 需要注意传入函数的参数和this的指向。
  • 节流可以通过时间戳版本+debounce控制首次/最后一次触发时函数执行的逻辑。
  1. 实现节流(throttle)时需要注意的事项:
  • leading和trailing同时设为false时,是否需要执行函数。
  • 为了拆分函数,可以将传入函数的参数和this的指向存在了闭包里。
  • 当wait被显式设置为0时,setTimeout可以被requestAnimationFrame所代替。
  1. 柯里化(curry)与偏函数应用(partial)的实现:
  • partial函数: 拼接输入参数与输出函数的参数。
  • curry函数: 利用递归的执行栈拼接参数。

好了,以上就是本文关于Lodash函数篇的全部内容。行文难免有疏漏和错误,还望读者批评指正。

你可能感兴趣的:(前端,javascript)