雨山


Debouncing Vue Component Methods

2021-04-20


Hey! In this post, I'm going to jot down a solution I found to a little web development problem I was having today. This is mostly for my own notes, but maybe it'll be useful to you too!

So, my web app is built on Vue. Occasionally, it has made sense to use Lodash's debounce function to prevent expensive and/or asynchronous operations from being called multiple times in quick succession. In the past, that's worked very well for me, and I never realized that there could be any complications involved in such a use until yesterday. First, let me describe what I did and what went wrong. Consider a component with this definition:

const Vue = require("vue")

Vue.component("my-cool-component", {
  template: `
    <button @click="runExpensiveOperation">
      Run the expensive operation!
    </button>
  `,

  methods: {
    runExpensiveOperation: function(){
      // ...
    },
  },
})

In other words, when the button is clicked, the component runs an expensive operation. But since the user can potentially click the button multiple times in quick succession (and thus initiate the expensive operation multiple times very quickly), it makes sense to "debounce" the function so that it only gets executed once in a given timeframe, even if called multiple times during that timeframe. So, the easy way to do that is to wrap the expensive function in Lodash's debounce function, like this:

const Vue = require("vue")
const lodash = require("lodash")

Vue.component("my-cool-component", {
  ...

  methods: {
    runExpensiveOperation: lodash.debounce(function(){
      // ...
    }, 100),
  },
})

So, I've declared that this expensive operation shall only run 100 milliseconds after the last successive call (and then the timer will reset).

Now, this setup works great, but only if there's one instance of the <my-cool-component> component mounted. If there are multiple instances of the component mounted, then only one of them will run the expensive operation!

As a toy example, consider this component:

const Vue = require("vue")
const lodash = require("lodash")

Vue.component("random-number", {
  template: `
    <div v-html="x"></div>
  `,

  data: function(){
    return {
      x: 0,
    }
  },

  methods: {
    update: function(){
      this.x = Math.random()
    },
  },

  mounted: function(){
    this.update()
  },
})

This component generates a random number and displays it. Nothing too fancy. (Please forgive my use of the v-html directive in this component's template; I had to use it because my site generator tried to parse the double curly brace notation.)

And suppose that we use the above component in this app:

const Vue = require("vue")

const app = new Vue({
  el: "#app",

  template: `
    <div>
      <random-number></random-number>
      <random-number></random-number>
      <random-number></random-number>
      <random-number></random-number>
      <random-number></random-number>
    </div>
  `,
})

So, this app should display five random numbers, like this:

0.3485475563141829
0.27104351148962946
0.12110439321774624
0.08632019215212428
0.751542072380456

But let's pretend for a moment that the <random-number> component's update method is expensive. So, I'll debounce it:

const Vue = require("vue")
const lodash = require("lodash")

Vue.component("random-number", {
  ...

  methods: {
    update: lodash.debounce(function(){
      this.x = Math.random()
    }, 100),
  },

  ...
})

Suddenly, however, the app no longer works as I'd expect. I'd expect each component to update its x-value 100 milliseconds after being mounted. Instead, only one of the components updates itself, and the results look like this:

0
0
0
0
0.007477773277148825

What does make sense about this is that we're calling a function multiple times in quick succession, but it's only running once — which is exactly what debouncing is supposed to do. On the other hand, what doesn't make sense about this to me is that, in my mental model of the app, each component instance should have its own copy of the debounced update method that's completely separate from the copies owned by the other instances; in other words, one instance of the <random-number> component calling its update method should have no effect on the other instances of the component. But that's clearly not what's happening; instead, all of the instances seem to be sharing the same debounced update method!

Well, that's clearly problematic. This first explanation for this phenomenon that occurred to me was something like: well, we're passing a single object to the Vue.component method for registration, so maybe all of the component instances share in common the properties of that object. But that doesn't seem right, since the update method worked just fine before debouncing; i.e., each instance knew how to update its own x value; i.e., each instance referenced the correct this in its update method. So, this explanation might still be right — it might still be the case that all instances of a component share in common the properties of that one object that was passed into the registration function — but perhaps there's some magic going on behind the scenes to bind each method call to the right instance at execution time...or something like that.

I still don't know whether or not that explanation is right, but in any case, it helped me to find the solution to the problem. I figured that if some kind of binding was happening behind the scenes, then such a binding might help me to fix the debouncing problem. According to the MDN docs, Function.prototype.bind "creates a new function that, when called, has its this keyword set to the provided value, with a given sequence of arguments preceding any provided when the new function is called." I knew that Lodash's debounce function returned another function, so I speculated that the right thing to do would be to bind that returned (debounced) function to the component object. So, I changed the update method back to its non-debounced formulation, and then I performed the debouncing and binding in the mounted method:

const Vue = require("vue")
const lodash = require("lodash")

Vue.component("random-number", {
  ...

  methods: {
    update: function(){
      this.x = Math.random()
    },
  },

  mounted: function(){
    this.update = lodash.debounce(this.update, 100).bind(this)
    this.update()
  },
})

And that solved the problem! Hooray!