Vue3 lazload source code analysis

Intersection Observer API

A method for asynchronously detecting the change of intersection between target element and ancestor element or viewport.

The Intersection Observer API will register a callback function. Whenever the monitored element enters or exits another element (or viewport), or the size of the intersection of two elements changes, the callback method will be triggered and executed, and the browser will optimize the element intersection management by itself.

Applicable scenario

  • Image lazy loading - loads when the image scrolls to visible
  • Unlimited scrolling of content - that is, when users scroll to the bottom of the content, they directly load more pages without user operation, giving users the illusion that web pages can scroll indefinitely
  • Detect advertising exposure - in order to calculate advertising revenue, you need to know the exposure of advertising elements
  • Perform a task or play an animation when the user sees an area

Alternative methods

In the past, event listening was usually used for intersection detection, and the Element.getBoundingClientRect() method needed to be called frequently to obtain the boundary information of related elements. Both event listening and calling Element.getBoundingClientRect() run on the main thread, so frequent triggering and calling may cause performance problems. This detection method is extremely strange and not elegant.

If multiple third-party libraries are referenced in order to use different businesses, they may each implement the same set of processes. In this case, the performance is poor and cannot be optimized

Concept and usage

The Intersection Observer API allows you to configure a callback function, which will be called when the following occurs:

  • Execute whenever the target element intersects with the device window or other specified elements. The device window or other element is called the root element or root.
  • When Observer first listens to the target element

Generally, you need to pay attention to the intersection change of the scrollable ancestor element closest to the document. If the element is not a descendant of the scrollable element, it defaults to the device window. If you want to observe the intersection relative to the root element, specify that the root element is null.

The degree of intersection between the target element and the root element is the intersection ratio. This is the representation of the intersection percentage of the target element relative to the root. Its value is between 0.0 and 1.0.

Create an intersection observer

Create an IntersectionObserver object and pass in the corresponding parameters and callback function. The callback function will be executed when the intersection size of the target element and the root element exceeds the size specified by the threshold.

options

parameterdescribe
rootSpecify the root element to check the visibility of the target. Must be a parent of the target element. If not specified or null, it defaults to browser window.
rootMarginThe outer margin of the root element. If the root parameter is specified, rootMargin can also be taken as a percentage. This attribute value is used as the area range for calculating the intersection when the root element and target intersect. Use this attribute to control the contraction or expansion of each side of the root element. The default value is 0.
thresholdIt can be a single number or an array of numbers. When the intersection degree of target element and root element reaches this value, the callback function registered by IntersectionObserver will be executed. If you just want to probe, when the visibility of the target element in the root element exceeds 50%, you can specify that the attribute value is 0.5. If you want the callback to be performed every 25% more visible of the target element in the root element, you can specify an array [0, 0.25, 0.5, 0.75, 1]. The default value is 0 (which means that as long as a target pixel appears in the root element, the callback function will be executed). The value of 1.0 means that the callback will be executed only when the target completely appears in the root element.
const options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

const callback =(entries, observer) => {
  entries.forEach(entry => {
    // Each entry describes an intersection change for one observed target element:
    // entry.boundingClientRect
    // entry.intersectionRatio
    // entry.intersectionRect
    // entry.isIntersecting
    // entry.rootBounds
    // entry.target
    // entry.time
  });
};

const observer = new IntersectionObserver(callback, options);

Please note that the callback function you registered will be executed in the main thread. So the function should be executed as fast as possible. If there are some time-consuming operations to be performed, it is recommended to use the Window.requestIdleCallback() method.

After creating an observer, you need to give a target element for observation.

const target = document.querySelector('#listItem');
observer.observe(target);

Calculation of intersection

Container elements and offset values

All areas are treated as a rectangle by the Intersection Observer API. If the element is an irregular shape, it will also be regarded as a minimum rectangle containing all areas of the element. Similarly, if the intersection part of the element is not a rectangle, it will also be regarded as a minimum rectangle containing all its intersection areas.

The container (root) element can be either a target element or an ancestor element. If null is specified, the browser viewport is used as the container (root). The size of the rectangle used to detect the intersection of the target element is as follows:

  • If root is implied (the value is null), it is the rectangular size of the window.
  • If there is an overflow, it is the content area of the root element
  • Otherwise, it is the rectangular boundary of the container element (obtained by the getBoundingClientRect() method)

The attribute value of rootMargin will be added as the margin offset value to the corresponding margin position of the container (root) element, and finally form the rectangular boundary of the root element

threshold

The IntersectionObserver API does not execute callbacks every time the intersection of elements changes. Instead, it uses the thresholds parameter. When you create an observer, you can provide one or more number type values to represent the percentage of the visible program of the target element in the root element. Then, the API callback function will only be executed when the element reaches the threshold specified by thresholds.

  • The thresholds of the first box contain each visual percentage
  • The second box has only a unique value [0.5].
  • The thresholds of the third box increase from 0 by 10% (0%, 10%, 20%, etc.).
  • The last box is [0, 0.25, 0.5, 0.75, 1.0].

requestIdleCallback

The window.requestIdleCallback() method inserts a function that will be called when the browser is idle. This enables developers to perform background and low priority work on the main event loop without delaying key events, such as animation and input response. Functions are generally executed in the order of first in first call. However, if the callback function specifies the execution timeout, it is possible to disrupt the execution order in order to execute the function before the timeout.

You can call **requestIdleCallback() * in the idle callback function to schedule another callback before the next event loop.

be careful:

  • requestAnimationFrame requests the browser to execute the callback function before the next re rendering
  • requestIdleCallback is called when the browser is idle

Custom event

CustomEvent

Create a new CustomEvent object.

$$ event = new CustomEvent(typeArg, customEventInit); $$

  • typeArg: a string representing the name of the event
  • customEventInit:
    |Parameter description|
    | ---------- | ----------------------------------------------------------- |
    |The optional default value of detail | is any type of data with null, which is a value related to event|
    |bubbles | a Boolean value indicating whether the event can bubble|
    |cancelable | a Boolean value indicating whether the event can be cancelled|

dispatchEvent

Send an event to a specified event target, and synchronously call the event handler related to the target element in an appropriate order. The standard event handling rules (including event capture and optional bubbling process) also apply to events dispatched manually using the dispatchEvent() method.

