Skip to content

Commit

Permalink
improve(lazy-loading): finish implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
berndartmueller committed Sep 3, 2020
1 parent 6f74441 commit a915630
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 74 deletions.
7 changes: 4 additions & 3 deletions entry/entry-complete.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { Controls } from './../src/components/controls/controls';
import { Controls, ControlsSettings } from './../src/components/controls/controls';
import { Lazy } from './../src/components/lazy/lazy';
import { LazyLoadImage } from './../src/components/lazyload-image/lazyload-image';
import { Virchual as VirchualCore, VirchualSettings } from './../src/virchual';
import { Virchual as VirchualCore, VirchualSettings as VirchualCoreSettings } from './../src/virchual';

// @TODO settings with combined settings from components
export type VirchualSettings = VirchualCoreSettings & ControlsSettings;

export default class Virchual extends VirchualCore {
constructor(container: HTMLElement, settings: VirchualSettings = {}) {
super(container, settings);

this.register(Controls, { isEnabled: true });
// this.register(Lazy, { threshold: 300 }); @TODO
this.register(LazyLoadImage, { lazyload: false });
this.register(LazyLoadImage, { lazyload: true });
}
}
2 changes: 1 addition & 1 deletion examples/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import './../../src/css/styles.css';
});

instance.register(Controls, { isEnabled: true });
instance.register(LazyLoadImage);
instance.register(LazyLoadImage, { lazyload: true });

instance.mount();
});
4 changes: 3 additions & 1 deletion src/components/controls/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { identity } from '../../types';
import { stop } from '../../utils/event';
import { ComponentDependencies } from './../component';

export type ControlsSettings = { isEnabled?: boolean };

