We used intersection observer to add classes and lazy load images. Here are some final touches that may enhance those features.
Add output events
We already have a good directive to add classes, and if no classes are passed it still acts well. Let's add a couple of events to further its use.
// inview.directive
// Enhance with output events
@Output() onInview = new EventEmitter<void>();
@Output() onOutofview = new EventEmitter<void>();
// then emit in the proper places
private classChange(
//...
) {
const c = this.viewClasses;
if (entry.isIntersecting) {
//...
this.onInview.emit();
if (this.options.once) {
observer.disconnect();
}
} else {
//...
this.onOutofview.emit();
}
}
)
It can be used as following
<span crInview onIniew="callMeInView()" onOutofview="callMeOutofView()">something</span>
Enhancement 2: Exposure
Another enhancement we can afford is to expose the observe
and unobserve
functions. For this to work we need to promote a local member for the intersection observer (io
).
// add local member
private io: IntersectionObserver;
// set and use this.io instead of observer
// add a couple of functions
observe() {
this.io.observe(this.el.nativeElement);
}
unobserve() {
this.io.unobserve(this.el.nativeElement);
}
// inside class change, we gotta swap disconnect with unobserve
private classChange(...){
// ...
if (this.options.once) {
this.unobserve();
}
}
Note: I have used entry.target
interchangeably with this.el.nativeElement
, truth is, since we have a single observer per directive, they are the same, we only need entry
for the extra properties provided like isIntersecting
.
We need to export the directive to be able to use those methods.
// inview.directive
@Directive({
selector: '[crLazy]',
standalone: true,
// export to use
exportAs: 'crLazy',
})
To use, in our template:
<!--a box that is being observed-->
<div
crInview
#bluebox="crInview"
>
...content
</div>
Stop observing the box below, then start again <br />
<button (click)="bluebox.unobserve()">Stop observing</button>
<button (click)="bluebox.observe()">Start observing</button>
This looks thorough. Too thorough. I might never use this feature in my whole life. We'll stop here before it gets too slimy.
Bug: dynamic images change undetected
When an image's source changes dynamically, we need a way to restart observation. Bummer. Let's fix that. There are two ways to do that, one is the setter
of the main string (crLazy
). This involves another private
variable to keep track of, but I am not going into that direction when I have OnChanges
lifecycle hook.
// lazy.directive
export class LazyDirective implements AfterViewInit, OnChanges {
// add OnChanges event handler
ngOnChanges(c: SimpleChanges) {
if (c.crLazy.firstChange) {
// act normally
return;
}
if (c.crLazy.currentValue !== c.crLazy.previousValue) {
// start observing again
this.io.observe(this.el.nativeElement);
}
}
}
For this to work, we need to promote the intersection observer to be a private member (io
), and we can only use unobserve
, and never disconnect
.
// lazy.directive
// promote the io
private io: IntersectionObserver;
// then use this.io intead
ngAfterViewInit() {
this.io = new IntersectionObserver(
// ...
);
this.io.observe(this.el.nativeElement);
}
// then ngOnchanges
In StackBlitz lazy component, test that by clicking on the "change image" button. The image should change.
We could have also exposed the observe
and unobserve
methods, and let the consuming component handle it, or added a live option
for some images. But that is not always practical. The image could be sitting in a highly reusable component like a product card.
Bonus: fade in effect
Here is a nice effect to add to let the background image fade in when ready. This effect is 100% external to the directive. This is how we know we built a flexible directive.
/*add style to fade in an image when ready*/
.hero {
background: no-repeat center center;
background-size: cover;
background-color: rgba(0, 0, 0, 0.35);
background-blend-mode: overlay;
transition: background-color 0.5s ease-in-out;
/*unessential*/
color: #fff;
min-height: 40dvh;
display: flex;
align-items: center;
justify-content: center;
/* image set by code */
}
.hero-null {
background-color: black;
}
Then just use the directive with null
fall back
<div
class="hero"
crLazy="largeimage.png"
[options]="{ nullCss: 'hero-null' }"
>
Text on image
</div>
Have a look in StackBlitz lazy component.
Enhancement #3: Destroy
There is one cheap enhancement we should have thought about earlier, and that is to dispose the observer when the host element is destroyed.
// add onDestroy event handler for both directives
export class LazyDirective implements AfterViewInit, OnChanges, OnDestroy {
// ...
ngOnDestroy() {
this.io?.disconnect();
this.io = null;
}
}
Revisit a single observer
I'm not quite keen on creating solutions for probable problems, but having a single intersection observer per page is still eating at me. Placing the observer on the window level and passing the arguments in the target was okay. But I wanted to investigate a different approach, where we are aware of the observers. Where better to do that than a root service?
// experimental lazy/service
@Injectable({ providedIn: 'root' })
export class LazyService {
obs: { [key: string]: IntersectionObserver } = {};
newOb(cb: Function, id: string, threshold: number = 0) {
// create a new observer, if no id is passed, consider as unique
if (id && this.obs[id]) {
return this.obs[id];
}
const io = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => cb(entry));
},
{
threshold: threshold,
}
);
// save observer to reuse
if (id) {
this.obs[id] = io;
}
return io;
}
}
To use that, we only need to do the following
- Add
id
tooptions
- If
id
was passed, we return an existingobserver
previously created, or create new one and save it. Else we return the newobserver
without saving it. - We need to make sure the
nativeElement
is not used in thecallback
, thus the image source passed, must be saved as anattribute
of the element, so that we can recall it safely upon intersection. - We also need to set
options
as a property of the element (cannot set attribute to ajson
object). - With this approach, we cannot
disconnect
. But since theobserver
is saved on root, it is reused across multiple routes
// expiremental lazy/directive
// change this to have the target explicitly passed
private setImage(src: string, target: HTMLElement) {
if (target.tagName === 'IMG') {
this.renderer.setAttribute(target, 'src', src);
} else {
this.renderer.setAttribute(target, 'style', `background-image: url(${src})`);
}
}
// change this to pass the target
private lazyLoad(entry: IntersectionObserverEntry) {
// ...
if (entry.isIntersecting) {
// if IMG, change src
const img = new Image();
// get options saved
const options = entry.target['options'];
img.addEventListener('load', () => {
// pass target explicitly
this.setImage(img.src, <HTMLElement>entry.target);
// use options instead of this.options
this.renderer.removeClass(entry.target, options.nullCss);
// disconnect
this.io.unobserve(entry.target);
});
if (options.fallBack) {
img.addEventListener('error', () => {
this.setImage(options.fallBack, <HTMLElement>entry.target);
// unobserve
this.io.unobserve(entry.target);
});
}
// get the source from attribute
img.src = entry.target.getAttribute('shLazy');
}
}
// then change this to call the service
ngAfterViewInit() {
// ...
// this can have the nativeElement
this.setImage(this.options.initial, this.el.nativeElement);
// ...
// save the content in an attribute to retrieve when wanted
this.el.nativeElement.setAttribute('shLazy', this.shLazy);
// also save options
this.renderer.setProperty(this.el.nativeElement, 'options', this.options);
this.io = this.lazyService.newOb((entry) => {
this.lazyLoad(entry);
}, this.options.id, this.options.threshold);
this.io.observe(this.el.nativeElement);
}
ngOnChanges(c: SimpleChanges) {
if (c.shLazy.firstChange) {
return;
}
if (c.shLazy.currentValue !== c.shLazy.previousValue) {
// set attribute again
this.el.nativeElement.setAttribute('shLazy', c.shLazy.currentValue);
// observe element
this.io.observe(this.el.nativeElement);
}
}
To use, we only need to pass a unique id for all elements we need to observe together. The downside to this, is that the threshold
is no longer unique to every element, but rather to every group of elements. The first host element to initialize the observer
, is the deciding element.
<img [src]="defaultImage" [shLazy]="image" [options]="{fallBack: defaultImage, id: 'productcard'}" />
This solution does not look neat, nor does it have a big effect on performance. But if your page has like a 1000 images above the fold, and more 1000s down the fold, you might want to consider a single observer.
Find this code in StackBlitz lazy folder.
That's it for our intersection observation. Thank you for reading this far. Did you confuse the bug for a cockroach?
Top comments (0)