$$ cancelled = !target.dispatchEvent(event) $$

Parameters:

  • Event is the event object to be dispatched.
  • target is used to initialize events and determine which targets will be triggered

Return value:

  • When the event is cancelable (cancelable is true) and at least one event processing method of the event calls Event.preventDefault(), the return value is false; Otherwise, return true.

If the event's type of the dispatched event is not initialized and specified before the method call, an unspecified will be thrown_ EVENT_ TYPE_ Err exception, or if the event type is null or an empty string. event handler will throw an uncapped exception; These event handlers run in a nested call stack: they block calls until they are processed, but exceptions do not bubble.

be careful

Unlike browser native events, native events are dispatched by DOM and call the event handler asynchronously through event loop, while dispatchEvent () calls the event handler synchronously. After calling dispatchEvent(), all event handlers listening to the event will execute and return before the code continues.

dispatchEvent() is the last step in the create init dispatch process and is used to schedule events into the implemented Event model. You can use the Event constructor to create an Event.

Lazy loading process

This is a more conventional implementation

Vue lazload source code analysis

Entry file

export const Lazyload = {
  /*
   * install function
   * @param  {App} app
   * @param  {object} options lazyload options
   */
  install(app: App, options: VueLazyloadOptions = {}) {
    const lazy = new Lazy(options)
    const lazyContainer = new LazyContainer(lazy)

    // Expose to component instances
    app.config.globalProperties.$Lazyload = lazy;

    // Component registration
    if (options.lazyComponent) {
      app.component('lazy-component', LazyComponent(lazy));
    }

    if (options.lazyImage) {
      app.component('lazy-image', LazyImage(lazy));
    }

    // Instruction registration
    app.directive('lazy', {
      // Keep pointing
      beforeMount: lazy.add.bind(lazy),
      beforeUpdate: lazy.update.bind(lazy),
      updated: lazy.lazyLoadHandler.bind(lazy),
      unmounted: lazy.remove.bind(lazy)
    });

    app.directive('lazy-container', {
      beforeMount: lazyContainer.bind.bind(lazyContainer),
      updated: lazyContainer.update.bind(lazyContainer),
      unmounted: lazyContainer.unbind.bind(lazyContainer),
    });
  }
}

lzay is the core implementation of lazy loading. It is important to expose it to Vue instances

app.config.globalProperties.$Lazyload = lazy;

Usage: two components and two instructions

Register components first

if (options.lazyComponent) {
  app.component('lazy-component', LazyComponent(lazy));
}

if (options.lazyImage) {
  app.component('lazy-image', LazyImage(lazy));
}

The lazy method needs to be called in different instruction hooks

app.directive('lazy', {
  // Keep pointing
  beforeMount: lazy.add.bind(lazy),
  beforeUpdate: lazy.update.bind(lazy),
  updated: lazy.lazyLoadHandler.bind(lazy),
  unmounted: lazy.remove.bind(lazy)
});

app.directive('lazy-container', {
  beforeMount: lazyContainer.bind.bind(lazyContainer),
  updated: lazyContainer.update.bind(lazyContainer),
  unmounted: lazyContainer.unbind.bind(lazyContainer),
});

Mode of use

template:

<ul>
  <li v-for="img in list">
    <img v-lazy="img.src" >
  </li>
</ul>

use v-lazy-container work with raw HTML

<div v-lazy-container="{ selector: 'img' }">
  <img data-src="//domain.com/img1.jpg">
  <img data-src="//domain.com/img2.jpg">
  <img data-src="//domain.com/img3.jpg">  
</div>

custom error and loading placeholder image

<div v-lazy-container="{ selector: 'img', error: 'xxx.jpg', loading: 'xxx.jpg' }">
  <img data-src="//domain.com/img1.jpg">
  <img data-src="//domain.com/img2.jpg">
  <img data-src="//domain.com/img3.jpg">  
</div>
<div v-lazy-container="{ selector: 'img' }">
  <img data-src="//domain.com/img1.jpg" data-error="xxx.jpg">
  <img data-src="//domain.com/img2.jpg" data-loading="xxx.jpg">
  <img data-src="//domain.com/img3.jpg">  
</div>

Default configuration

During initialization, we can pass in some configuration parameters

Vue.use(VueLazyload, {
  preLoad: 1.3,
  error: 'dist/error.png',
  loading: 'dist/loading.gif',
  attempt: 1,
  // the default is ['scroll', 'wheel', 'mousewheel', 'resize', 'animationend', 'transitionend']
  listenEvents: [ 'scroll' ]
})

This is how its internal configuration parameters are handled

this.options = {
  // Do not print breakpoint information
  silent,
  // Trigger event
  dispatchEvent: !!dispatchEvent,
  throttleWait: throttleWait || 200,
  // Preload screen ratio
  preLoad: preLoad || 1.3,
  // Preload pixels
  preLoadTop: preLoadTop || 0,
  // Failure diagram
  error: error || DEFAULT_URL,
  // Loading diagram
  loading: loading || DEFAULT_URL,
  // Failed retries
  attempt: attempt || 3,
  scale: scale || getDPR(scale),
  listenEvents: listenEvents || DEFAULT_EVENTS,
  supportWebp: supportWebp(),
  // filter
  filter: filter || {},
  // Dynamically modify element attributes
  adapter: adapter || {},
  // Use IntersectionObserver
  observer: !!observer || true,
  observerOptions: observerOptions || DEFAULT_OBSERVER_OPTIONS,
};

Monitoring implementation

There are only two

// Two schemes of lazy loading
export const modeType = {
  event: 'event',
  observer: 'observer',
};

It is necessary to judge whether it is compatible with the observer mode

const inBrowser = typeof window !== 'undefined' && window !== null

export const hasIntersectionObserver = checkIntersectionObserver()

function checkIntersectionObserver(): boolean {
  if (inBrowser &&
    'IntersectionObserver' in window &&
    'IntersectionObserverEntry' in window &&
    'intersectionRatio' in window.IntersectionObserverEntry.prototype) {
    // Minimal polyfill for Edge 15's lack of `isIntersecting`
    // See: https://github.com/w3c/IntersectionObserver/issues/211
    if (!('isIntersecting' in window.IntersectionObserverEntry.prototype)) {
      Object.defineProperty(window.IntersectionObserverEntry.prototype,
        'isIntersecting', {
        get: function () {
          return this.intersectionRatio > 0
        }
      })
    }
    return true
  }
  return false
}

