8
votes

I have certain functions that I need to be able to call when a vue component is destroyed, but I don't necessarily know what they are before the event is created.

Is there a way to dynamically add a listener to the vue lifecycle events?

What I'm trying to achieve:

...methods: {
    logOnDestroy(txt) {
        this.$on('beforeDestroy', () => {
            console.log(txt)
        }
    }
}

But that is not currently being called. Is there a different way to programatically bind listeners to component lifecycle events at runtime?

6

6 Answers

8
votes

You might ask, can it be simpler?

From Vue.js Component Hooks as Events, this is the syntax you are looking for

this.$once('hook:beforeDestroy', () => {

I'm not sure how exactly you intend to make it dynamic, but here is an adaption of your logOnDestroy() method in Vue CLI's default HelloWorld app,

Demo

Vue.component('helloworld', {
  template: '<h1>{{ msg }}</h1>',
  name: 'helloworld',
  props: { msg: String },
  mounted() {
    this.logOnDestroy('Goodbye HelloWorld')
  },
  methods: {
    logOnDestroy(txt) {
      this.$once('hook:beforeDestroy', () => {
        console.log(txt)
      })
    }    
  }
});

new Vue({
  el: '#app',
  data: {
    showHello: true
  },
  mounted() {
    setTimeout(() => {
      this.showHello = false
    }, 3000)
  }
});
Vue.config.productionTip = false
Vue.config.devtools = false
<script src="https://unpkg.com/vue"></script>
<div id="app">
  <img alt="Vue logo" src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Vue.js_Logo_2.svg/277px-Vue.js_Logo_2.svg.png" style="height: 50px;">
  <helloworld v-if="showHello" msg="Welcome to Your Vue.js App"/>
</div>
4
votes

An array of handlers for each life-cycle event is stored in the this.$options object. You can add a handler by pushing to the corresponding array (you'll need to create the array first if there are no handlers set already):

new Vue({
  el: '#app',
  created() {
    if (!this.$options.mounted) {
      this.$options.mounted = [];
    }
  
    this.$options.mounted.push(() => {
      console.log('mounted')
    });
    
    this.$options.mounted.push(() => {
      console.log('also mounted')
    });
  }
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.13/vue.min.js"></script>

<div id="app">
  <div></div>
</div>

So in your case:

methods: {
  logOnDestroy(txt) {
    if (!this.$options.beforeDestroy) {
      this.$options.beforeDestroy = [];
    }

    this.$options.beforeDestroy.push(() => {
      console.log(txt)
    });
  }
}
3
votes

Jan 2019 - A warning about the answer to this question - I found a subtle problem when using this code to add dynamic listeners.

this.$options.beforeDestroy.push(() => {
  console.log(txt)
});

It works ok when there is no static beforeDestroy defined. In this case the handlers array is a direct property of $options.

But if you define a static beforeDestroy hook on the component, the handlers array is property of $options.__proto__, which means multiple instances of the component inherit dynamic handlers of previous instances (effectively, the above code modifies the template used to create successive instances).

How much of a practical problem this is, I'm not sure. It looks bad because the handler array gets bigger as you navigate the app (e.g switching pages adds a new function each time).


A safer way of adding dynamic handlers is to use this injectHook code, which is used by Vue for hot module reload (you can find it in index.js of a running Vue app). Note, I am using Vue CLI 3.

function injectHook(options, name, hook) {
  var existing = options[name]
  options[name] = existing
    ? Array.isArray(existing) ? existing.concat(hook) : [existing, hook]
    : [hook]
}
...
injectHook(this.$options, 'beforeDestroy', myHandler)

What happens here is a new array is created on the instance which contains all the handlers from __proto__, plus the new one. The old array still exists (unmodified), and the new array is destroyed along with the instance, so there is no build-up of handlers in __proto__ handler array.

1
votes

One simple solution would be to simply track all dynamic handlers that you want to add inside the component:

Vue.component('foo', {
  template: '<div>foo component</div>',
  data() {
    return {
      beforeDestroyHandlers: []
    }
  },
  created() {
    this.beforeDestroyHandlers.push(() => {
      console.log('new handler called');
    });
  },
  beforeDestroy() {
    this.beforeDestroyHandlers.forEach(handler => handler());
  }
});

new Vue({
  el: '#app',
  data: {
    includeComponent: false
  }
});
<script src="https://unpkg.com/vue"></script>

<div id="app">
  <button v-on:click="includeComponent = true">Add component</button>
  <button v-on:click="includeComponent = false">Destroy component</button>
  <foo v-if="includeComponent"></foo>
</div>
0
votes

Eric99's answer is instructive, but I feel there is something easier than fiddling with Vue's internals to do to achieve the same goal: use an event on the component. Easier to read, shorter to write.

Vue.component('foo', {
  methods {
    logOnDestroy (txt) {
      this.$on('beforeDestroy', () => {
        console.log(txt)
      })
    }
  },
  beforeDestroy () {
    this.$emit('beforeDestroy');
  }
});

You might ask: do I need to call this.$off() after I emit my event to prevent memory leaks? The answer is no, Vue does this for you automatically, so the above code is fine.

0
votes

The Eric99's answer is excellent but it does not work for in-component navigation guards like beforeRouteLeave, beforeRouteUpdate, beforeRouteEnter - they are maintained by VueRouter (and collected at the time of the compilation of the corresponding component). Our life is made even more difficult by the fact that Vue-Router caches the constructor of the component - and uses it instead of the actual component instance.

To solve the issue I had to dig into the VueRouter's internals but I came up with the following code ( _Ctor is the cached constructor - I have not checked whether using just it is enough, so to be safe I use both the component definition and the constructor):

const routeComponent = this.$route.component;
injectHook([
  routeComponent, 
  routeComponent._Ctor && routeComponent._Ctor[0] 
    ? routeComponent._Ctor[0].options 
    : null
], 'beforeRouteLeave', this.hookFunction);

function injectHook(routeComponentInstance, hookName, hookFunction)
{
  (Array.isArray(routeComponentInstance) 
    ? routeComponentInstance 
    : [routeComponentInstance]
  ).forEach(instance =>
  {
    if (instance)
    {
      const existing = instance[hookName];
      if (existing && Array.isArray(existing))
      {
        const index = existing.findIndex(item => item === hookFunction);
        if (index < 0) return;
      }
      instance[hookName] = existing
        ? Array.isArray(existing) ? existing.concat(hookFunction) : [existing, hookFunction]
        : [hookFunction];
    }
  });
}

function removeHook(routeComponentInstance, hookName, hookFunction)
{
  (Array.isArray(routeComponentInstance) 
    ? routeComponentInstance 
    : [routeComponentInstance]
  ).forEach(instance =>
  {
    if (instance)
    {
      const existing = instance[hookName];
      if (existing && Array.isArray(existing))
      {
        const index = existing.findIndex(item => item === hookFunction);
        if (index !== -1) existing.splice(index, 1);
      }
    }
  });
}