Strange Problems and Solutions of JavaScript Anti shake and Throttling

  • 2021-09-24 21:09:56
  • OfStack

Scene

There are already a lot of articles about anti-shake and throttling on the Internet. Why should we write another one? As a matter of fact, anti-shake and throttling, we found some strange problems in use, and after several modifications, here we mainly share the problems we encountered and how to solve them.

Why use anti-shake and throttling?

Because some functions are triggered/called too quickly, we need to manually limit the frequency of their execution. For example, the common event of listening to scroll bars, if there is no anti-shake processing, and if the time spent on each function execution exceeds the interval between triggers, the page will jam.

Evolution

Initial implementation

We first implement a simple dejitter function


function debounce(delay, action) {
 let tId
 return function(...args) {
  if (tId) clearTimeout(tId)
  tId = setTimeout(() => {
   action(...args)
  }, delay)
 }
}

Test 1


//  Use  Promise  Simple encapsulation  setTimeout , the same below 
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
 let num = 0
 const add = () => ++num

 add()
 add()
 console.log(num) // 2

 const fn = debounce(10, add)
 fn()
 fn()
 console.log(num) // 2
 await wait(20)
 console.log(num) // 3
})()

Well, it seems that the basic effect has been realized. The wrapped function fn is called twice, but it is not executed immediately, but waits for the time interval to pass before finally executing once.

What about this?

However, the above implementation has a fatal problem and does not handle this! You may not feel it when you use it for native event handling, but when you use this-sensitive code such as ES6 class, you will definitely encounter problems caused by this.

For example, the following uses class to declare 1 counter


class Counter {
 constructor() {
  this.i = 0
 }
 add() {
  this.i++
 }
}

We may want to add a new attribute fn to constructor


class Counter {
 constructor() {
  this.i = 0
  this.fn = debounce(10, this.add)
 }
 add() {
  this.i++
 }
}

Unfortunately, the this binding here is problematic. Try the following code


const counter = new Counter()
counter.fn() // Cannot read property 'i' of undefined

An exception is thrown Cannot read property 'i' of undefined The reason is that this Without binding, we can bind manually this .bind(this)


class Counter {
 constructor() {
  this.i = 0
  this.fn = debounce(10, this.add.bind(this))
 }
 add() {
  this.i++
 }
}

But a better way is to modify debounce That enables it to bind automatically this


function debounce(delay, action) {
 let tId
 return function(...args) {
  if (tId) clearTimeout(tId)
  tId = setTimeout(() => {
   action.apply(this, args)
  }, delay)
 }
}

Then, the code will run as expected


;(async () => {
 class Counter {
  constructor() {
   this.i = 0
   this.fn = debounce(10, this.add)
  }
  add() {
   this.i++
  }
 }

 const counter = new Counter()
 counter.add()
 counter.add()
 console.log(counter.i) // 2

 counter.fn()
 counter.fn()
 console.log(counter.i) // 2
 await wait(20)
 console.log(counter.i) // 3
})()

What about the return value?

I don't know if you have found out, but use it now debounce Wrapped functions have no return value, which is a function with only side effects. However, we still encounter scenarios where we need to return values.
For example, after input stops, use Ajax to request background data to determine whether the same data already exists.

Modify debounce An implementation that caches the results of the last execution and has initial result parameters


function debounce(delay, action, init = undefined) {
 let flag
 let result = init
 return function(...args) {
  if (flag) clearTimeout(flag)
  flag = setTimeout(() => {
   result = action.apply(this, args)
  }, delay)
  return result
 }
}

The calling code becomes


;(async () => {
 class Counter {
  constructor() {
   this.i = 0
   this.fn = debounce(10, this.add, 0)
  }
  add() {
   return ++this.i
  }
 }

 const counter = new Counter()

 console.log(counter.add()) // 1
 console.log(counter.add()) // 2

 console.log(counter.fn()) // 0
 console.log(counter.fn()) // 0
 await wait(20)
 console.log(counter.fn()) // 3
})()

It looks perfect? However, not considering asynchronous functions is a big failure!

Try the following test code


//  Use  Promise  Simple encapsulation  setTimeout , the same below 
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
 let num = 0
 const add = () => ++num

 add()
 add()
 console.log(num) // 2

 const fn = debounce(10, add)
 fn()
 fn()
 console.log(num) // 2
 await wait(20)
 console.log(num) // 3
})()
0

An exception is thrown fn(...).then is not a function Because our wrapped function is synchronized, the value returned the first time is not of type Promise.

Unless we change the default value


//  Use  Promise  Simple encapsulation  setTimeout , the same below 
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
 let num = 0
 const add = () => ++num

 add()
 add()
 console.log(num) // 2

 const fn = debounce(10, add)
 fn()
 fn()
 console.log(num) // 2
 await wait(20)
 console.log(num) // 3
})()
1

Support asynchronous functions with return values

There are two ways to support asynchronism