Lazy constructor

class Lazy {
  constructor({
    preLoad,
    error,
    throttleWait,
    preLoadTop,
    dispatchEvent,
    loading,
    attempt,
    silent = true,
    scale,
    listenEvents,
    filter,
    adapter,
    observer,
    observerOptions
  }: VueLazyloadOptions) {
    this.lazyContainerMananger = null;
    this.mode = modeType.event;
    // Listening queue, each picture instance
    this.ListenerQueue = [];
    // Act as observation instance ID
    this.TargetIndex = 0;
    // Observe queue, window, or other parent element instances
    this.TargetQueue = [];
    this.options = {
         Omitted above...
    };
    // Initialization event
    this._initEvent();
    // cache
    this._imageCache = new ImageCache(200);
    // View detection
    this.lazyLoadHandler = throttle(
      this._lazyLoadHandler.bind(this),
      this.options.throttleWait!
    );
    // Select lazy loading method
    this.setMode(this.options.observer ? modeType.observer : modeType.event);
  }
}

We analyze their functions step by step. First, there are two maintenance queues

// Listening queue, each picture instance
this.ListenerQueue = [];
// Act as observation instance ID
this.TargetIndex = 0;
// Observe queue, window, or other parent element instances
this.TargetQueue = [];

Listener queue: used to save instances of lazy loaded pictures

TargetQueue: used to save instances of lazy load containers

Publish subscribe event (_initEvent)

Three image loading events are provided by default:

  • loading
  • loaded
  • error
// Provide publish subscribe events
_initEvent() {
  this.Event = {
    listeners: {
      loading: [],
      loaded: [],
      error: [],
    },
  };

  this.$on = (event, func) => {
    if (!this.Event.listeners[event]) this.Event.listeners[event] = [];
    this.Event.listeners[event].push(func);
  };

  this.$off = (event, func) => {
    // No method
    if (!func) {
      // Direct interrupt without event
      if (!this.Event.listeners[event]) return;
      // Otherwise, empty the event queue directly
      this.Event.listeners[event].length = 0;
      return;
    }
    // Clear only specified functions
    remove(this.Event.listeners[event], func);
  };

  this.$once = (event, func) => {
    const on = () => {
      // Trigger immediate removal event once
      this.$off(event, on);
      func.apply(this, arguments);
    };
    this.$on(event, on);
  };

  this.$emit = (event, context, inCache) => {
    if (!this.Event.listeners[event]) return;
    // Traversal event triggered by all listening methods
    this.Event.listeners[event].forEach((func) => func(context, inCache));
  };
}

The basic code is relatively simple. There is a remove function, which is mainly used to remove instances from the queue

function remove(arr: Array<any>, item: any) {
  if (!arr.length) return;
  const index = arr.indexOf(item);
  if (index > -1) return arr.splice(index, 1);
}

Picture cache

Cache processing is performed by default during initialization

this._imageCache = new ImageCache(200);

The implementation is also relatively simple

class ImageCache {
  max: number;
  _caches: Array<string>;
  constructor(max: number) {
    this.max = max || 100
    this._caches = []
  }

  has(key: string): boolean {
    return this._caches.indexOf(key) > -1;
  }

  // A unique index value is required
  add(key: string) {
    // Prevent duplicate | invalid addition
    if (!key || this.has(key)) return;
    this._caches.push(key);
    // Remove oldest pictures over limit
    if (this._caches.length > this.max) {
      this.free();
    }
  }

  // fifo
  free() {
    this._caches.shift();
  }
}

View detection (lazyLoadHandler)

Throttling has been automatically added during initialization to reduce the trigger frequency. The default value is 200

// View detection
this.lazyLoadHandler = throttle(
  this._lazyLoadHandler.bind(this),
  this.options.throttleWait!
);

The main functions are

  • Detect whether it is in the view
  • Whether to trigger picture loading logic
  • Clean up queue useless instances
/**
 * find nodes which in viewport and trigger load
 * @return
 */
_lazyLoadHandler() {
  // Nodes to be cleaned up
  const freeList: Array<Tlistener> = []

  this.ListenerQueue.forEach((listener) => {
    // No DOM node 𞓜 no parent node | DOM has been loaded
    if (!listener.el || !listener.el.parentNode || listener.state.loaded) {
      freeList.push(listener)
    }
    // Detect whether it is within the visual view
    const catIn = listener.checkInView();
    if (!catIn) return;
    // If it is not loaded in the view
    if (!listener.state.loaded) listener.load()
  });

  // Useless node instance removal
  freeList.forEach((item) => {
    remove(this.ListenerQueue, item);
    // Manually destroy the association between vm instance and DOM
    item.$destroy && item.$destroy()
  });
}

Select lazy loading mode (setMode)

Initialize calling function

// Select lazy loading method
this.setMode(this.options.observer ? modeType.observer : modeType.event);

Main functions:

  • Graceful degradation is required when using the observer mode
  • If you use observer, remove the event logic and instantiate it
  • If you use event, remove the observation and bind the event
setMode(mode: string) {
  // Incompatible degradation scheme
  if (!hasIntersectionObserver && mode === modeType.observer) {
    mode = modeType.event;
  }

  this.mode = mode; // event or observer
  if (mode === modeType.event) {
    if (this._observer) {
      // Remove all observations from the event queue
      this.ListenerQueue.forEach((listener) => {
        this._observer!.unobserve(listener.el);
      });
      // Remove observation
      this._observer = null;
    }
    // Add event
    this.TargetQueue.forEach((target) => {
      this._initListen(target.el, true);
    });
  } else {
    // Remove event queue
    this.TargetQueue.forEach((target) => {
      this._initListen(target.el, false);
    });
    // IntersectionObserver instantiation
    this._initIntersectionObserver();
  }
}

Event binding mode (_initListen)

The default events are

const DEFAULT_EVENTS = [
  'scroll',
  'wheel',
  'mousewheel',
  'resize',
  'animationend',
  'transitionend',
  'touchmove',
];

I understand everything below

/*
  * add or remove eventlistener
  * @param  {DOM} el DOM or Window
  * @param  {boolean} start flag
  * @return
  */