export class Controls {
private controls: HTMLButtonElement[];
private onClickBound: () => identity;

constructor(private imports: ComponentDependencies, private settings?: { isEnabled?: boolean }) {
constructor(private imports: ComponentDependencies, private settings?: ControlsSettings) {
this.controls = [].slice.call(imports.virchual.container.querySelectorAll('.virchual__control'));

imports.eventBus.on('destroy', () => {
Expand Down
170 changes: 126 additions & 44 deletions src/components/lazyload-image/lazyload-image.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ComponentDependencies } from './../component';
import { slidingWindow, range } from '@virchual/utils/index';

export type LazyLoadImageSettings = {
/**
Expand All @@ -10,88 +11,169 @@ export type LazyLoadImageSettings = {
* The CSS selector for lazyloaded images. Default 'img,picture'
*/
lazyloadSelector?: string;

/**
* How many images should get eagerly loaded on both sides of current slide.
*/
loadEager?: number;
};

const LAZY_CLASSNAME = 'virchual__lazy';
const LOADING_CLASSNAME = `${LAZY_CLASSNAME}--loading`;
const ERROR_CLASSNAME = `${LAZY_CLASSNAME}--failed`;
const COMPLETE_CLASSNAME = `${LAZY_CLASSNAME}--loaded`;

/**
* Return true if given elemen is a <picture> tag.
*
* @param element HTML Element.
*/
function isPictureTag(element: HTMLElement): boolean {
return element && element.nodeName.toLowerCase() === 'picture';
}

/**
* Return either parent <picture> tag or <img> tag.
*
* @param element Image HTML tag.
*/
function getImage(image: HTMLImageElement | HTMLPictureElement): HTMLImageElement | HTMLPictureElement {
const picture = image.parentNode as HTMLElement;

if (isPictureTag(picture)) {
return picture;
}

return image;
}

export class LazyLoadImage {
private lazyload: boolean;
private lazyloadSelector: string;
private loadEager: number;
private events: {
load: LazyLoadImage['onLoad'];
error: LazyLoadImage['onError'];
};

constructor(private imports: ComponentDependencies, settings: LazyLoadImageSettings) {
const { lazyload, lazyloadSelector, loadEager } = {
const { lazyload, lazyloadSelector } = {
lazyload: true,
lazyloadSelector: 'img,picture',
loadEager: 1,
...settings,
};

this.lazyload = lazyload;
this.lazyloadSelector = lazyloadSelector;
this.loadEager = loadEager;
this.events = {
load: this.onLoad.bind(this),
error: this.onError.bind(this),
};

imports.eventBus.on('mounted', () => {
this.lazyLoad();
});
// exit in case lazy loading is disabled
if (!this.lazyload) {
return;
}

imports.eventBus.on('move', () => {
this.lazyLoad();
imports.eventBus.on({
mounted: () => {
this.doLazyLoad();
},
move: () => {
this.doLazyLoad();
},
});
}

private lazyLoad(): void {
const images = this.getImages(this.imports.virchual.currentIndex, this.imports.virchual.currentIndex + 1);
/**
* Lazy load images.
*/
private doLazyLoad(): void {
const images = this.getImages();

images.forEach(image => {
this.loadImage(image);
});
images.forEach(image => this.loadImage(image));
}

private loadImage(image: HTMLImageElement) {
// @todo handle picture and img element slightly different
/**
* Get all image tags (<img>, <picture>) to lazy load.
*/
private getImages() {
const indices = range(0, this.imports.virchual.slides.length - 1);
const slidesWindowIndices = slidingWindow(indices, this.imports.virchual.currentIndex, 1);

[].forEach.call(image.getElementsByTagName('source'), (source: HTMLSourceElement) => {
const dataSrc = source.dataset.srcset;
const images: Array<HTMLImageElement | HTMLPictureElement> = [];

if (dataSrc) {
source.setAttribute('srcset', dataSrc);
slidesWindowIndices.forEach(index => {
const slide = this.imports.virchual.slides[index];

source.removeAttribute('data-srcset');
if (!slide.isMounted) {
return;
}

[].forEach.call(slide.ref.querySelectorAll(this.lazyloadSelector), (img: HTMLElement) => {
// skip <img> tags within <picture> tags
if (isPictureTag(img.parentElement)) {
return;
}

images.push(img);
});
});

image.classList.add('loading');
return images;
}

private getImages(start: number, end: number) {
if (start - this.loadEager >= 0) {
start = start - this.loadEager;
}
/**
* Do the actual lazy loading for a given image.
*
* @param image Either <img> or <picture> tag.
*/
private loadImage(image: HTMLImageElement | HTMLPictureElement) {
let hasLazyImages = false;

if (this.imports.virchual.slides.length > end + this.loadEager) {
end = end + this.loadEager;
}
[].forEach.call(image.querySelectorAll('img,source'), (source: HTMLImageElement | HTMLSourceElement) => {
const srcSetData = source.dataset.srcset;
const srcData = source.dataset.src;

const images = [];
if (srcSetData) {
source.setAttribute('srcset', srcSetData);
source.removeAttribute('data-srcset');
}

while (start <= end) {
const slide = this.imports.virchual.slides[start];
// source is <img>
if (srcData) {
source.setAttribute('src', source.dataset.src);
source.removeAttribute('data-src');

start++;
this.imports.eventBus.on(this.events, source);
}

if (!slide.isMounted) {
continue;
if (srcSetData || srcData) {
hasLazyImages = true;
}
});

[].forEach.call(slide.ref.querySelectorAll(this.lazyloadSelector), (img: HTMLElement) => {
images.push(img);
});
if (hasLazyImages) {
image.classList.add(LAZY_CLASSNAME);
image.classList.add(LOADING_CLASSNAME);
}
}

return images;
private onLoad(event: Event) {
const image = event.target as HTMLImageElement;

this.completeLoading(image);
}

private onError(event: Event) {
const image = event.target as HTMLImageElement;
const target = getImage(image);

target.classList.add(ERROR_CLASSNAME);

this.completeLoading(image);
}

private completeLoading(image: HTMLImageElement) {
const target = getImage(image);

target.classList.remove(LOADING_CLASSNAME);
target.classList.add(COMPLETE_CLASSNAME);

this.imports.eventBus.off(this.events, image);
}
}
9 changes: 9 additions & 0 deletions src/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,12 @@
transition-duration: inherit;
opacity: 1;
}

.virchual__lazy {
opacity: 0.4;
transition: opacity 0.5s;
}

.virchual__lazy--loaded {
opacity: 1;
}
72 changes: 50 additions & 22 deletions src/utils/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ export function stop(event: MouseEvent | TouchEvent) {
event.preventDefault();
}

export type EventHandler = (...args: unknown[]) => void;
export type EventTarget = (Window & typeof globalThis) | Element;
export type EventOptions = boolean | AddEventListenerOptions;

export class Event {
/**
* Store all event this.data.
*/
private handlers: Array<{
event: string;
handler: (...args: unknown[]) => void;
elm: (Window & typeof globalThis) | Element;
opts: boolean | AddEventListenerOptions;
handler: EventHandler;
elm: EventTarget;
opts: EventOptions;
}> = [];

/**
Expand All @@ -26,17 +30,21 @@ export class Event {
* @param element - Optional. Native event will be listened to when this arg is provided.
* @param opts - Optional. Options for addEventListener.
*/
on(events: string, handler: EventHandler, element?: EventTarget, opts?: EventOptions): void;
on(events: { [event: string]: EventHandler }, element?: EventTarget, opts?: EventOptions): void;
on(
events: string,
handler: (...args: unknown[]) => void,
element?: (Window & typeof globalThis) | Element,
opts: boolean | AddEventListenerOptions = {},
) {
events.split(' ').forEach(event => {
element && element.addEventListener(event, handler, opts);

this.handlers.push({ event, handler, elm: element, opts: opts });
});
events: string | { [event: string]: EventHandler },
handler?: EventHandler | EventTarget,
element?: EventTarget | EventOptions,
opts: EventOptions = {},
): void {
if (typeof events === 'string') {
events.split(' ').forEach(event => this.addEvent(event, element as EventTarget, handler as EventHandler, opts));
} else {
for (const event in events) {
this.addEvent(event, handler as EventTarget, events[event], opts);
}
}
}

/**
Expand All @@ -45,14 +53,16 @@ export class Event {
* @param events - A event name or names split by space.
* @param element - Optional. removeEventListener() will be called when this arg is provided.
*/
off(events: string, element?: (Window & typeof globalThis) | Element) {
events.split(' ').forEach(event => {
this.handlers = this.handlers.filter(item => {
if (item && item.event === event && item.elm === element) {
this.unroll(item);
}
});
});
off(events: string, element?: EventTarget): void;
off(events: { [event: string]: EventHandler }, element?: EventTarget): void;
off(events: string | { [event: string]: EventHandler }, element?: EventTarget) {
if (typeof events === 'string') {
events.split(' ').forEach(event => this.removeEvent(event, element));
} else {
for (const event in events) {
this.removeEvent(event, element);
}
}
}

/**
Expand Down Expand Up @@ -83,7 +93,25 @@ export class Event {
*
* @param item - An object containing event this.data.
*/
private unroll(item) {
private unroll(item: Event['handlers'][0]) {
item.elm && item.elm.removeEventListener(item.event, item.handler, item.opts);
}

private addEvent(event: string, element: EventTarget, handler: EventHandler, opts: EventOptions) {
element && element.addEventListener(event, handler, opts);

this.handlers.push({ event, handler: handler as EventHandler, elm: element as EventTarget, opts: opts });
}

private removeEvent(event: string, element: EventTarget) {
this.handlers = this.handlers.filter(item => {
if (item && item.event === event && item.elm === element) {
this.unroll(item);

return false;
}

return true;
});
}
}
2 changes: 1 addition & 1 deletion website/static/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
.homeWrapper {
display: flex;
flex-wrap: wrap;
flex-direction: row;
flex-direction: column;
justify-content: center;
}

Expand Down
2 changes: 1 addition & 1 deletion website/static/css/virchual.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit a915630

Please sign in to comment.