maciekgrzybek / svelte-inview

A Svelte action that monitors an element enters or leaves the viewport.🔥
MIT License
749 stars 23 forks source link

Many IntersectObserver object created #22

Open izderadicka opened 2 years ago

izderadicka commented 2 years ago

I have been looking into code and if I understand code correctly new IntersectObserver is created for every component, that has inview action.

My scenario has one root element which has many (thousands) of child elements/components , which lazy load an image, when they get into view.

As per this article: https://www.bennadel.com/blog/3954-intersectionobserver-api-performance-many-vs-shared-in-angular-11-0-5.htm it looks that there is notable performance advantage for single IntersectObserver having many observed elements, comparing to many IntersectObservers, each one just with one observed element.

What do you think?

maciekgrzybek commented 2 years ago

Hey @izderadicka thanks for that insight. I'll take a look and get back to you :)

brahma-dev commented 1 year ago

Quick and dirty fix.

export const inview = (node: HTMLElement, options = {}) => {
    //adapted from https://github.com/maciekgrzybek/svelte-inview
    let defaultOptions = {
        root: null,
        rootMargin: '0px',
        threshold: 0,
        unobserveOnEnter: false,
    }
    const { root, rootMargin, threshold, unobserveOnEnter } = {
        ...defaultOptions,
        ...options,
    };

    let prevPos = {
        x: undefined,
        y: undefined,
    };

    let scrollDirection: ScrollDirection = {
        vertical: undefined,
        horizontal: undefined,
    };

    if (typeof IntersectionObserver !== 'undefined' && node) {
        // Global variable to hold observers;
        window._inview_observers = window._inview_observers || {};

        // Unique id for each observer (defaults to root for viewport)
        let observerid = "root";
        if (root != null) {
            observerid = root.getAttribute("data-svelte-inview-id");
            if (!observerid) {
                observerid = "_" + Math.floor(Math.random() * 100000);
                root.setAttribute("data-svelte-inview-id", observerid);
            }
        }
        window._inview_observers[observerid] = window._inview_observers[observerid] || new IntersectionObserver(
            (entries, _observer) => {
                entries.forEach((singleEntry) => {
                    if (prevPos.y > singleEntry.boundingClientRect.y) {
                        scrollDirection.vertical = 'up';
                    } else {
                        scrollDirection.vertical = 'down';
                    }

                    if (prevPos.x > singleEntry.boundingClientRect.x) {
                        scrollDirection.horizontal = 'left';
                    } else {
                        scrollDirection.horizontal = 'right';
                    }

                    prevPos = {
                        y: singleEntry.boundingClientRect.y,
                        x: singleEntry.boundingClientRect.x,
                    };

                    const detail = {
                        inView: singleEntry.isIntersecting,
                        entry: singleEntry,
                        scrollDirection,
                        node: singleEntry.target,
                        observer: _observer,
                    };

                    singleEntry.target.dispatchEvent(new CustomEvent('change', { detail }));

                    if (singleEntry.isIntersecting) {
                        singleEntry.target.dispatchEvent(new CustomEvent('enter', { detail }));

                        unobserveOnEnter && _observer.unobserve(node);
                    } else {
                        singleEntry.target.dispatchEvent(new CustomEvent('leave', { detail }));
                    }
                });
            },
            {
                root,
                rootMargin,
                threshold,
            }
        );

        // This dispatcher has to be wrapped in setTimeout, as it won't work otherwise.
        // Not sure why is it happening, maybe a callstack has to pass between the listeners?
        // Definitely something to investigate to understand better.
        setTimeout(() => {
            node.dispatchEvent(
                new CustomEvent('init', { detail: { observer: window._inview_observers[observerid], node } })
            );
        }, 0);

        window._inview_observers[observerid].observe(node);

        return {
            destroy() {
                window._inview_observers[observerid].unobserve(node);
            },
        };
    }
}
maciekgrzybek commented 1 year ago

@brahma-dev it is A solution, but definitely not THE solution :) Keeping things in a global variable doesn't sound like good idea to me, although it might be a solution for a quick fix :) I thought about this whole thing @izderadicka and I'd like to go with the svelte store, but I won't really have time soon to do this. If you want to give it a shot, please, be my guest :) I'll let you know if I'll have time earlier to work on this.