_initListen(el: HTMLElement, start: boolean) {
  this.options.listenEvents!.forEach((evt) => _[start ? 'on' : 'off'](el, evt, this.lazyLoadHandler))
}
const _ = {
  on(el: Element, type: string, func: () => void, capture = false) {
    el.addEventListener(type, func, {
      capture: capture,
      passive: true
    })
  },
  off(el: Element, type: string, func: () => void, capture = false) {
    el.removeEventListener(type, func, capture)
  }
}

The role of passive is described as follows

passive: Boolean. When set to true, it means that the listener will never call preventDefault(). If the listener still calls this function, the client will ignore it and throw a console warning.

According to the specification, the default value of the passive option is always false. However, this introduces the possibility that event listeners handling some touch events (and others) may block the browser's main thread when trying to handle scrolling, resulting in a significant reduction in performance during scrolling processing.

To prevent this problem, some browsers (especially Chrome and Firefox) have changed the default value of the passive option of the touchstart (EN US) and touchmove (EN US) events of the Document level nodes Window, Document and Document.body to true. This prevents the event listener from being called, so the page cannot be prevented from rendering when the user scrolls.

Observer initialization (_initIntersectionObserver)

If parameters are not transferred, there are default parameters

const DEFAULT_OBSERVER_OPTIONS = {
  rootMargin: '0px',
  threshold: 0,
};

Next, instantiate, and then add all lazy loaded image instances to the observation

/**
 * init IntersectionObserver
 * set mode to observer
 * @return
 */
_initIntersectionObserver() {
  if (!hasIntersectionObserver) return

  this._observer = new IntersectionObserver(
    // callback is usually triggered twice. One is when the target element just enters the viewport (becomes visible), and the other is when it leaves the viewport completely (becomes invisible).
    this._observerHandler.bind(this),
    this.options.observerOptions
  );

  // Load queue all data into observation
  if (this.ListenerQueue.length) {
    // All elements of the list are added to the observation
    this.ListenerQueue.forEach((listener) => {
      // Start looking at elements
      this._observer!.observe(listener.ell as Element);
    });
  }
}

Operation of callback function

/**
 * init IntersectionObserver
 * Traverse and compare trigger elements and listening elements. If the loading is completed, remove the observation, otherwise start loading
 * @return
 */
_observerHandler(entries: Array<IntersectionObserverEntry>) {
  entries.forEach((entry) => {
    // Does the visibility of the target element in the root element change
    // If isIntersecting is true, the of the target element has reached at least one of the thresholds specified in the values of the thresholds attribute. If it is false, the target element is not visible within the given threshold range.
    if (entry.isIntersecting) {
      this.ListenerQueue.forEach((listener) => {
        // The trigger element in the container element matches the queue element
        if (listener.el === entry.target) {
          // Remove when loading is complete
          if (listener.state.loaded) return this._observer!.unobserve(listener.el as Element);
          // Load
          listener.load();
        }
      });
    }
  });
}

The data obtained by callback is as follows:

Picture address specification function (_valueFormatter)

/**
 * generate loading loaded error image url
 * @param {string} image's src
 * @return {object} image's loading, loaded, error url
 */
_valueFormatter(value) {
  let src = value;
  // The picture in case of loading / error. If there is no picture, it will be taken as the default
  let { loading, error, cors } = this.options;

  // value is object
  if (isObject(value)) {
    if (!value.src && !this.options.silent) console.error('Vue Lazyload Next warning: miss src with ' + value)
    src = value.src;
    loading = value.loading || this.options.loading;
    error = value.error || this.options.error;
  }

  return {
    src,
    loading,
    error,
    cors
  };
}

Search for the corresponding picture address (getBestSelectionFromSrcset)

Filter invalid instances and integrate parameters

// Filter final replacement picture address
function getBestSelectionFromSrcset(el: Element, scale: number): string {
  // Non IMG tag or no responsive attribute
  if (el.tagName !== 'IMG' || !el.getAttribute('data-srcset')) return '';

  // For example, "img. 400px. JPG 400W, img. 800px. JPG" = > ['img. 400px. JPG 400W ',' img. 800px. JPG ']
  let options = el.getAttribute('data-srcset')!.trim().split(',');
  const result: Array<[tmpWidth: number, tmpSrc: string]> = []
  // Parent element
  const container = el.parentNode as HTMLElement;
  const containerWidth = container.offsetWidth * scale;

  let spaceIndex: number;
  let tmpSrc: string;
  let tmpWidth: number;
  // ......
}

Convert address and width

// Filter final replacement picture address
function getBestSelectionFromSrcset(el: Element, scale: number): string {
  // ......
  options.forEach((item) => {
    item = item.trim();
    spaceIndex = item.lastIndexOf(' ');
    // If the width is not specified, it will be 99999 by default
    if (spaceIndex === -1) {
      tmpSrc = item;
      tmpWidth = 99999;
    } else {
      tmpSrc = item.substr(0, spaceIndex);
      tmpWidth = parseInt(
        item.substr(spaceIndex + 1, item.length - spaceIndex - 2),
        10
      );
    }
    return [tmpWidth, tmpSrc];
  });
}

Get as follows

/*
  obtain
  [
    [400, 'img.400px.jpg''],
    [99999, 'img.800px.jpg']
  ]
*/

Sort

// Filter final replacement picture address
function getBestSelectionFromSrcset(el: Element, scale: number): string {
  // ......
  // Width first, webp latter first
  result.sort(function (a, b) {
    if (a[0] < b[0]) return 1;
    if (a[0] > b[0]) return -1;
    if (a[0] === b[0]) {
      if (b[1].indexOf('.webp', b[1].length - 5) !== -1) {
        return 1;
      }
      if (a[1].indexOf('.webp', a[1].length - 5) !== -1) {
        return -1;
      }
    }
    return 0;
  });
}

Get the final address

// Filter final replacement picture address
function getBestSelectionFromSrcset(el: Element, scale: number): string {
  // ......
  let bestSelectedSrc = '';
  let tmpOption;
  for (let i = 0; i < result.length; i++) {
    tmpOption = result[i];
    bestSelectedSrc = tmpOption[1];
    const next = result[i + 1];
    // Determine which response graph to load
    if (next && next[0] < containerWidth) {
      bestSelectedSrc = tmpOption[1];
      break;
    } else if (!next) {
      bestSelectedSrc = tmpOption[1];
      break;
    }
  }

  // Returns the final image
  return bestSelectedSrc;
}

