This is the second day of my participation in the November Gwen Challenge. Check out the details: the last Gwen Challenge 2021

The article Outlines

  • Based on using
    • debounceUsing the demonstration
    • lodash.debounce
  • Common problem: Passing different parameters
    • Solution: Memoize
    • Solution: With cache
  • Common problems: Correct use of React componentsdebounce
  • implementationlodash.debounce
    • Basic implementation
    • Performance optimization
    • implementationoptions
  • Just to be briefthrottle

Based on using

When elevator equipment detects someone entering the elevator, it resets a fixed time before closing the door.

Debounce and elevator waiting have similar logic.

Debounce returns a debounce function which, when called, will re-delay the execution of func for the specified time.

Debounce is an effective way to avoid consecutive triggers in a short period of time and executes logic after the trigger has finished. It is often used with events such as Element Resize and input keyDown.

debounceUsing the demonstration

Here’s a simple example: Get the size of an element after it changes size, and then proceed

<div class="box"></div>
Copy the code
.box {
  width: 100px;
  height: 100px;
  background-color: green;
  resize: both;
  overflow: auto;
}

.box::after {
  content: attr(data-size);
}
Copy the code
function getElementSizeInfo(target) {
  const { width, height } = target.getBoundingClientRect();
  const { offsetWidth, offsetHeight } = target;

  return {
    width,
    height,
    offsetWidth,
    offsetHeight,
  };
}

