UNPKG

vanilla-lazyload

Version:

A fast, lightweight script to load images as they enter the viewport. SEO friendly, it supports responsive images (both srcset + sizes and picture) and progressive JPEG

521 lines (445 loc) 14.1 kB
var getDefaultSettings = () => ({ elements_selector: "img", container: window, threshold: 300, throttle: 150, data_src: "src", data_srcset: "srcset", data_sizes: "sizes", data_bg: "bg", class_loading: "loading", class_loaded: "loaded", class_error: "error", class_initial: "initial", skip_invisible: true, callback_load: null, callback_error: null, callback_set: null, callback_enter: null, callback_finish: null, to_webp: false }); const dataPrefix = "data-"; const processedDataName = "was-processed"; const processedDataValue = "true"; 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 setWasProcessedData = element => setData(element, processedDataName, processedDataValue); const getWasProcessedData = element => getData(element, processedDataName) === processedDataValue; const purgeProcessedElements = elements => { return elements.filter(element => !getWasProcessedData(element)); }; const purgeOneElement = (elements, elementToPurge) => { return elements.filter(element => element !== elementToPurge); }; const getTopOffset = function(element) { return ( element.getBoundingClientRect().top + window.pageYOffset - element.ownerDocument.documentElement.clientTop ); }; const isBelowViewport = function(element, container, threshold) { const fold = container === window ? window.innerHeight + window.pageYOffset : getTopOffset(container) + container.offsetHeight; return fold <= getTopOffset(element) - threshold; }; const getLeftOffset = function(element) { return ( element.getBoundingClientRect().left + window.pageXOffset - element.ownerDocument.documentElement.clientLeft ); }; const isAtRightOfViewport = function(element, container, threshold) { const documentWidth = window.innerWidth; const fold = container === window ? documentWidth + window.pageXOffset : getLeftOffset(container) + documentWidth; return fold <= getLeftOffset(element) - threshold; }; const isAboveViewport = function(element, container, threshold) { const fold = container === window ? window.pageYOffset : getTopOffset(container); return fold >= getTopOffset(element) + threshold + element.offsetHeight; }; const isAtLeftOfViewport = function(element, container, threshold) { const fold = container === window ? window.pageXOffset : getLeftOffset(container); return fold >= getLeftOffset(element) + threshold + element.offsetWidth; }; function isInsideViewport(element, container, threshold) { return ( !isBelowViewport(element, container, threshold) && !isAboveViewport(element, container, threshold) && !isAtRightOfViewport(element, container, threshold) && !isAtLeftOfViewport(element, container, threshold) ); } /* 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) */ function 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 replaceExtToWebp = (value, condition) => condition ? value.replace(/\.(jpe?g|png)/gi, ".webp") : value; const detectWebp = () => { var webpString = "image/webp"; var canvas = document.createElement("canvas"); if (canvas.getContext && canvas.getContext("2d")) { return canvas.toDataURL(webpString).indexOf(`data:${webpString}`) === 0; } return false; }; const runningOnBrowser = typeof window !== "undefined"; const isBot = (runningOnBrowser && !("onscroll" in window)) || /(gle|ing|ro)bot|crawl|spider/i.test(navigator.userAgent); const supportsClassList = runningOnBrowser && "classList" in document.createElement("p"); const supportsWebp = runningOnBrowser && detectWebp(); 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 setSourcesInChildren = function( parentTag, attrName, dataAttrName, toWebpFlag ) { for (let i = 0, childTag; (childTag = parentTag.children[i]); i += 1) { if (childTag.tagName === "SOURCE") { let attrValue = getData(childTag, dataAttrName); setAttributeIfValue(childTag, attrName, attrValue, toWebpFlag); } } }; const setAttributeIfValue = function( element, attrName, value, toWebpFlag ) { if (!value) { return; } element.setAttribute(attrName, replaceExtToWebp(value, toWebpFlag)); }; const setSourcesImg = (element, settings) => { const toWebpFlag = supportsWebp && settings.to_webp; const srcsetDataName = settings.data_srcset; const parent = element.parentNode; if (parent && parent.tagName === "PICTURE") { setSourcesInChildren(parent, "srcset", srcsetDataName, toWebpFlag); } const sizesDataValue = getData(element, settings.data_sizes); setAttributeIfValue(element, "sizes", sizesDataValue); const srcsetDataValue = getData(element, srcsetDataName); setAttributeIfValue(element, "srcset", srcsetDataValue, toWebpFlag); const srcDataValue = getData(element, settings.data_src); setAttributeIfValue(element, "src", srcDataValue, toWebpFlag); }; const setSourcesIframe = (element, settings) => { const srcDataValue = getData(element, settings.data_src); setAttributeIfValue(element, "src", srcDataValue); }; const setSourcesVideo = (element, settings) => { const srcDataName = settings.data_src; const srcDataValue = getData(element, srcDataName); setSourcesInChildren(element, "src", srcDataName); setAttributeIfValue(element, "src", srcDataValue); element.load(); }; const setSourcesBgImage = (element, settings) => { const toWebpFlag = supportsWebp && settings.to_webp; const srcDataValue = getData(element, settings.data_src); const bgDataValue = getData(element, settings.data_bg); if (srcDataValue) { let setValue = replaceExtToWebp(srcDataValue, toWebpFlag); element.style.backgroundImage = `url("${setValue}")`; } if (bgDataValue) { let setValue = replaceExtToWebp(bgDataValue, toWebpFlag); element.style.backgroundImage = setValue; } }; const setSourcesFunctions = { IMG: setSourcesImg, IFRAME: setSourcesIframe, VIDEO: setSourcesVideo }; const setSources = (element, instance) => { const settings = instance._settings; const tagName = element.tagName; const setSourcesFunction = setSourcesFunctions[tagName]; if (setSourcesFunction) { setSourcesFunction(element, settings); instance._updateLoadingCount(1); instance._elements = purgeOneElement(instance._elements, element); return; } setSourcesBgImage(element, settings); }; const callbackIfSet = function(callback, argument) { if (callback) { callback(argument); } }; const genericLoadEventName = "load"; const mediaLoadEventName = "loadeddata"; const errorEventName = "error"; const addEventListener = (element, eventName, handler) => { element.addEventListener(eventName, handler); }; const removeEventListener = (element, eventName, handler) => { element.removeEventListener(eventName, handler); }; const addAllEventListeners = (element, loadHandler, errorHandler) => { addEventListener(element, genericLoadEventName, loadHandler); addEventListener(element, mediaLoadEventName, loadHandler); addEventListener(element, errorEventName, errorHandler); }; const removeAllEventListeners = (element, loadHandler, errorHandler) => { removeEventListener(element, genericLoadEventName, loadHandler); removeEventListener(element, mediaLoadEventName, loadHandler); removeEventListener(element, errorEventName, errorHandler); }; const eventHandler = function(event, success, instance) { var settings = instance._settings; const className = success ? settings.class_loaded : settings.class_error; const callback = success ? settings.callback_load : settings.callback_error; const element = event.target; removeClass(element, settings.class_loading); addClass(element, className); callbackIfSet(callback, element); instance._updateLoadingCount(-1); }; const addOneShotEventListeners = (element, instance) => { const loadHandler = event => { eventHandler(event, true, instance); removeAllEventListeners(element, loadHandler, errorHandler); }; const errorHandler = event => { eventHandler(event, false, instance); removeAllEventListeners(element, loadHandler, errorHandler); }; addAllEventListeners(element, loadHandler, errorHandler); }; const managedTags = ["IMG", "IFRAME", "VIDEO"]; function revealElement(element, instance, force) { var settings = instance._settings; if (!force && getWasProcessedData(element)) { return; // element has already been processed and force wasn't true } callbackIfSet(settings.callback_enter, element); if (managedTags.indexOf(element.tagName) > -1) { addOneShotEventListeners(element, instance); addClass(element, settings.class_loading); } setSources(element, instance); setWasProcessedData(element); callbackIfSet(settings.callback_set, element); } const removeFromArray = (elements, indexes) => { while (indexes.length) { elements.splice(indexes.pop(), 1); } }; /* * Constructor */ const LazyLoad = function(instanceSettings) { this._settings = Object.assign({}, getDefaultSettings(), instanceSettings); this._loadingCount = 0; this._queryOriginNode = this._settings.container === window ? document : this._settings.container; this._previousLoopTime = 0; this._loopTimeout = null; this._boundHandleScroll = this.handleScroll.bind(this); this._isFirstLoop = true; window.addEventListener("resize", this._boundHandleScroll); this.update(); }; LazyLoad.prototype = { _loopThroughElements: function(forceDownload) { const settings = this._settings, elements = this._elements, elementsLength = !elements ? 0 : elements.length; let i, processedIndexes = [], isFirstLoop = this._isFirstLoop; if (isFirstLoop) { this._isFirstLoop = false; } if (elementsLength === 0) { this._stopScrollHandler(); return; } for (i = 0; i < elementsLength; i++) { let element = elements[i]; /* If must skip_invisible and element is invisible, skip it */ if (settings.skip_invisible && element.offsetParent === null) { continue; } if ( forceDownload || isInsideViewport( element, settings.container, settings.threshold ) ) { if (isFirstLoop) { addClass(element, settings.class_initial); } this.load(element); processedIndexes.push(i); } } // Removing processed elements from this._elements. removeFromArray(elements, processedIndexes); }, _startScrollHandler: function() { if (!this._isHandlingScroll) { this._isHandlingScroll = true; this._settings.container.addEventListener( "scroll", this._boundHandleScroll ); } }, _stopScrollHandler: function() { if (this._isHandlingScroll) { this._isHandlingScroll = false; this._settings.container.removeEventListener( "scroll", this._boundHandleScroll ); } }, _updateLoadingCount: function(plusMinus) { this._loadingCount += plusMinus; if (this._elements.length === 0 && this._loadingCount === 0) { callbackIfSet(this._settings.callback_finish); } }, handleScroll: function() { const throttle = this._settings.throttle; if (throttle !== 0) { let now = Date.now(); let remainingTime = throttle - (now - this._previousLoopTime); if (remainingTime <= 0 || remainingTime > throttle) { if (this._loopTimeout) { clearTimeout(this._loopTimeout); this._loopTimeout = null; } this._previousLoopTime = now; this._loopThroughElements(); } else if (!this._loopTimeout) { this._loopTimeout = setTimeout( function() { this._previousLoopTime = Date.now(); this._loopTimeout = null; this._loopThroughElements(); }.bind(this), remainingTime ); } } else { this._loopThroughElements(); } }, loadAll: function() { this._loopThroughElements(true); }, update: function(elements) { const settings = this._settings; const nodeSet = elements || this._queryOriginNode.querySelectorAll(settings.elements_selector); this._elements = purgeProcessedElements( Array.prototype.slice.call(nodeSet) // NOTE: nodeset to array for IE compatibility ); if (isBot) { this.loadAll(); return; } this._loopThroughElements(); this._startScrollHandler(); }, destroy: function() { window.removeEventListener("resize", this._boundHandleScroll); if (this._loopTimeout) { clearTimeout(this._loopTimeout); this._loopTimeout = null; } this._stopScrollHandler(); this._elements = null; this._queryOriginNode = null; this._settings = null; }, load: function(element, force) { revealElement(element, this, force); } }; /* Automatic instances creation if required (useful for async script loading) */ if (runningOnBrowser) { autoInitialize(LazyLoad, window.lazyLoadOptions); } export default LazyLoad;