We can look directly at the official example usage

<template>
  <div ref="container">
    <img v-lazy="'img.400px.jpg'" data-srcset="img.400px.jpg 400w, img.800px.jpg 800w, img.1200px.jpg 1200w">
  </div>
</template>

Search for parent scrollParent

// Find the parent element of a scrolling element
const scrollParent = (el: HTMLElement) => {
  if (!inBrowser) return
  if (!(el instanceof Element)) {
    return window
  }

  let parent = el

  while (parent) {
    // body, html, or break without a parent element
    if (parent === document.body || parent === document.documentElement || !parent.parentNode) break
    // If the attribute corresponding to overflow is set, the parent element is returned
    if (/(scroll|auto)/.test(overflow(parent))) return parent
    // Recursion allows the parent element to judge
    parent = parent.parentNode as HTMLElement
  }

  return window
}

Response object (ReactiveListener)

It is mainly used to convert lazy loaded images into an instance object

Basic properties

export default class ReactiveListener {
  constructor({
    el,
    src,
    error,
    loading,
    bindType,
    $parent,
    options,
    cors,
    elRenderer,
    imageCache
  }) {
    this.el = el;
    this.src = src;
    this.error = error;
    this.loading = loading;
    // The style attribute of the instruction transfer, such as background image
    this.bindType = bindType;
    // Reconnection times
    this.attempt = 0;
    this.cors = cors;

    this.naturalHeight = 0;
    this.naturalWidth = 0;

    this.options = options;

    this.rect = {} as DOMRect;

    this.$parent = $parent;
    // Calling lazy_ elRenderer method, set src and trigger the corresponding event
    this.elRenderer = elRenderer;
    this._imageCache = imageCache;
    // computing time
    this.performanceData = {
      loadStart: 0,
      loadEnd: 0
    };

    this.filter();
    this.initState();
    this.render('loading', false);
  }
}

Execute filter

/*
  * listener filter
  */
filter() {
  // Perform filter operation
  Object.keys(this.options.filter).forEach((key) => {
    this.options.filter[key](this, this.options);
  });
}

Initialize picture state (initState)

/*
  * init listener state
  * @return
  */
initState() {
  // The HTMLElement.dataset attribute allows you to read and write all custom data attribute (data - *) sets set on elements in HTML or DOM.
  if ('dataset' in this.el!) {
    this.el.dataset.src = this.src;
  } else {
    this.el!.setAttribute('data-src', this.src);
  }

  this.state = {
    loading: false,
    error: false,
    loaded: false,
    rendered: false,
  };
}

Update status (update)

If the parameters are changed, all processes need to be updated again

/*
  * update image listener data
  * @param  {String} image uri
  * @param  {String} loading image uri
  * @param  {String} error image uri
  * @return
  */
update(option: { src: string, loading: string, error: string }) {
  const oldSrc = this.src;
  // Update address
  this.src = option.src;
  this.loading = option.loading;
  this.error = option.error;
  // Re execute the filter
  this.filter();
  // Reset the status after the old and new addresses are different
  if (oldSrc !== this.src) {
    this.attempt = 0;
    this.initState();
  }
}

Check in view

/*
  * get el node rect
  * @return
  */
getRect() {
  this.rect = this.el!.getBoundingClientRect();
}

/*
  * check el is in view
  * @return {Boolean} el is in view
  */
checkInView() {
  this.getRect();
  return (
    this.rect.top < window.innerHeight * this.options.preLoad! &&
    this.rect.bottom > this.options.preLoadTop! &&
    this.rect.left < window.innerWidth * this.options.preLoad! &&
    this.rect.right > 0
  );
}

Render function (render)

/*
  * render image
  * @param  {String} state to render // ['loading', 'src', 'error']
  * @param  {String} is form cache
  * @return
  */
render(state: string, cache: boolean) {
  this.elRenderer(this, state, cache);
}

The call here is lazy's_ elRenderer method, set src and trigger the corresponding event

Rendering Loading

/*
  * render loading first
  * @params cb:Function
  * @return
  */
renderLoading(cb: Function) {
  this.state.loading = true;
  loadImageAsync(
    {
      src: this.loading,
      cors: this.cors,
    },
    () => {
      this.render('loading', false);
      this.state.loading = false;
      cb();
    },
    () => {
      // handler `loading image` load failed
      cb();
      this.state.loading = false;
    }
  );
}

Load the loading picture first, and then continue to load the real picture after success

Execute load

/*
  * try load image and  render it
  * @return
  */
load(onFinish = noop) {
  // Loading failed
  if (this.attempt > this.options.attempt! - 1 && this.state.error) {
    onFinish();
    return;
  }
  // Interrupt loaded
  if (this.state.rendered && this.state.loaded) return;

  // Cached interrupts
  if (this._imageCache.has(this.src as string)) {
    this.state.loaded = true;
    this.render('loaded', true);
    this.state.rendered = true;
    return onFinish();
  }
  // Omit
}

The above code mainly makes three judgments

  • Interrupt in case of continuous failure
  • Interrupt when loaded
  • Break when cached
/*
  * try load image and  render it
  * @return
  */
load(onFinish = noop) {
  // Omit

  this.renderLoading(() => {
    this.attempt
    // Dynamically modify element attributes
    this.options.adapter.beforeLoad && this.options.adapter.beforeLoad(this, this.options)
    // Record start time
    this.record('loadStart');

    loadImageAsync(
      {
        src: this.src,
        cors: this.cors,
      },
      (data: {
        naturalHeight: number;
        naturalWidth: number
        src: string;
      }) => {
        // Record size
        this.naturalHeight = data.naturalHeight;
        this.naturalWidth = data.naturalWidth;
        // modify state
        this.state.loaded = true;
        this.state.error = false;
        // Record loading time
        this.record('loadEnd');
        // Render view 
        this.render('loaded', false);
        this.state.rendered = true;
        // Record cache
        this._imageCache.add(this.src);
        onFinish();
      },
      (err: Error) => {
        this.state.error = true;
        this.state.loaded = false;
        this.render('error', false);
      }
    );
  });
}

After Loading succeeds, the status will be modified, and then the record cache will execute the callback

record, performance

Internally calculated functions

/*
  * record performance
  * @return
  */
record(event: 'loadStart' | 'loadEnd') {
  this.performanceData[event] = Date.now();
}

Exposed to the external query function and returns the specification object

/*
  * output performance data
  * @return {Object} performance data
  */
performance() {
  let state = 'loading';
  let time = 0;

  if (this.state.loaded) {
    state = 'loaded';
    time = (this.performanceData.loadEnd - this.performanceData.loadStart) / 1000;
  }

  if (this.state.error) state = 'error';

  return {
    src: this.src,
    state,
    time,
  };
}

Manually destroy the instance ($destroy)

/*
  * $destroy
  * @return
  */
$destroy() {
  this.el = null;
  this.src = '';
  this.error = null;
  this.loading = '';
  this.bindType = null;
  this.attempt = 0;
}

Print record (performance)

/**
 * output listener's load performance
 * @return {Array}
 */
performance() {
  const list: Array<VueReactiveListener> = []
  this.ListenerQueue.map(item => list.push(item.performance()))
  return list
}

Set picture address and status (_elRenderer)

/**
 * set element attribute with image'url and state
 * @param  {object} lazyload listener object
 * @param  {string} state will be rendered
 * @param  {bool} inCache  is rendered from cache
 * @return
 */
_elRenderer(listener: ReactiveListener, state: TeventType, cache: boolean) {
  if (!listener.el) return;
  const { el, bindType } = listener;
  // Determines the render state address
  let src;
  switch (state) {
    case 'loading':
      src = listener.loading;
      break;
    case 'error':
      src = listener.error;
      break;
    default:
      src = listener.src
      break;
  }

  // Use the command to set the background directly
  if (bindType) {
    el.style[bindType] = 'url("' + src + '")';
  } else if (el.getAttribute('src') !== src) { // Use properties to set values
    el.setAttribute('src', src);
  }

  // Modify status attribute value
  el.setAttribute('lazy', state);

  // Publish corresponding events
  this.$emit(state, listener, cache);

  // Dynamically modify element properties (configure incoming)
  this.options.adapter[state] &&
    this.options.adapter[state](listener, this.options);

  // Trigger element event (configuration passed in true)
  if (this.options.dispatchEvent) {
    // Create a custom event
    const event = new CustomEvent(state, {
      detail: listener,
    });
    el.dispatchEvent(event);
  }
}

Main process:

  1. Rendering is determined by the state
  2. Set to style or property
  3. Record current state to element
  4. Publish corresponding events
  5. Execute dynamic modification function (if configured)
  6. Whether to trigger custom events (if true is selected for configuration)

You can see how to use dynamically modified functions from official examples

Vue.use(vueLazy, {
    adapter: {
        loaded ({ bindType, el, naturalHeight, naturalWidth, $parent, src, loading, error, Init }) {
            // do something here
            // example for call LoadedHandler
            LoadedHandler(el)
        },
        loading (listender, Init) {
            console.log('loading')
        },
        error (listender, Init) {
            console.log('error')
        }
    }
})

lazy instruction trigger function

When we review the entry, the lazy instruction performs different operations on different hooks

app.directive('lazy', {
  // Keep pointing
  beforeMount: lazy.add.bind(lazy),
  beforeUpdate: lazy.update.bind(lazy),
  updated: lazy.lazyLoadHandler.bind(lazy),
  unmounted: lazy.remove.bind(lazy)
});

Add lazy load instance to queue (add)

There are a lot of codes. We will disassemble the whole function step by step

Judge whether the queue and specification parameters already exist

/*
  * add image listener to queue
  * @param  {DOM} el
  * @param  {object} binding vue directive binding
  * @param  {vnode} vnode vue directive vnode
  * @return
  */
