Vue components are great, right? They encapsulate the view and behavior of your app into nice little composable pieces. If you need a little extra functionality on them, just attach directives! Thing is, directives are fairly inflexible and can’t do everything. Directives can’t (easily) emit events, for example. Well, this being Vue, of course there’s a solution. Abstract components!
Abstract components are like normal components, except they don’t render anything to the DOM. They just add extra behavior to existing ones. You might be familiar with abstract components from Vue’s built-in ones, such as <transition>
, <component>
, and <slot>
.
A great use-case for abstract components is tracking when an element enters the viewport with IntersectionObserver
. Let’s take a look at implementing a simple abstract component to handle that here.
If you’d like a proper production-ready implementation of this, take a look at vue-intersect, which this tutorial is based on.
First we’ll create a quick abstract component that simply renders its contents. To accomplish this, we’ll take a quick dip into render functions.
export default {
// Enables an abstract component in Vue.
// This property is undocumented and may change at any time,
// but your component should work without it.
abstract: true,
// Yay, render functions!
render() {
// Without using a wrapper component, we can only render one child component.
try {
return this.$slots.default[0];
} catch (e) {
throw new Error('IntersectionObserver.vue can only render one, and exactly one child component.');
}
return null;
}
}
Congratulations! You now have an abstract component that, well, does nothing! It just renders its children.
Okay, now let’s stick in the logic for IntersectionObserver
.
IntersectionObserver
isn’t supported natively in IE or Safari, so you might want to go grab a polyfill for it.
export default {
// Enables an abstract component in Vue.
// This property is undocumented and may change at any time,
// but your component should work without it.
abstract: true,
// Yay, render functions!
render() {
// Without using a wrapper component, we can only render one child component.
try {
return this.$slots.default[0];
} catch (e) {
throw new Error('IntersectionObserver.vue can only render one, and exactly one child component.');
}
return null;
},
mounted () {
// There's no real need to declare observer as a data property,
// since it doesn't need to be reactive.
this.observer = new IntersectionObserver((entries) => {
this.$emit(entries[0].isIntersecting ? 'intersect-enter' : 'intersect-leave', [entries[0]]);
});
// You have to wait for the next tick so that the child element has been rendered.
this.$nextTick(() => {
this.observer.observe(this.$slots.default[0].elm);
});
}
}
Alright, so now we have an abstract component we can use like this:
<intersection-observer @intersect-enter="handleEnter" @intersect-leave="handleLeave">
<my-honest-to-goodness-component></my-honest-to-goodness-component>
</intersection-observer>
We’re not done yet though…
We need to make sure not to leave any dangling IntersectionObservers
when the component is removed from the DOM, so let’s fix that real quick now.
export default {
// Enables an abstract component in Vue.
// This property is undocumented and may change at any time,
// but your component should work without it.
abstract: true,
// Yay, render functions!
render() {
// Without using a wrapper component, we can only render one child component.
try {
return this.$slots.default[0];
} catch (e) {
throw new Error('IntersectionObserver.vue can only render one, and exactly one child component.');
}
return null;
},
mounted() {
// There's no real need to declare observer as a data property,
// since it doesn't need to be reactive.
this.observer = new IntersectionObserver((entries) => {
this.$emit(entries[0].isIntersecting ? 'intersect-enter' : 'intersect-leave', [entries[0]]);
});
// You have to wait for the next tick so that the child element has been rendered.
this.$nextTick(() => {
this.observer.observe(this.$slots.default[0].elm);
});
},
destroyed() {
// Why did the W3C choose "disconnect" as the method anyway?
this.observer.disconnect();
}
}
And just for bonus points, let’s make the observer threshold configurable with props.
export default {
// Enables an abstract component in Vue.
// This property is undocumented and may change at any time,
// but your component should work without it.
abstract: true,
// Props work just fine in abstract components!
props: {
threshold: {
type: Array
}
},
// Yay, render functions!
render() {
// Without using a wrapper component, we can only render one child component.
try {
return this.$slots.default[0];
} catch (e) {
throw new Error('IntersectionObserver.vue can only render one, and exactly one child component.');
}
return null;
},
mounted() {
// There's no real need to declare observer as a data property,
// since it doesn't need to be reactive.
this.observer = new IntersectionObserver((entries) => {
this.$emit(entries[0].isIntersecting ? 'intersect-enter' : 'intersect-leave', [entries[0]]);
}, {
threshold: this.threshold || 0
});
// You have to wait for the next tick so that the child element has been rendered.
this.$nextTick(() => {
this.observer.observe(this.$slots.default[0].elm);
});
},
destroyed() {
// Why did the W3C choose "disconnect" as the method anyway?
this.observer.disconnect();
}
}
The final usage looks like this:
<intersection-observer @intersect-enter="handleEnter" @intersect-leave="handleLeave" :threshold="[0, 0.5, 1]">
<my-honest-to-goodness-component></my-honest-to-goodness-component>
</intersection-observer>
There you go! Your first abstract component.
Big thanks to Thomas Kjærgaard / Heavyy for the initial implementation and idea!
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
While we believe that this content benefits our community, we have not yet thoroughly reviewed it. If you have any suggestions for improvements, please let us know by clicking the “report an issue“ button at the bottom of the tutorial.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!