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
parameter | describe |
---|---|
root | Specify 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. |
rootMargin | The 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. |
threshold | It 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:
- Rendering is determined by the state
- Set to style or property
- Record current state to element
- Publish corresponding events
- Execute dynamic modification function (if configured)
- 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
parameter | describe |
---|---|
instance | Component instance using directive |
value | The value passed to the instruction. For example, in v-my-directive="1 + 1", the value is 2 |
oldValue | Previous values, available only in beforeUpdate and updated. Whether the value has changed or not is available |
arg | Parameters are passed to the instruction, if any. For example, in v-my-directive:foo, arg is "foo" |
modifiers | The object that contains the modifier, if any. For example, in v-my-directive.foo.bar, the modifier object is {foo: true, bar: true} |
dir | An 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
- Generate a response object and join the listening queue
- Add window and parent scrolling elements to the observation queue, where repeated additions will be filtered
- 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
- Get final render address
- Check whether it exists in the queue and decide to add or modify it directly
- Re perform observation
- 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