add(el: HTMLElement, binding: DirectiveBinding, vnode: VNode) {
  // If in the listening queue
  if (this.ListenerQueue.some((item) => item.el === el)) {
    // Update instance
    this.update(el, binding);
    // Perform detection in the next update cycle
    return nextTick(this.lazyLoadHandler);
  }

  // Canonical format
  let { src, loading, error, cors } = this._valueFormatter(binding.value)
  
  nextTick(() => {
      // Omit
  }  
}

Add lazy load instance (addLazyBox)

/*
  * add lazy component to queue
  * @param  {Vue} vm lazy component instance
  * @return
  */
addLazyBox(vm: Tlistener) {
  // Add to listening queue
  this.ListenerQueue.push(vm);
  if (inBrowser) {
    // Add observation queue
    this._addListenerTarget(window);
    // If there are observation objects, add observation
    this._observer?.observe(vm.el);
    // The observation queue is also added if the parent element exists
    if (vm.$el?.parentNode) {
      this._addListenerTarget(vm.$el.parentNode);
    }
  }
}

The added method is shown below

/*
  * add listener target
  * @param  {DOM} el listener target
  * @return
  */
_addListenerTarget(el: HTMLElement | Window) {
  if (!el) return;
  // Find if the target already exists
  let target = this.TargetQueue.find((target) => target.el === el);
  if (!target) {
    // Initialization structure
    target = {
      el,
      id: ++this.TargetIndex,
      childrenCount: 1,
      listened: true,
    };
    // Bind using event mode
    this.mode === modeType.event && this._initListen(target.el, true);
    this.TargetQueue.push(target);
  } else {
    // Number of child elements plus 1
    target.childrenCount++;
  }
  // Returns the index value of the current target element
  return this.TargetIndex;
}

The reason why the parent rolling element of each lazy loading instance is added to the queue is to avoid multiple initialization operations on the same element

The number of child elements is recorded to ensure that the parent element will not be directly deleted when the lazy loading instance is removed, resulting in other instances being affected

Find container element and corresponding address

We can know what the instruction attributes are

parameterdescribe
instanceComponent instance using directive
valueThe value passed to the instruction. For example, in v-my-directive="1 + 1", the value is 2
oldValuePrevious values, available only in beforeUpdate and updated. Whether the value has changed or not is available
argParameters are passed to the instruction, if any. For example, in v-my-directive:foo, arg is "foo"
modifiersThe object that contains the modifier, if any. For example, in v-my-directive.foo.bar, the modifier object is {foo: true, bar: true}
dirAn object that is passed as a parameter when registering an instruction. For example, in the following instructions
/*
  * add image listener to queue
  * @param  {DOM} el
  * @param  {object} binding vue directive binding
  * @param  {vnode} vnode vue directive vnode
  * @return
  */
add(el: HTMLElement, binding: DirectiveBinding, vnode: VNode) {
  // Omit
  nextTick(() => {
    // Get the corresponding responsive picture address
    src = getBestSelectionFromSrcset(el, this.options.scale as number) || src;
    // If it exists, add observation
    this._observer?.observe(el);
    // Gets the modifier object, if any
    const container: string = Object.keys(binding.modifiers)[0];
    // Parent scroll element
    let $parent: any;

    if (container) {
      $parent = binding.instance!.$refs[container]
      // if there is container passed in, try ref first, then fallback to getElementById to support the original usage
      $parent = $parent
        ? $parent.$el || $parent
        : document.getElementById(container);
    }

    // Find parent element
    if (!$parent) {
      $parent = scrollParent(el);
    }
  }  
}

There may be questions about the modifier object. You can see its usage from the official demo

<template>
  <div ref="container">
     <!-- Customer scrollable element -->
     <img v-lazy.container ="imgUrl"/>
     <div v-lazy:background-image.container="img"></div>
  </div>
</template>

Attach a dom ref name or dom ID to the instruction. If there are multiple instructions, only the first one will be taken by default, and then look up the upper layer from the component instance using the instruction

If you can't find it, search directly from the dom element

Generate a response object, join the queue and detect the view

/*
  * add image listener to queue
  * @param  {DOM} el
  * @param  {object} binding vue directive binding
  * @param  {vnode} vnode vue directive vnode
  * @return
  */
add(el: HTMLElement, binding: DirectiveBinding, vnode: VNode) {
  // Omit
  nextTick(() => {
        // Omit
      const newListener = new ReactiveListener({
        el,
        src,
        error,
        loading,
        bindType: binding.arg!,
        $parent,
        options: this.options,
        cors,
        elRenderer: this._elRenderer.bind(this),
        imageCache: this._imageCache
      });

      // Join event queue
      this.ListenerQueue.push(newListener);

      // Join the observation queue
      if (inBrowser) {
        this._addListenerTarget(window);
        this._addListenerTarget($parent);
      }

      nextTick(() => this.lazyLoadHandler());
  }  
}

Finally, I did three things

  1. Generate a response object and join the listening queue
  2. Add window and parent scrolling elements to the observation queue, where repeated additions will be filtered
  3. Trigger a view detection after the next Dom update

Update instance address (update)

/**
 * update image src
 * @param  {DOM} el
 * @param  {object} vue directive binding
 * @return
 */
update(el: HTMLElement, binding: DirectiveBinding, vnode?: VNode) {
  let { src, loading, error } = this._valueFormatter(binding.value)
  // Get the corresponding responsive picture address
  src = getBestSelectionFromSrcset(el, this.options.scale!) || src;

  const exist = this.ListenerQueue.find((item) => item.el === el);
  if (!exist) {
    // If it does not exist, it is added to the queue
    this.add(el, binding, vnode!);
  } else {
    // Update address if it already exists
    exist.update({
      src,
      error,
      loading
    });
  }
  // Rebind observation
  if (this._observer) {
    this._observer.unobserve(el);
    this._observer.observe(el);
  }
  // After the next DOM update cycle ends, a deferred callback is executed for detection
  nextTick(() => this.lazyLoadHandler());
}

Main process

  1. Get final render address
  2. Check whether it exists in the queue and decide to add or modify it directly
  3. Re perform observation
  4. After the next DOM update cycle ends, a deferred callback is executed for detection

Remove instance from listening queue (remove)

/**
 * remove listener form list
 * @param  {DOM} el
 * @return
 */
remove(el: HTMLElement) {
  // There is no direct interrupt
  if (!el) return;
  // Remove observation
  this._observer?.unobserve(el);
  const existItem = this.ListenerQueue.find((item) => item.el === el);
  if (existItem) {
    // Reduce the number of childrenCount. If it is 0, the corresponding event and TargetQueue instance will be removed
    this._removeListenerTarget(existItem.$parent);
    this._removeListenerTarget(window);
    // Remove from queue
    remove(this.ListenerQueue, existItem);
    // Manual destruction
    existItem.$destroy && existItem.$destroy()
  }
}

Among them_ removeListenerTarget will be parsed below

Remove instance from observation queue (_removeListenerTarget)

/*
  * remove listener target or reduce target childrenCount
  * @param  {DOM} el or window
  * @return
  */
_removeListenerTarget(el: HTMLElement | Window & typeof globalThis) {
  this.TargetQueue.forEach((target, index) => {
    // If it matches the queue data
    if (target!.el === el) {
      // Sub quantity - 1
      target.childrenCount--;
      // Already 0
      if (!target.childrenCount) {
        // Remove event
        this._initListen(target.el, false);
        // Remove target from observation queue
        this.TargetQueue.splice(index, 1);
        target = null;
      }
    }
  });
}

Remove component

/*
  * remove lazy components form list
  * @param  {Vue} vm Vue instance
  * @return
  */
removeComponent(vm: Tlistener) {
  if (!vm) return
  // Remove target from queue
  remove(this.ListenerQueue, vm)
  // If there are observation objects, remove the observation
  this._observer?.unobserve(vm.el);
  // Nodes that have parent elements are also removed
  if (vm.$parent && vm.$el.parentNode) {
    this._removeListenerTarget(vm.$el.parentNode)
  }
  this._removeListenerTarget(window)
}

Lazy image component (lazy image)

Component basic input properties

export default (lazy: Lazy) => {
  return defineComponent({
    props: {
      src: [String, Object],
      tag: {
        type: String,
        default: 'img'
      }
    },

    setup(props, { slots }) {
      const el: Ref = ref(null)
      // to configure
      const options = reactive({
        src: '',
        error: '',
        loading: '',
        attempt: lazy.options.attempt
      })
      // state
      const state = reactive({
        loaded: false,
        error: false,
        attempt: 0
      })
      const renderSrc: Ref = ref('')
      const { rect, checkInView } = useCheckInView(el, lazy.options.preLoad!)

      // Generate standardized instance object
      const vm = computed(() => {
        return {
          el: el.value,
          rect,
          checkInView,
          load,
          state,
        }
      })

      // Initialize the corresponding picture address in various states
      const init = () => {
        const { src, loading, error } = lazy._valueFormatter(props.src)
        state.loaded = false
        options.src = src
        options.error = error!
        options.loading = loading!
        renderSrc.value = options.loading
      }
      init()

      return () => createVNode(
        props.tag,
        {
          src: renderSrc.value,
          ref: el
        },
        [slots.default?.()]
      )
    }
  })
}

Load function

export default (lazy: Lazy) => {
  return defineComponent({
    setup(props, { slots }) {
      // Omit
      const load = (onFinish = noop) => {
        // Failed retries
        if ((state.attempt > options.attempt! - 1) && state.error) {
          onFinish()
          return
        }
        const src = options.src
        loadImageAsync({ src }, ({ src }: loadImageAsyncOption) => {
          renderSrc.value = src
          state.loaded = true
        }, () => {
          state.attempt++
          renderSrc.value = options.error
          state.error = true
        })
      }
    }
  })
}

Trigger event

export default (lazy: Lazy) => {
  return defineComponent({
    setup(props, { slots }) {
      // Omit
      
      // Address modification re execution process
      watch(
        () => props.src,
        () => {
          init()
          lazy.addLazyBox(vm.value)
          lazy.lazyLoadHandler()
        }
      )
      onMounted(() => {
        // Save to event queue
        lazy.addLazyBox(vm.value)
        // Perform a view check immediately
        lazy.lazyLoadHandler()
      })
      onUnmounted(() => {
        lazy.removeComponent(vm.value)
      })
    }
  })
}

Lazy component

export default (lazy: Lazy) => {
  return defineComponent({
    props: {
      tag: {
        type: String,
        default: 'div'
      }
    },
    emits: ['show'],
    setup(props, { emit, slots }) {
      const el: Ref = ref(null)
      const state = reactive({
        loaded: false,
        error: false,
        attempt: 0
      })
      const show = ref(false)
      const { rect, checkInView } = useCheckInView(el, lazy.options.preLoad!)

      // Notify parent component
      const load = () => {
        show.value = true
        state.loaded = true
        emit('show', show.value)
      }

      // Standardize instance objects
      const vm = computed(() => {
        return {
          el: el.value,
          rect,
          checkInView,
          load,
          state,
        }
      })

      onMounted(() => {
        // Save to event queue
        lazy.addLazyBox(vm.value)
        // Perform a view check immediately
        lazy.lazyLoadHandler()
      })

      onUnmounted(() => {
        lazy.removeComponent(vm.value)
      })

      return () => createVNode(
        props.tag,
        {
          ref: el
        },
        [show.value && slots.default?.()]
      )
    }
  })
}

The main difference from the picture component is that the loading function directly notifies the parent element and only records the status itself

Lazy container instruction

Let's look back at the code related to this entry

export const Lazyload = {
  /*
   * install function
   * @param  {App} app
   * @param  {object} options lazyload options
   */
  install(app: App, options: VueLazyloadOptions = {}) {
    // Omit
    const lazyContainer = new LazyContainer(lazy)
    
    app.directive('lazy-container', {
      beforeMount: lazyContainer.bind.bind(lazyContainer),
      updated: lazyContainer.update.bind(lazyContainer),
      unmounted: lazyContainer.unbind.bind(lazyContainer),
    });
  }
}

After the instance is generated, the corresponding method will be called in the hook function of the lazy container instruction

Lazy container implementation

// Lazy load container management
export default class LazyContainerManager {
  constructor(lazy: Lazy) {
    // Save lazy point
    this.lazy = lazy;
    // Save management point
    lazy.lazyContainerMananger = this
    // Maintenance queue
    this._queue = [];
  }

  bind(el: HTMLElement, binding: DirectiveBinding, vnode: VNode) {
    const container = new LazyContainer(
      el,
      binding,
      vnode,
      this.lazy,
    );
    // Save and load container instances
    this._queue.push(container);
  }

  update(el: HTMLElement, binding: DirectiveBinding, vnode?: VNode) {
    const container = this._queue.find((item) => item.el === el);
    if (!container) return;
    container.update(el, binding);
  }

  unbind(el: HTMLElement, binding?: DirectiveBinding, vnode?: VNode) {
    const container = this._queue.find((item) => item.el === el);
    if (!container) return;
    // Empty status
    container.clear();
    // Remove instance 
    remove(this._queue, container);
  }
}

This is a globally unified container instance management component with only three functions

  • Before mounting, save the container component as a LazyContainer instance and save it in the queue maintenance
  • After the update, the queue instance properties are updated
  • Clear the state and remove the instance before destroying

LazyContainer class

class LazyContainer {
  constructor(el: HTMLElement, binding: DirectiveBinding, vnode: VNode, lazy: Lazy) {
    this.el = null;
    this.vnode = vnode;
    this.binding = binding;
    this.options = {} as DefaultOptions;
    this.lazy = lazy;

    this._queue = [];
    this.update(el, binding);
  }

  update(el: HTMLElement, binding: DirectiveBinding) {
    this.el = el;
    this.options = Object.assign({}, defaultOptions, binding.value);

    // All pictures under the component are added to the lazy load queue
    const imgs = this.getImgs();
    imgs.forEach((el: HTMLElement) => {
      this.lazy!.add(
        el,
        Object.assign({}, this.binding, {
          value: {
            src: el.getAttribute('data-src') || el.dataset.src,
            error: el.getAttribute('data-error') || el.dataset.error || this.options.error,
            loading: el.getAttribute('data-loading') || el.dataset.loading || this.options.loading
          },
        }),
        this.vnode as VNode
      );
    });
  }

  getImgs(): Array<HTMLElement> {
    return Array.from(this.el!.querySelectorAll(this.options.selector));
  }

  clear() {
    const imgs = this.getImgs();
    imgs.forEach((el) => this.lazy!.remove(el));

    this.vnode = null;
    this.binding = null;
    this.lazy = null;
  }
}

There are only two functions

  • When updating, retrieve all the corresponding tags in the container, sort out the parameters and pass them into the lzay queue, which will judge whether to add or update
  • During component cleanup, all instances of corresponding tags in the lzay lazy load queue will be removed from the container

Tags: Javascript Front-end html5 Vue.js

Posted on Fri, 19 Nov 2021 23:57:58 -0500 by leela