UNPKG

@mottor/vanilla-lazyload

Version:

LazyLoad is a lightweight (2.4 kB) and flexible script that speeds up your web application by deferring the loading of your below-the-fold images, videos and iframes to when they will enter the viewport. It's written in plain "vanilla" JavaScript, it leve

657 lines (560 loc) 20.8 kB
const runningOnBrowser = typeof window !== "undefined"; const isBot = (runningOnBrowser && !("onscroll" in window)) || (typeof navigator !== "undefined" && /(gle|ing|ro)bot|crawl|spider/i.test(navigator.userAgent)); const supportsIntersectionObserver = runningOnBrowser && "IntersectionObserver" in window; const supportsClassList = runningOnBrowser && "classList" in document.createElement("p"); const isHiDpi = runningOnBrowser && window.devicePixelRatio > 1; const defaultSettings = { elements_selector: ".lazy", container: isBot || runningOnBrowser ? document : null, threshold: 300, thresholds: null, data_src: "src", data_srcset: "srcset", data_sizes: "sizes", data_bg: "bg", data_bg_hidpi: "bg-hidpi", data_bg_multi: "bg-multi", data_bg_multi_hidpi: "bg-multi-hidpi", data_poster: "poster", class_applied: "applied", class_loading: "loading", class_loaded: "loaded", class_error: "error", class_entered: "entered", class_exited: "exited", unobserve_completed: true, unobserve_entered: false, cancel_on_exit: true, callback_enter: null, callback_exit: null, callback_applied: null, callback_loading: null, callback_loaded: null, callback_error: null, callback_finish: null, callback_cancel: null, use_native: false }; const getExtendedSettings = (customSettings) => { return Object.assign({}, defaultSettings, customSettings); }; /* Creates instance and notifies it through the window element */ const createInstance = function(classObj, options) { var event; let eventString = "LazyLoad::Initialized"; let instance = new classObj(options); try { // Works in modern browsers event = new CustomEvent(eventString, { detail: { instance } }); } catch (err) { // Works in Internet Explorer (all versions) event = document.createEvent("CustomEvent"); event.initCustomEvent(eventString, false, false, { instance }); } window.dispatchEvent(event); }; /* Auto initialization of one or more instances of lazyload, depending on the options passed in (plain object or an array) */ const autoInitialize = (classObj, options) => { if (!options) { return; } if (!options.length) { // Plain object createInstance(classObj, options); } else { // Array of objects for (let i = 0, optionsItem; (optionsItem = options[i]); i += 1) { createInstance(classObj, optionsItem); } } }; const statusLoading = "loading"; const statusLoaded = "loaded"; const statusApplied = "applied"; const statusEntered = "entered"; const statusError = "error"; const statusNative = "native"; const dataPrefix = "data-"; const statusDataName = "ll-status"; const getData = (element, attribute) => { return element.getAttribute(dataPrefix + attribute); }; const setData = (element, attribute, value) => { var attrName = dataPrefix + attribute; if (value === null) { element.removeAttribute(attrName); return; } element.setAttribute(attrName, value); }; const getStatus = (element) => getData(element, statusDataName); const setStatus = (element, status) => setData(element, statusDataName, status); const resetStatus = (element) => setStatus(element, null); const hasEmptyStatus = (element) => getStatus(element) === null; const hasStatusLoading = (element) => getStatus(element) === statusLoading; const hasStatusError = (element) => getStatus(element) === statusError; const hasStatusNative = (element) => getStatus(element) === statusNative; const statusesAfterLoading = [statusLoading, statusLoaded, statusApplied, statusError]; const hadStartedLoading = (element) => statusesAfterLoading.indexOf(getStatus(element)) >= 0; const safeCallback = (callback, arg1, arg2, arg3) => { if (!callback) { return; } if (arg3 !== undefined) { callback(arg1, arg2, arg3); return; } if (arg2 !== undefined) { callback(arg1, arg2); return; } callback(arg1); }; const addClass = (element, className) => { if (supportsClassList) { element.classList.add(className); return; } element.className += (element.className ? " " : "") + className; }; const removeClass = (element, className) => { if (supportsClassList) { element.classList.remove(className); return; } element.className = element.className. replace(new RegExp("(^|\\s+)" + className + "(\\s+|$)"), " "). replace(/^\s+/, ""). replace(/\s+$/, ""); }; const addTempImage = (element) => { element.llTempImage = document.createElement("IMG"); }; const deleteTempImage = (element) => { delete element.llTempImage; }; const getTempImage = (element) => element.llTempImage; const unobserve = (element, instance) => { if (!instance) return; const observer = instance._observer; if (!observer) return; observer.unobserve(element); }; const resetObserver = (observer) => { observer.disconnect(); }; const unobserveEntered = (element, settings, instance) => { if (settings.unobserve_entered) unobserve(element, instance); }; const updateLoadingCount = (instance, delta) => { if (!instance) return; instance.loadingCount += delta; }; const decreaseToLoadCount = (instance) => { if (!instance) return; instance.toLoadCount -= 1; }; const setToLoadCount = (instance, value) => { if (!instance) return; instance.toLoadCount = value; }; const isSomethingLoading = (instance) => instance.loadingCount > 0; const haveElementsToLoad = (instance) => instance.toLoadCount > 0; const getSourceTags = (parentTag) => { let sourceTags = []; for (let i = 0, childTag; (childTag = parentTag.children[i]); i += 1) { if (childTag.tagName === "SOURCE") { sourceTags.push(childTag); } } return sourceTags; }; const setAttributeIfValue = (element, attrName, value) => { if (!value) { return; } element.setAttribute(attrName, value); }; const resetAttribute = (element, attrName) => { element.removeAttribute(attrName); }; const hasOriginalAttributes = (element) => { return !!element.llOriginalAttrs; }; const saveOriginalImageAttributes = (element) => { if (hasOriginalAttributes(element)) { return; } const originalAttributes = {}; originalAttributes["src"] = element.getAttribute("src"); originalAttributes["srcset"] = element.getAttribute("srcset"); originalAttributes["sizes"] = element.getAttribute("sizes"); element.llOriginalAttrs = originalAttributes; }; const restoreOriginalImageAttributes = (element) => { if (!hasOriginalAttributes(element)) { return; } const originalAttributes = element.llOriginalAttrs; setAttributeIfValue(element, "src", originalAttributes["src"]); setAttributeIfValue(element, "srcset", originalAttributes["srcset"]); setAttributeIfValue(element, "sizes", originalAttributes["sizes"]); }; const setImageAttributes = (element, settings) => { setAttributeIfValue(element, "sizes", getData(element, settings.data_sizes)); setAttributeIfValue(element, "srcset", getData(element, settings.data_srcset)); setAttributeIfValue(element, "src", getData(element, settings.data_src)); }; const resetImageAttributes = (element) => { resetAttribute(element, "src"); resetAttribute(element, "srcset"); resetAttribute(element, "sizes"); }; const forEachPictureSource = (element, fn) => { const parent = element.parentNode; if (!parent || parent.tagName !== "PICTURE") { return; } let sourceTags = getSourceTags(parent); sourceTags.forEach(fn); }; const forEachVideoSource = (element, fn) => { let sourceTags = getSourceTags(element); sourceTags.forEach(fn); }; const restoreOriginalAttributesImg = (element) => { forEachPictureSource(element, (sourceTag) => { restoreOriginalImageAttributes(sourceTag); }); restoreOriginalImageAttributes(element); }; const setSourcesImg = (element, settings) => { forEachPictureSource(element, (sourceTag) => { saveOriginalImageAttributes(sourceTag); setImageAttributes(sourceTag, settings); }); saveOriginalImageAttributes(element); setImageAttributes(element, settings); }; const resetSourcesImg = (element) => { forEachPictureSource(element, (sourceTag) => { resetImageAttributes(sourceTag); }); resetImageAttributes(element); }; const setSourcesIframe = (element, settings) => { setAttributeIfValue(element, "src", getData(element, settings.data_src)); }; const setSourcesVideo = (element, settings) => { forEachVideoSource(element, (sourceTag) => { setAttributeIfValue(sourceTag, "src", getData(sourceTag, settings.data_src)); }); setAttributeIfValue(element, "poster", getData(element, settings.data_poster)); setAttributeIfValue(element, "src", getData(element, settings.data_src)); element.load(); }; const setSourcesFunctions = { IMG: setSourcesImg, IFRAME: setSourcesIframe, VIDEO: setSourcesVideo }; const setBackground = (element, settings, instance) => { const bg1xValue = getData(element, settings.data_bg); const bgHiDpiValue = getData(element, settings.data_bg_hidpi); const bgDataValue = isHiDpi && bgHiDpiValue ? bgHiDpiValue : bg1xValue; if (!bgDataValue) return; element.style.backgroundImage = `url("${bgDataValue}")`; getTempImage(element).setAttribute("src", bgDataValue); manageLoading(element, settings, instance); }; // NOTE: THE TEMP IMAGE TRICK CANNOT BE DONE WITH data-multi-bg // BECAUSE INSIDE ITS VALUES MUST BE WRAPPED WITH URL() AND ONE OF THEM // COULD BE A GRADIENT BACKGROUND IMAGE const setMultiBackground = (element, settings, instance) => { const bg1xValue = getData(element, settings.data_bg_multi); const bgHiDpiValue = getData(element, settings.data_bg_multi_hidpi); const bgDataValue = isHiDpi && bgHiDpiValue ? bgHiDpiValue : bg1xValue; if (!bgDataValue) { return; } element.style.backgroundImage = bgDataValue; manageApplied(element, settings, instance); }; const setSources = (element, settings) => { const setSourcesFunction = setSourcesFunctions[element.tagName]; if (!setSourcesFunction) { return; } setSourcesFunction(element, settings); }; const manageApplied = (element, settings, instance) => { addClass(element, settings.class_applied); setStatus(element, statusApplied); if (settings.unobserve_completed) { // Unobserve now because we can't do it on load unobserve(element, settings); } safeCallback(settings.callback_applied, element, instance); }; const manageLoading = (element, settings, instance) => { updateLoadingCount(instance, +1); addClass(element, settings.class_loading); setStatus(element, statusLoading); safeCallback(settings.callback_loading, element, instance); }; const elementsWithLoadEvent = ["IMG", "IFRAME", "VIDEO"]; const hasLoadEvent = (element) => elementsWithLoadEvent.indexOf(element.tagName) > -1; const checkFinish = (settings, instance) => { if (instance && !isSomethingLoading(instance) && !haveElementsToLoad(instance)) { safeCallback(settings.callback_finish, instance); } }; const addEventListener = (element, eventName, handler) => { element.addEventListener(eventName, handler); element.llEvLisnrs[eventName] = handler; }; const removeEventListener = (element, eventName, handler) => { element.removeEventListener(eventName, handler); }; const hasEventListeners = (element) => { return !!element.llEvLisnrs; }; const addEventListeners = (element, loadHandler, errorHandler) => { if (!hasEventListeners(element)) element.llEvLisnrs = {}; const loadEventName = element.tagName === "VIDEO" ? "loadeddata" : "load"; addEventListener(element, loadEventName, loadHandler); addEventListener(element, "error", errorHandler); }; const removeEventListeners = (element) => { if (!hasEventListeners(element)) { return; } const eventListeners = element.llEvLisnrs; for (let eventName in eventListeners) { const handler = eventListeners[eventName]; removeEventListener(element, eventName, handler); } delete element.llEvLisnrs; }; const doneHandler = (element, settings, instance) => { deleteTempImage(element); updateLoadingCount(instance, -1); decreaseToLoadCount(instance); removeClass(element, settings.class_loading); if (settings.unobserve_completed) { unobserve(element, instance); } }; const loadHandler = (event, element, settings, instance) => { const goingNative = hasStatusNative(element); doneHandler(element, settings, instance); addClass(element, settings.class_loaded); setStatus(element, statusLoaded); safeCallback(settings.callback_loaded, element, instance); if (!goingNative) checkFinish(settings, instance); }; const errorHandler = (event, element, settings, instance) => { const goingNative = hasStatusNative(element); doneHandler(element, settings, instance); addClass(element, settings.class_error); setStatus(element, statusError); safeCallback(settings.callback_error, element, instance); if (!goingNative) checkFinish(settings, instance); }; const addOneShotEventListeners = (element, settings, instance) => { const elementToListenTo = getTempImage(element) || element; if (hasEventListeners(elementToListenTo)) { // This happens when loading is retried twice return; } const _loadHandler = (event) => { loadHandler(event, element, settings, instance); removeEventListeners(elementToListenTo); }; const _errorHandler = (event) => { errorHandler(event, element, settings, instance); removeEventListeners(elementToListenTo); }; addEventListeners(elementToListenTo, _loadHandler, _errorHandler); }; const loadBackground = (element, settings, instance) => { addTempImage(element); addOneShotEventListeners(element, settings, instance); setBackground(element, settings, instance); setMultiBackground(element, settings, instance); }; const loadRegular = (element, settings, instance) => { addOneShotEventListeners(element, settings, instance); setSources(element, settings); manageLoading(element, settings, instance); }; const load = (element, settings, instance) => { if (hasLoadEvent(element)) { loadRegular(element, settings, instance); } else { loadBackground(element, settings, instance); } }; const loadNative = (element, settings, instance) => { addOneShotEventListeners(element, settings, instance); setSources(element, settings); setStatus(element, statusNative); }; const cancelLoading = (element, entry, settings, instance) => { if (!settings.cancel_on_exit) return; if (!hasStatusLoading(element)) return; if (element.tagName !== "IMG") return; //Works only on images removeEventListeners(element); resetSourcesImg(element); restoreOriginalAttributesImg(element); removeClass(element, settings.class_loading); updateLoadingCount(instance, -1); resetStatus(element); safeCallback(settings.callback_cancel, element, entry, instance); }; const onEnter = (element, entry, settings, instance) => { setStatus(element, statusEntered); addClass(element, settings.class_entered); removeClass(element, settings.class_exited); unobserveEntered(element, settings, instance); safeCallback(settings.callback_enter, element, entry, instance); if (hadStartedLoading(element)) return; //Prevent loading it again load(element, settings, instance); }; const onExit = (element, entry, settings, instance) => { if (hasEmptyStatus(element)) return; //Ignore the first pass, at landing addClass(element, settings.class_exited); cancelLoading(element, entry, settings, instance); safeCallback(settings.callback_exit, element, entry, instance); }; const tagsWithNativeLazy = ["IMG", "IFRAME"]; const shouldUseNative = (settings) => settings.use_native && "loading" in HTMLImageElement.prototype; const loadAllNative = (elements, settings, instance) => { elements.forEach((element) => { if (tagsWithNativeLazy.indexOf(element.tagName) === -1) { return; } element.setAttribute("loading", "lazy"); //TODO: Move inside the loadNative method loadNative(element, settings, instance); }); setToLoadCount(instance, 0); }; const isIntersecting = (entry) => entry.isIntersecting || entry.intersectionRatio > 0; const getObserverSettings = (settings) => ({ root: settings.container === document ? null : settings.container, rootMargin: settings.thresholds || settings.threshold + "px" }); const intersectionHandler = (entries, settings, instance) => { entries.forEach((entry) => isIntersecting(entry) ? onEnter(entry.target, entry, settings, instance) : onExit(entry.target, entry, settings, instance) ); }; const observeElements = (observer, elements) => { elements.forEach((element) => { observer.observe(element); }); }; const updateObserver = (observer, elementsToObserve) => { resetObserver(observer); observeElements(observer, elementsToObserve); }; const setObserver = (settings, instance) => { if (!supportsIntersectionObserver || shouldUseNative(settings)) { return; } instance._observer = new IntersectionObserver((entries) => { intersectionHandler(entries, settings, instance); }, getObserverSettings(settings)); }; const toArray = (nodeSet) => Array.prototype.slice.call(nodeSet); const queryElements = (settings) => settings.container.querySelectorAll(settings.elements_selector); const excludeManagedElements = (elements) => toArray(elements).filter(hasEmptyStatus); const hasError = (element) => hasStatusError(element); const filterErrorElements = (elements) => toArray(elements).filter(hasError); const getElementsToLoad = (elements, settings) => excludeManagedElements(elements || queryElements(settings)); const retryLazyLoad = (settings, instance) => { const errorElements = filterErrorElements(queryElements(settings)); errorElements.forEach(element => { removeClass(element, settings.class_error); resetStatus(element); }); instance.update(); }; const setOnlineCheck = (settings, instance) => { if (!runningOnBrowser) { return; } window.addEventListener("online", () => { retryLazyLoad(settings, instance); }); }; const LazyLoad = function (customSettings, elements) { const settings = getExtendedSettings(customSettings); this._settings = settings; this.loadingCount = 0; setObserver(settings, this); setOnlineCheck(settings, this); this.update(elements); }; LazyLoad.prototype = { update: function (givenNodeset) { const settings = this._settings; const elementsToLoad = getElementsToLoad(givenNodeset, settings); setToLoadCount(this, elementsToLoad.length); if (isBot || !supportsIntersectionObserver) { this.loadAll(elementsToLoad); return; } if (shouldUseNative(settings)) { loadAllNative(elementsToLoad, settings, this); return; } updateObserver(this._observer, elementsToLoad); }, destroy: function () { // Observer if (this._observer) { this._observer.disconnect(); } // Clean custom attributes on elements queryElements(this._settings).forEach((element) => { delete element.llOriginalAttrs; }); // Delete all internal props delete this._observer; delete this._settings; delete this.loadingCount; delete this.toLoadCount; }, loadAll: function (elements) { const settings = this._settings; const elementsToLoad = getElementsToLoad(elements, settings); elementsToLoad.forEach((element) => { unobserve(element, this); load(element, settings, this); }); }, unobserve: function(element) { unobserve(element, this); } }; LazyLoad.load = (element, customSettings) => { const settings = getExtendedSettings(customSettings); load(element, settings); }; LazyLoad.resetStatus = (element) => { resetStatus(element); }; // Automatic instances creation if required (useful for async script loading) if (runningOnBrowser) { autoInitialize(LazyLoad, window.lazyLoadOptions); } export default LazyLoad;