Wrapping asynchronous functions as synchronous functions Make the wrapped function asynchronous

The first idea is realized


//  Use  Promise  Simple encapsulation  setTimeout , the same below 
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
 let num = 0
 const add = () => ++num

 add()
 add()
 console.log(num) // 2

 const fn = debounce(10, add)
 fn()
 fn()
 console.log(num) // 2
 await wait(20)
 console.log(num) // 3
})()
2

The calling mode and synchronous function are completely 1, of course, it supports asynchronous functions


;(async () => {
 const get = async i => i

 console.log(await get(1))
 console.log(await get(2))
 //  Note that the default value is changed to  Promise
 const fn = debounce(10, get, 0)
 console.log(fn(3)) // 0
 console.log(fn(4)) // 0
 await wait(20)
 console.log(fn(5)) // 4
})()

The second way of thinking is realized


//  Use  Promise  Simple encapsulation  setTimeout , the same below 
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
 let num = 0
 const add = () => ++num

 add()
 add()
 console.log(num) // 2

 const fn = debounce(10, add)
 fn()
 fn()
 console.log(num) // 2
 await wait(20)
 console.log(num) // 3
})()
4

Call mode supports asynchronous mode


//  Use  Promise  Simple encapsulation  setTimeout , the same below 
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
 let num = 0
 const add = () => ++num

 add()
 add()
 console.log(num) // 2

 const fn = debounce(10, add)
 fn()
 fn()
 console.log(num) // 2
 await wait(20)
 console.log(num) // 3
})()
5

As you can see, the problem with the first idea is that the return value will always be the old return value, and the main problem with the second idea is to wrap the synchronous function as asynchronous. Weighing the pros and cons, we think the second idea is more correct. After all, it is unlikely that the usage scenario itself must be a synchronous operation. Also, the original setTimeout is asynchronous, but you didn't realize it when you didn't need to return a value.

Avoid the loss of original function information

Later, someone raised a question. If the function carries other information, such as $like jQuery, it is a function, but it also contains other attributes. If you use debounce, you can't find it.

At first, we immediately thought of all the traversible properties on the copy function, and then thought of the Proxy feature of ES6, which is really magical. Using Proxy to solve this problem will be exceptionally simple because in addition to calling the function, other 1-cut operations still point to the original function!


//  Use  Promise  Simple encapsulation  setTimeout , the same below 
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
 let num = 0
 const add = () => ++num

 add()
 add()
 console.log(num) // 2

 const fn = debounce(10, add)
 fn()
 fn()
 console.log(num) // 2
 await wait(20)
 console.log(num) // 3
})()
6

Test 1


//  Use  Promise  Simple encapsulation  setTimeout , the same below 
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
 let num = 0
 const add = () => ++num

 add()
 add()
 console.log(num) // 2

 const fn = debounce(10, add)
 fn()
 fn()
 console.log(num) // 2
 await wait(20)
 console.log(num) // 3
})()
7

Realize throttling

A throttle function throttle is realized in this way


/**
 *  Function throttling 
 *  Throttling  (throttle)  Jean 1 Do not execute a function too frequently, reduce the execution of too fast calls, called throttling 
 *  Function dejitter similar to but different from the above ,  Wrapped function on 1 The second operation will be executed directly after the minimum interval has passed ,  Otherwise, the operation will be ignored 
 *  The obvious difference from the above function debounce is that the operation will be performed in a loop with the minimum interval time during continuous operation ,  Instead of just executing the last 1 Secondary operation 
 *  Note :  The function is the first 1 Secondary call 1 Will be implemented, don't worry about the first 1 If you can't get the cached value for the next time, you will get it for subsequent consecutive calls 1 Cached value of times 
 *  Note :  Higher-order functions that return function results need to use the  {@link Proxy}  Implementation to avoid information loss on the prototype chain of the original function 
 *
 * @param {Number} delay  Minimum interval time, in units  ms
 * @param {Function} action  Actions that really need to be performed 
 * @return {Function}  A function with throttling function after packaging. This function is asynchronous, and the function that needs to be wrapped  {@link action}  There is not much correlation between asynchronous and asynchronous 
 */
const throttle = (delay, action) => {
 let last = 0
 let result
 return new Proxy(action, {
  apply(target, thisArg, args) {
   return new Promise(resolve => {
    const curr = Date.now()
    if (curr - last > delay) {
     result = Reflect.apply(target, thisArg, args)
     last = curr
     resolve(result)
     return
    }
    resolve(result)
   })
  },
 })
}

Summarize

Well, in fact, the anti-shake and throttling here are still simple implementations, and other functions such as canceling anti-shake/forcing cache flushing have not yet been implemented. Of course, the function is enough for our generation, and it has also been put into the public function library rx-util.

The above is JavaScript anti-shake and throttling encountered strange problems and solve the details, more information about JavaScript anti-shake and throttling please pay attention to other related articles on this site!


Related articles: