DEV Community

loading...

IntersectionObserver mixin for web components

Bryan Ollendyke
@elmsln @haxcamp @btopro #HAXTheWeb #drupal #webcomponents #edtech ✻ Full stack unicorn Adjunct professor teaching about webdev, ethics, and everything in between
・4 min read


"One weird trick" we use for boosting performance of elements added to the DOM, is conditional rendering. We'll cover a few different methods we use for conditional / lazy rendering, first starting with a mixin approach.

If you're not aware of what an IntersectionObserver is, it's basically a low level javascript API that allows us to generate an event when something is visible. This means that you can respond to the event of the user scrolling down in your app and "seeing" your element. By knowing when our element is visible, we can conditionally load... the whole the internals of the element for optimizing performance!

While incentivizing usage of LitElement, IntersectionObserverMixin is a vanillaJS mixin that works with any web component. Let's look at what it does first and then different examples, all of which are more or less the same.

TLDWTRTC (Too long, don't want to read the code)

  • We export a SuperClass which supports a new IntersectionObserver
  • This has some reasonable defaults applied in constructor which can be changed in your implementation
  • connectedCallback we activate the observer
  • disconnectedCallback we disconnect the observer
  • LitElement specific: We have a reactive property called elementVisible

Show me the code (or skip if lazy-loading your memory)

const IntersectionObserverMixin = function (SuperClass) {
  // SuperClass so we can write any web component library / base class
  return class extends SuperClass {
    /**
     * Constructor
     */
    constructor() {
      super();
      // listen for this to be true in your element
      this.elementVisible = false;
      // threasholds to check for, every 25%
      this.IOThresholds = [0.0, 0.25, 0.5, 0.75, 1.0];
      // margin from root element
      this.IORootMargin = "0px";
      // wait till at least 50% of the item is visible to claim visible
      this.IOVisibleLimit = 0.5;
      // drop the observer once we are visible
      this.IORemoveOnVisible = true;
      // delay in observing, performance reasons for minimum at 100
      this.IODelay = 100;
    }
    /**
     * Properties, LitElement format
     */
    static get properties() {
      let props = {};
      if (super.properties) {
        props = super.properties;
      }
      return {
        ...props,
        elementVisible: {
          type: Boolean,
          attribute: "element-visible",
          reflect: true,
        },
      };
    }
    /**
     * HTMLElement specification
     */
    connectedCallback() {
      if (super.connectedCallback) {
        super.connectedCallback();
      }
      // setup the intersection observer, only if we are not visible
      if (!this.elementVisible) {
        this.intersectionObserver = new IntersectionObserver(
          this.handleIntersectionCallback.bind(this),
          {
            root: document.rootElement,
            rootMargin: this.IORootMargin,
            threshold: this.IOThresholds,
            delay: this.IODelay,
          }
        );
        this.intersectionObserver.observe(this);
      }
    }
    /**
     * HTMLElement specification
     */
    disconnectedCallback() {
      // if we have an intersection observer, disconnect it
      if (this.intersectionObserver) {
        this.intersectionObserver.disconnect();
      }
      if (super.disconnectedCallback) {
        super.disconnectedCallback();
      }
    }
    /**
     * Very basic IntersectionObserver callback which will set elementVisible to true
     */
    handleIntersectionCallback(entries) {
      for (let entry of entries) {
        let ratio = Number(entry.intersectionRatio).toFixed(2);
        // ensure ratio is higher than our limit before trigger visibility
        if (ratio >= this.IOVisibleLimit) {
          this.elementVisible = true;
          // remove the observer if we've reached our target of being visible
          if (this.IORemoveOnVisible) {
            this.intersectionObserver.disconnect();
          }
        }
      }
    }
  };
};

export { IntersectionObserverMixin };
Enter fullscreen mode Exit fullscreen mode

Usage in practice

So when we leverage this in production we get timing as follows:

  • Element definition loads (usually with dynamic import() from previous post)
  • render() of element uses a ternary statement (LitElement specific) in order to conditionally render the contents of the element ${this.elementVisible ? htmlrender my element shadow html:render nothing by default}
  • When ${this.elementVisibile} changes to true we apply a updated() life-cycle callback (LitElement specific) in order to do additional dynamic import()s of internal elements used in the shadow of our element
  • When the element is visible, it will disconnect the IntersectionObserver by default

This approach maximizes performance with minimal effort. It also allows for content authors in HAX to add elements to the page like video-player or wikipedia-query or a11y-gif-player and not have to think about performance yet obtain reasonably high scores for CMS driven, user generated content.

What implementation looks like

Basically two or three things and you've got a faster element with a single dependency. These examples are from wikipedia-query

  • import it:
import { IntersectionObserverMixin } from "@lrnwebcomponents/intersection-element/lib/IntersectionObserverMixin.js";
Enter fullscreen mode Exit fullscreen mode
  • Conditionally render:
// LitElement render function
  render() {
    return html` ${this.elementVisible
      ? html` <h3 .hidden="${this.hideTitle}" part="heading-3">
            ${this._title}
          </h3>
          <div id="result"></div>
          <citation-element
            creator="{Wikipedia contributors}"
            scope="sibling"
            license="by-sa"
            title="${this.search} --- {Wikipedia}{,} The Free Encyclopedia"
            source="https://${this
              .language}.wikipedia.org/w/index.php?title=${this.search}"
            date="${this.__now}"
          ></citation-element>`
      : ``}`;
  }
Enter fullscreen mode Exit fullscreen mode
  • Conditionally import / use the variable for conditional logic internally:
updated(changedProperties) {
    changedProperties.forEach((oldValue, propName) => {
      // element is visible, now we can search
      if (propName == "elementVisible" && this[propName]) {
        import("@lrnwebcomponents/citation-element/citation-element.js");
      }
      if (
        ["elementVisible", "search", "headers", "language"].includes(
          propName
        ) &&
        this.search &&
        this.headers &&
        this.elementVisible &&
        this.language
      ) {
        clearTimeout(this._debounce);
        this._debounce = setTimeout(() => {
          this.updateArticle(this.search, this.headers, this.language);
        }, 10);
      }
    });
  }
Enter fullscreen mode Exit fullscreen mode

Here we can see an additional conditional logic for wikipedia-query in that it uses elementVisibile along with having a search term, language, and headers in order to trigger a fetch() of the wikipedia public API. This means that if you have a wikipedia article embedded lower in your website, that your user won't invoke the API to load the article until it's in the viewport!

Some other code examples where we've implemented this IntersectionObserverMixin to juice performance on an element:

This can be used with VanillaJS implementations as well by observering the attribute for changes, though no implementations exist here. You can also find a more advanced implementation in lazyImageLoader which is a SuperClass that mixes in IntersectionObserverMixin to do a lazyloading image placeholder SVG.

Discussion (0)

Forem Open with the Forem app