Would you like to buy me a ☕️ instead?
Ideally, Vue components are self-contained pieces of UI without any observable side effects to matters outside the component’s scope. But unfortunately, that’s not always possible. For example, sometimes, we need to bind global event listeners, use setInterval
, or initialize a particular third-party library inside of a component.
❌
// Vue 3
export default defineComponent({
name: 'SomeComponent',
setup() {
// Global event listener
document.body.addEventListener('click', () => {
// do something expensive ...
}, { capture: true });
// Interval
setInterval(() => {
// do something expensive ...
}, 2000);
// Third-party library
let flatpickrElement = ref(null);
onMounted(() => {
flatpickr(flatpickrElement.value);
});
// ...
},
});
// Vue 2
export default {
name: 'SomeComponent',
created() {
// Global event listener
document.body.addEventListener('click', () => {
// do something expensive ...
}, { capture: true });
// Interval
setInterval(() => {
// do something expensive ...
}, 2000);
},
mounted() {
// Third-party library
flatpickr(this.$refs.flatpickrElement);
},
};
In cases like that, components must clean up after they are destroyed. If we don’t do this, all kinds of nasty things can happen, ranging from the malfunction of our apps to memory leaks.
Removing Global Event Listeners, Clearing Intervals, and Cleaning up Third-party Libraries
Let’s update our demo from above by adding hooks for cleaning up the global side effects our component causes.
✅
// Vue 3
export default defineComponent({
name: 'SomeComponent',
setup() {
// Global event listener
let options = { capture: true };
let callback = () => {
// do something expensive ...
};
document.body.addEventListener('click', callback, options);
onUnmounted(() => document.body.removeEventListener('click', callback, options));
// Interval
let intervalId = setInterval(() => {
// do something expensive ...
}, 2000);
onUnmounted(() => clearInterval(intervalId));
// Third-party library
let flatpickrElement = ref(null);
let flatpickrInstance;
onMounted(() => {
flatpickrInstance = flatpickr(flatpickrElement.value);
});
onUnmounted(() => flatpickrInstance.destroy());
// ...
},
});
// Vue 2
export default {
name: 'SomeComponent',
created() {
// Global event listener
let options = { capture: true };
let callback = () => {
// do something expensive ...
};
document.body.addEventListener('click', callback, options);
this.$once('hook:beforeDestroy', () => document.body.removeEventListener('click', callback, options));
// Interval
let intervalId = setInterval(() => {
// do something expensive ...
}, 2000);
this.$once('hook:beforeDestroy', () => clearInterval(intervalId));
// Third-party library
let flatpickrInstance;
this.$once('hook:mounted', () => {
flatpickrInstance = flatpickr(this.$refs.flatpickrElement);
});
this.$once('hook:beforeDestroy', () => flatpickrInstance.destroy());
},
};
Every time our component is destroyed, the corresponding hooks are called, and the global side effects are cleaned up. That way, we don’t need to worry about memory leaks or global event listeners stacking up with every new component instance we create.
Do you want to learn more about advanced Vue.js techniques?
Register for the Newsletter of my upcoming book: Advanced Vue.js Application Architecture.
beforeDestroy
and onUnmounted
Hooks in @vue/test-utils Tests
One of my colleagues discovered that when testing components with the excellent @vue/test-utils
package, the beforeDestroy
and onUnmounted
hooks are not called after a test! Although I didn’t expect it to be that way, this is by design. It is not a problem most of the time, but in some cases, this can lead to unexpected behavior with test cases interfering with each other because of a polluted global scope.
test('It should make magic happen.', () => {
const wrapper = mount(SomeComponent);
// ...
expect(magicHappened).toBe(true);
// Vue 3.
wrapper.unmount();
// Vue 2.
wrapper.destroy();
});
Using this straightforward solution in those rare cases when this is a factor is usually fine. But people can easily forget it, so I prefer a more general solution.
I consider it a best practice to wrap third-party dependencies, and @vue/test-utils
is no different. Doing so enables us to set default options that make sense for our application globally.
// Vue 3
// test/utils.js
import { merge } from 'lodash';
import {
mount as vueTestUtilsMount,
} from '@vue/test-utils';
let defaultOptions = {
global: {
mocks: {
// Mocked plugins
$t: input => input,
},
},
// ...
};
export function mount(component, customOptions = {}) {
let options = merge({}, defaultOptions, customOptions);
return vueTestUtilsMount(component, options);
}
Furthermore, having a custom wrapper module for @vue/test-utils
gives us the perfect place to configure a global behavior like this. Luckily, @vue/test-utils
for Vue 2 has a built-in helper function that makes it very straightforward to trigger the beforeDestroy
hook for every component initialized during testing.
// Vue 2
// test/utils.js
import { merge } from 'lodash';
import {
mount as vueTestUtilsMount,
enableAutoDestroy,
} from '@vue/test-utils';
// See: https://vue-test-utils.vuejs.org/api/#enableautodestroy-hook
enableAutoDestroy(afterEach);
let defaultOptions = {
mocks: {
// Mocked plugins
$t: input => input,
},
// ...
};
export function mount(component, customOptions = {}) {
let options = merge({}, defaultOptions, customOptions);
return vueTestUtilsMount(component, options);
}
Unfortunately, it seems that this hook has been removed in @vue/test-utils
for Vue 3. So we need to implement the functionality ourselves.
// Vue 3
// test/utils.js
import { merge } from 'lodash';
import {
mount as vueTestUtilsMount,
} from '@vue/test-utils';
let defaultOptions = {
global: {
mocks: {
// Mocked plugins
$t: input => input,
},
},
// ...
};
let wrappers = new Set();
afterEach(() => {
wrappers.forEach(wrapper => wrapper.unmount());
wrappers.clear();
});
export function mount(component, customOptions = {}) {
let options = merge({}, defaultOptions, customOptions);
let wrapper = vueTestUtilsMount(component, options);
wrappers.add(wrapper);
return wrapper;
}
Wrapping It Up
What humanity is experiencing at a global scale right now is also true in programming. If we make a mess without taking responsibility for cleaning up after ourselves, bad things will happen.