rgon commented 1 year ago

@maciekgrzybek here's an improved version of @brahma-dev 's code that creates a single store that holds all observers. It appears to behave properly.

import { tick } from 'svelte';
import { writable, get, type Readable } from 'svelte/store';
import type {
  ObserverEventDetails,
  Options,
  Position,
  ScrollDirection,
  Event,
  LifecycleEventDetails,
} from './types';

export function createMapStore<T>(initialValue: Record<string, T> = {}) : Readable<Record<string, T>> & {
    get: (k:string) => T;
    set: (key:string, value:T) => void;
    remove: (k:string) => void;
    update: (fn: (m:Record<string, T>) => Record<string, T>) => void;
} {
    const store = writable(initialValue);

    return {
        subscribe: store.subscribe,
        update: store.update,

        get: (k:string) => get(store)[k],
        set: (key:string, value:T) => store.update(m => Object.assign({}, m, {[key]: value})),
        remove(k:string) {
            store.update(s => {
                delete s[k];
                return s;
            });
        },
    }
}

// Store to hold 'global' observers
export const inview_observers = createMapStore<IntersectionObserver>({});

export default function inview (node: HTMLElement, options:Options = {}){
    //adapted from https://github.com/maciekgrzybek/svelte-inview
    const { root, rootMargin, threshold, unobserveOnEnter } = {
        ...defaultOptions,
        ...options,
    };

    let prevPos: Position = {
        x: undefined,
        y: undefined,
    };

    let scrollDirection: ScrollDirection = {
        vertical: undefined,
        horizontal: undefined,
    };

    // This ensures it's running in the browser, so we're safe to modify the shared store
    if (typeof IntersectionObserver !== 'undefined' && node) {
        let observerid:string = "root";
        // Unique id for each observer (defaults to root for viewport)
        if (root != null) {
            if (!root.getAttribute("data-svelte-inview-id")) {
                observerid = "_" + Math.floor(Math.random() * 100000);
                root.setAttribute("data-svelte-inview-id", observerid);
            }
        }

        if (!inview_observers.get(observerid)) inview_observers.set(observerid, new IntersectionObserver(
            (entries, _observer) => {
                entries.forEach((singleEntry) => {
                    if ((prevPos.y ?? 0) > singleEntry.boundingClientRect.y) {
                        scrollDirection.vertical = 'up';
                    } else {
                        scrollDirection.vertical = 'down';
                    }

                    if ((prevPos.x ?? 0) > singleEntry.boundingClientRect.x) {
                        scrollDirection.horizontal = 'left';
                    } else {
                        scrollDirection.horizontal = 'right';
                    }

                    prevPos = {
                        y: singleEntry.boundingClientRect.y,
                        x: singleEntry.boundingClientRect.x,
                    };

                    const detail = {
                        inView: singleEntry.isIntersecting,
                        entry: singleEntry,
                        scrollDirection,
                        node: singleEntry.target,
                        observer: _observer,
                    };

                    singleEntry.target.dispatchEvent(createEvent('inview_change', detail));

                    if (singleEntry.isIntersecting) {
                        singleEntry.target.dispatchEvent(createEvent('inview_enter', detail));

                        unobserveOnEnter && _observer.unobserve(node);
                    } else {
                        singleEntry.target.dispatchEvent(createEvent('inview_leave', detail));
                    }
                });
            },
            {
                root,
                rootMargin,
                threshold,
            }
        ))

        inview_observers.get(observerid).observe(node);

        tick().then(() => {
            node.dispatchEvent(
                createEvent('inview_init', { detail: { observer: inview_observers.get(observerid), node } })
            );
        });

        return {
            destroy() {
                if (inview_observers.get(observerid)) {
                    inview_observers.get(observerid)?.unobserve(node);
                    inview_observers.remove(observerid);
                }
            },
        }
    } else {
        return {
            destroy() {},
        }
    }
}
maciekgrzybek commented 1 year ago

@rgon that looks great, can you prepare a PR for that?