const resizeObserver = new ResizeObserver((entries) = > {
  // resizeObserverCallback
  for (const entry of entries) {
    const sizeInfo = getElementSizeInfo(entry.target);
    entry.target.dataset.size = sizeInfo.offsetWidth;
    // ...}}); resizeObserver.observe(document.querySelector(".box"));
Copy the code

In this example, ResizeObserver is used to listen for element resizing, and the resizeObserverCallback logic is triggered multiple times when resizing is dragged.

The example here is simple and does not result in a frame jam. However, if the DOM tree is heavy, frequent getBoundingClientRect, offsetWidth, offsetHeight will lead to multiple redraws in a short period of time, increasing the performance burden. If the JS calculation logic following the size is very time-consuming, the Script will occupy the main thread for a long time, and multiple calls in a short time will cause the drag operation to be disconnected.

To optimize the experience, it is tempting to leave the time-wasting logic to the end of a continuous operation.

So how do you know if a continuous operation has ended? There seemed to be no Web API available, so debounce was the only way to simulate it.

It is estimated that the interval for continuous operation is n seconds. After an operation, wait n seconds. If a new operation is observed, the operation is continuous.

Easily rewrite code with Debounce

function getElementSizeInfo(target) {
  const { width, height } = target.getBoundingClientRect();
  const { offsetWidth, offsetHeight } = target;

  return {
    width,
    height,
    offsetWidth,
    offsetHeight,
  };
}

function resizeCallback(target) {
  const sizeInfo = getElementSizeInfo(target);
  target.dataset.size = sizeInfo.offsetWidth;
  // ...
}

// Set the observation time to 800 ms
const debouncedResizeCallback = _.debounce(resizeCallback, 800);

const resizeObserver = new ResizeObserver((entries) = > {
  for (const entry ofentries) { debouncedResizeCallback(entry.target); }}); resizeObserver.observe(document.querySelector(".box"));
Copy the code

Codepen. IO/curly210102…

lodash.debounce

In our daily development, we often use the debounce method provided by loDash, the JavaScript utility library. Take a look at what Lodash. Debounce comes with.

_.debounce(
  func,
  [(wait = 0)],
  [
    (options = {
      leading: false.trailing: true.maxWait: 0,})]);Copy the code

In addition to the base parameters, Lodash. Debounce also provides an options configuration

  • leading: Whether to call at the beginning of a continuous operation
  • trailing: Whether to call at the end of a continuous operation
  • maxWait: Maximum duration of a continuous operation

The default, of course, is the ending call (leading: false; The trailing: true)

Set leading: true if you need to give feedback immediately at the start of the operation

leadingtrailingAt the same timetrueOnly multiple action methods are called at the end of the wait

In addition to the configuration, Lodash. Debounce also provides two manual manipulation methods for debounce function:

  • debounced.cancel(): Cancels the tail call of this continuous operation
  • debounced.flush(): Perform the continuous operation immediately

Common problem: Passing different parameters

In most scenarios, the Debmentioning function will handle the repeat operation of a single object.

So can Debounce handle multiple objects?

Going back to the ResizeObserver example, where we listened for a single element, we now add multiple elements.

<div class="container">
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
</div>
Copy the code
.container {
  display: flex;
  overflow: auto;
  resize: both;
  width: 800px;
  border: 1px solid # 333;
}
.box {
  width: 100px;
  height: 100px;
  background-color: green;
  margin: 10px;
  flex: 1;
}

.box::after {
  content: attr(data-size);
}
Copy the code
// omit duplicate code...

const debouncedResizeCallback = _.debounce(resizeCallback, 800);

const resizeObserver = new ResizeObserver((entries) = > {
  for (const entry ofentries) { debouncedResizeCallback(entry.target); }});document.querySelectorAll(".box").forEach((el) = > {
  resizeObserver.observe(el);
});
Copy the code

Codepen. IO/curly210102…

For clarity, there is a flexbox container wrapped around the element to resize multiple elements at the same time.

Resize the container so that all elements have changed but only the last element shows the changed width.

It’s not complicated to explain. Multiple objects correspond to a single debouncedResizeCallback. DebouncedResizeCallback is called only once, and the last object to call it happens to be the last.

Solution: Memoize

It’s easy to solve, just give each element a debouncedResizeCallback.

const memories = new WeakMap(a);const debouncedResizeCallback = (obj) = > {
  if(! memories.has(obj)) { memories.set(obj, _.debounce(resizeCallback,800));
  }
  memories.get(obj)(obj);
};
Copy the code

Simplified writing using lodash implementation

const debouncedResizeCallback = _.wrap(
  _.memoize(() = > _.debounce(resizeCallback)),
  (getMemoizedFunc, obj) = > getMemoizedFunc(obj)(obj)
);
Copy the code

Wrap Creates a wrapper function with the wrapper function body as the second argument. Substitute the first argument as the first argument to the wrapper function, which is to bring _.memoize(…) The section is brought into the function as getMemoizedFunc, equivalent to

const debouncedResizeCallback = (obj) = >
  _.memoize(() = > _.debounce(resizeCallback))(obj)(obj);
Copy the code

Memoize will return a cache creation/reading function, and _.memoize(() => _.debounce(resizeCallback))(obj) reading will return the debounce function corresponding to OBj.

Codepen. IO/curly210102…

This example on StackOverflow is another application scenario for the Memoize solution.

// Avoid frequent updates to the same id
function save(obj) {
  console.log("saving", obj.name);
  // syncToServer(obj);
}

const saveDebounced = _.wrap(
  _.memoize(() = > _.debounce(save), _.property("id")),
  (getMemoizedFunc, obj) = > getMemoizedFunc(obj)(obj)
);

saveDebounced({ id: 1.name: "Jim" });
saveDebounced({ id: 2.name: "Jane" });
saveDebounced({ id: 1.name: "James" });
/ / - saving James
/ / - saving Jane
Copy the code

To sum up, the memoize method is essentially assigning debmentioning function to each object.

Solution: With cache

The memoize method will produce a separate debwriting function, which will also have some drawbacks. If the Debounce package is a heavy operation, a single call involves a lot of recalculation and rerendering. Being able to gather calls to multiple objects into a single calculation and rendering can help optimize performance.

A collection is a cache space that records the called object.

function getDebounceWithCache(callback, ... debounceParams) {
  const cache = new Set(a);const debounced = _.debounce(function () {
    callback(cache);
    cache.clear();
  }, ...debounceParams);

  return (items) = > {
    items.forEach((item) = > cache.add(item));
    debounced(cache);
  };
}

const debouncedResizeCallback = getDebounceWithCache(resizeCallback, 800);

function resizeCallback(targets) {
  targets.forEach((target) = > {
    const sizeInfo = getElementSizeInfo(target);
    target.dataset.size = sizeInfo.offsetWidth;
  });
  // ...
}

const resizeObserver = new ResizeObserver((entries) = > {
  debouncedResizeCallback(entries.map((entry) = > entry.target));
});

document.querySelectorAll(".box").forEach((el) = > {
  resizeObserver.observe(el);
});
Copy the code

Codepen. IO/curly210102…

In summary, the with Cache method essentially collects the called object as a whole and applies the whole to the final operation.

Common problem: Correct use of debounce in React components

Defining the debmentioning function in the React component requires attention to uniqueness.

Class component has no pit, can be defined as instance property

import debounce from "lodash.debounce";
import React from "react";

export default class Component extends React.Component {
  state = {
    value: ""};constructor(props) {
    super(props);
    this.debouncedOnUpdate = debounce(this.onUpdate, 800);
  }

  componentWillUnmount() {
    this.debouncedOnUpdate.cancel();
  }

  onUpdate = (e) = > {
    const target = e.target;
    const value = target.value;
    this.setState({
      value,
    });
  };

  render() {
    return (
      <div className="App">
        <input onKeyDown={this.debouncedOnUpdate} />
        <p>{this.state.value}</p>
      </div>); }}Copy the code

Function components need to avoid re-declarations in re-render

import debounce from "lodash.debounce";
import React from "react";

export default function Component() {
  const [value, setValue] = React.useState("");
  const debouncedOnUpdateRef = React.useRef(null);

  React.useEffect(() = > {
    const onUpdate = (e) = > {
      const target = e.target;
      const value = target.value;
      setValue(value);
    };
    debouncedOnUpdateRef.current = debounce(onUpdate, 800);

    return () = >{ debouncedOnUpdateRef.current.cancel(); }; } []);return (
    <div className="App">
      <input onKeyDown={debouncedOnUpdateRef.current} />
      <p>{value}</p>
    </div>
  );
}
Copy the code

To implement adebounce

Here is a step-by-step implementation of Debounce using the LoDash source code as a reference.

Basic implementation

Start with an intuitive test scenario

Codepen. IO/curly210102…

Implement the base debounce function using clearTimeout + setTimeout, calling debounce → regenerating the timer → terminating the function on time.

function debounce(
  func,
  wait = 0,
  options = {
    leading: false,
    trailing: true,
    maxWait: 0,}) {
  let timer = null;
  let result = null;

  function debounced(. args) {
    if (timer) {
      clearTimeout(timer);
    }
    timer = setTimeout(() = > {
      result = func.apply(this, args);
    }, wait);

    return result;
  }

  debounced.cancel = () = > {};
  debounced.flush = () = > {};

  return debounced;
}
Copy the code

Performance optimization

ClearTimeout + setTimeout does quickly implement basic functionality, but there is room for optimization.

For example, if you have an array, add elements by debouncedSort. By adding 100 consecutive elements, the debmentioning function will need to unload and reinstall the timer 100 times in a short time. There’s not much point in reinventing the wheel. In fact, a timer can do it.

Set a timer that runs its course undisturbed by the trigger operation. Calculate the difference between the expected duration and the actual duration, and set the next timer according to the difference.

function debounce(
  func,
  wait = 0,
  options = {
    leading: false,
    trailing: true,
    maxWait: 0,}) {
  let timer = null;
  let result = null;
  let lastCallTime = 0;
  let lastThis = null;
  let lastArgs = null;

  function later() {
    const last = Date.now() - lastCallTime;

    if (last >= wait) {
      result = func.apply(lastThis, lastArgs);
      timer = null;
    } else {
      timer = setTimeout(later, wait - last); }}function debounced(. args) {
    lastCallTime = Date.now();
    if(! timer) { timer =setTimeout(later, wait);
    }

    return result;
  }

  debounced.cancel = () = > {};
  debounced.flush = () = > {};

  return debounced;
}
Copy the code

Codepen. IO/curly210102…

Optimization and performance reference since modernjavascript.blogspot.com/2013/08/bui images…

Realize the options

Implement leading and trailing, find the “start” and “end” points in the code, and add the leading and trailing conditions.

Note that when both leading and trailing are true, the end is called only if the action is triggered multiple times during the wait. This is done by logging the most recent parameter, lastArgs.

function debounce(
  func,
  wait = 0,
  options = {
    leading: false,
    trailing: true,
    maxWait: 0,}) {
  const leading = options.leading;
  const trailing = options.trailing;
  const hasMaxWait = "maxWait" in options;
  const maxWait = Math.max(options.maxWait, wait);
  let timer = null;
  let result = null;
  let lastCallTime = 0;
  let lastThis = null;
  let lastArgs = null;

  function later() {
    const last = Date.now() - lastCallTime;

    if (last >= wait) {
      / / end
      timer = null;
      // Use lastArgs to determine if there are multiple triggers, leading: true, trailing: true
      // This may be null/undefined cannot be used as a judgment object
      if (trailing && lastArgs) {
        invokeFunction();
      }
      lastArgs = lastThis = null;
    } else {
      timer = setTimeout(later, wait - last); }}function invokeFunction() {
    const thisArg = lastThis;
    const args = lastArgs;
    lastArgs = lastThis = null;
    result = func.apply(thisArg, args);
  }

  function debounced(. args) {
    lastCallTime = Date.now();
    lastThis = this;
    lastArgs = args;
    if (timer === null) {
      / / the start
      timer = setTimeout(later, wait);
      if(leading) { invokeFunction(); }}return result;
  }

  debounced.cancel = () = > {};
  debounced.flush = () = > {};

  return debounced;
}
Copy the code

Codepen. IO/curly210102…

We then implement maxWait, which refers to the maximum interval between func executions.

To implement maxWait, determine:

  • How to judge reachedmaxWait
  • When to judge

How to judge reached?

Record the time lastInvokeTime when func was first invoked and last executed, judging date.now () -lastInvokeTime >= maxWait

When to judge?

MaxWait attempts to modify existing timers by using them to watch.

Since maxWait is greater than or equal to WAIT, the initial timer can use WAIT directly, but the duration of subsequent timers must take into account maxWait remaining time.

function remainingWait(time) {
  // Remaining waiting time
  const remainingWaitingTime = wait - (time - lastCallTime);
  // 'func' The remaining time of the maximum delay
  const remainingMaxWaitTime = maxWait - (time - lastInvokeTime);

  return hasMaxWait
    ? Math.min(remainingWaitingTime, remainingMaxWaitTime)
    : remainingWaitingTime;
}
Copy the code

At the same time, this change also brings a problem, the original delay was the remainingWait time, but now it becomes remainingWait. Three situations arise:

  • Case one: The timer callback iswaitpoint-to-point
  • Case 2: The timer callback ismaxWaitpoint-to-point

Note that the last case, trailing: false, does not execute func when maxWait reaches the point. Because at this point there is no way to determine if there are any subsequent operations, there is no way to determine if the most recent operation is trailing (the trailing: false tail is not executed), and you have to wait passively for the next call before executing func.

Focus on the two pointcuts for implementing func: “Calling debmentioning function” and “timer callback”.

Two pointcuts do not have absolute sequential relationship, can be said to operate in parallel with each other. One pointcut executes func and the other should skip execution. So add shouldInvoke judgment to all of them to avoid chaos.

function shouldInvoke(time) {
  return (
    time - lastCallTime >= wait ||
    (hasMaxWait && time - lastInvokeTime >= maxWait)
  );
}
Copy the code

The final code

function debounce(
  func,
  wait = 0,
  options = {
    leading: false,
    trailing: true,
    maxWait: 0,}) {
  const leading = options.leading;
  const trailing = options.trailing;
  const hasMaxWait = "maxWait" in options;
  const maxWait = Math.max(options.maxWait, wait);
  let timer = null;
  let result = null;
  let lastCallTime = 0;
  let lastInvokeTime = 0;
  let lastThis = null;
  let lastArgs = null;

  function later() {
    const time = Date.now();

    if (shouldInvoke(time)) {
      / / end
      timer = null;
      if (trailing && lastArgs) {
        invokeFunction(time);
      }
      lastArgs = lastThis = null;
    } else {
      timer = setTimeout(later, remainingTime(time));
    }
    return result;
  }

  function shouldInvoke(time) {
    return (
      time - lastCallTime >= wait ||
      (hasMaxWait && time - lastInvokeTime >= maxWait)
    );
  }

  function remainingTime(time) {
    const last = time - lastCallTime;
    const lastInvoke = time - lastInvokeTime;
    const remainingWaitingTime = wait - last;

    return hasMaxWait
      ? Math.min(remainingWaitingTime, maxWait - lastInvoke)
      : remainingWaitingTime;
  }

  function invokeFunction(time) {
    const thisArg = lastThis;
    const args = lastArgs;
    lastInvokeTime = time;
    lastArgs = lastThis = null;
    result = func.apply(thisArg, args);
  }

  function debounced(. args) {
    const time = Date.now();
    const needInvoke = shouldInvoke(time);
    lastCallTime = time;
    lastThis = this;
    lastArgs = args;

    if (needInvoke) {
      if (timer === null) {
        lastInvokeTime = time;
        timer = setTimeout(later, wait);
        if (leading) {
          invokeFunction(time);
        }
        return result;
      }
      if (hasMaxWait) {
        timer = setTimeout(later, wait);
        invokeFunction(time);
        returnresult; }}if (timer === null) {
      timer = setTimeout(later, wait);
    }
    return result;
  }

  debounced.cancel = () = > {};
  debounced.flush = () = > {};

  return debounced;
}
Copy the code

Codepen. IO/curly210102…

Just to be briefthrottle

Debounce is used for shock prevention and throttle for throttling.

Throttling is performed at most once in a specified period of time.

Simple throttling implementation

function throttle(fn, wait) {
  let lastCallTime = 0;
  return function (. args) {
    const time = Date.now();
    if (time - lastCallTime >= wait) {
      fn.apply(this, args);
    }
    lastCallTime = time;
  };
}
Copy the code
function throttle(fn, wait) {
  let isLocked = false;
  return function (. args) {
    if(! isLocked) {return;
    }

    isLocked = true;
    fn.apply(this, args);
    setTimeout(() = > {
      isLocked = false;
    }, wait);
  };
}
Copy the code

Lodash uses Debounce for throttle

_.throttle(
  func,
  [(wait = 0)],
  [
    (options = {
      leading: true.// Whether to execute before throttling starts
      trailing: true.// Whether to execute after throttling ends}));Copy the code
function throttle(func, wait, options) {
  let leading = true;
  let trailing = true;

  if (typeoffunc ! = ="function") {
    throw new TypeError("Expected a function");
  }
  if (isObject(options)) {
    leading = "leading" inoptions ? !!!!! options.leading : leading; trailing ="trailing" inoptions ? !!!!! options.trailing : trailing; }return debounce(func, wait, {
    leading,
    trailing,
    maxWait: wait,
  });
}
Copy the code