Drupal 8 Oembed - how to lazy load stuff?

Recently I've been working a lot with front-end performance related topics.  One of the most glaring issues is how large the Youtube JS libraries are.  Unfortunately, there doesn't seem to be a lazy loading mechanism for the core OEmbed system that checks all of the boxes I needed, so I had to make one.  Fortunately, Drupal makes this quite easy!

The following solution can easily live within a theme.  The thing that I like most about it is that it's lightweight, low impact, and doesn't mess with any persistent data!  If your client decides they don't like it for whatever reason -- or if the core team adds their own lazy loader, it's super quick and easy to remove.

Basically, it's just a pair of strategically positioned preprocess hooks and a simple javascript behavior.  The "outer iframe" is preprocessed to move the 'src' attribute to a benign 'data-src'.  Not to fear, javascript moves it back where it's supposed to go when the user clicks a button or when the iframe enters the viewport - whichever meets requirements.  The other hook modifies the "inner iframe" to add some player parameters such as the flag to allow the YouTube JS API to control stuff.  Just for completeness, there are a lot of other things that can be tweaked in this second hook!

One other thing that this solution works around is the issue of stale oembed metadata.  There's an existing core issue that prevents the direct use of the thumbnail that's already saved and managed by Drupal.  Essentially, if a video thumbnail is updated on the Youtube side, there's no way for Drupal to re-fetch the asset.  It's shocking in Drupal to find something that can't be done, but at the time of this writing there is not a public API that can help with this!

Finally, the notion of GA tracking also becomes feasible by simply including the tag manager container within the outer iframe.  This will allow the native tracking tag to work without any advanced GTM customization.

Proof of concept example

Obligatory Disclaimer: the following is a proof of concept.  It should be peer reviewed and subject to a security evaluation before use in production.

Directory structure

- .git/
- docroot/
  |
  - core/
  - modules/
  - themes/
    |
    - custom/
      |
      - yourtheme/
        |
        - yourtheme.info.yml
        - yourtheme.libraries.yml
        - css/
          |
          - _media-video.scss
        - js/
          |
          - behaviors/
            |
            - lazy-load-youtube.js
        - templates/
          |
          - field--media--field-media-oembed-video--video-embed.html.twig

yourtheme.libraries.yml 

lazy-load-youtube:
  version: 1.x
  js:
    js/behaviors/lazy-load-youtube.js: {}
  dependencies:
    - core/drupal

lazy-load-youtube.js

/**
 * @file
 * Javascript behavior that facilitates lazy loading youtube libraries.
 */
(function (Drupal) {

  'use strict';

  /**
   * Helper to determine if webp format is supported.
   *
   * @returns {boolean}
   *   True if webp is supported, otherwise false.
   *
   * @see https://stackoverflow.com/a/27232658/1476330
   */
  function support_format_webp() {
    const elem = document.createElement('canvas');
    if (!!(elem.getContext && elem.getContext('2d'))) {
      return elem.toDataURL('image/webp').indexOf('data:image/webp') === 0;
    }
    return false;
  }

  Drupal.behaviors.lazyLoadYoutube = {
    attach: function(context) {
      const video_embeds = context.querySelectorAll('.field-media-oembed-video');
      video_embeds.forEach(function(video_embed) {
        const button = video_embed.querySelector('button');

        // If webp isn't supported by the browser, swap it out a legacy format.
        if (!support_format_webp()) {
          const bg = button.getAttribute('data-bg-legacy');
          button.setAttribute('style', 'background-image: url("' + bg + '")');
        }

        // When a play button is clicked:
        // 1) Copy the data-src attribute to the src attribute
        // 2) Delete the data-src attribute
        // 3) Detach the button from the DOM
        button.addEventListener('click', function(event) {
          const iframe = video_embed.querySelector('iframe');
          const src = iframe.getAttribute('data-src');
          iframe.removeAttribute('data-src');
          iframe.setAttribute('src', src);
          this.parentNode.removeChild(this);
        });
      });
    }
  }
})(Drupal);

field--media--field-media-oembed-video--video-embed.html.twig

{% for item in items %}
  <div class="field field-media-oembed-video">
    {{ item.content }}
    {% apply spaceless %}
    <button
      aria-label="{{ 'Play video'|t }}"
      style="background-image: url({{ media_thumbnail }})"
      data-bg-legacy="{{ media_thumbnail_legacy }}"
    >
      <i class="fa fa-youtube-play" aria-hidden="true"></i>
    </button>
    {% endapply %}
  </div>
{% endfor %}

_media-video.scss

.media--video {
  .field-media-oembed-video {
    position: relative;
    height: 0;
    overflow: hidden;
    padding-bottom: 56.25%;

    button {
      position: absolute;
      top: 0;
      width: 100%;
      height: 100%;
      background-size: cover;
      background-position: center;

      i {
        font-size: 3rem;
      }
    }

    iframe {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
    }
  }
}

yourtheme.theme

/**
 * Implements hook_preprocess_HOOK() for field.
 */
function yourtheme_preprocess_field(&$variables) {
  if ($variables['element']['#field_name'] !== 'field_media_oembed_video') {
    return;
  }

  $variables['#attached']['library'][] = 'yourtheme/lazy-load-youtube';

  /* @var \Drupal\media\MediaInterface $media */
  $media = $variables['element']['#object'];

  // Derive a thumbnail image + alt text.
  $url = $media->get('field_media_oembed_video')->getString();
  // youtube.com vs youtu.be -- both are valid.
  $needle = strpos($url, 'youtube.com') !== FALSE ? '?v=' : 'youtu.be/';
  $embed_code = substr($url, strpos($url, $needle) + strlen($needle));

  $variables['media_thumbnail'] = "//i.ytimg.com/vi_webp/$embed_code/maxresdefault.webp";
  $variables['media_thumbnail_legacy'] = "//i.ytimg.com/vi/$embed_code/maxresdefault.jpg";

  // Set up the iframe src to be lazy loaded in.
  $src = $variables['items'][0]['content']['#attributes']['src'];
  $variables['items'][0]['content']['#attributes']['data-src'] = $src;
  unset($variables['items'][0]['content']['#attributes']['src']);
}

/**
 * Implements hook_preprocess_HOOK() for media_oembed_iframe.
 */
function yourtheme_preprocess_media_oembed_iframe(&$variables) {
  if (strpos((string) $variables['media'], 'youtube.com') !== FALSE || strpos((string) $variables['media'], 'youtu.be') !== FALSE) {
    // Make the video auto-play on load.
    $variables['media'] = str_replace('?feature=oembed', '?feature=oembed&autoplay=1', $variables['media']);
  }
}

 

Find this useful?  Go ahead and adapt it to your own needs.  All that I ask in return is proper attribution.

 

Update 2.23.2021: I've recently created a contributed project, oembed_lazyload, that meets all of the functional requirements of my current project in a more elegant and extensible way.

Cheers.