
Rikki BochowFront end Developer
How to optimise your progressively decoupled Drupal frontend with the new Intersection Observer API.
Read on for front-end tips backed by code you can use! You can also watch the full video at the end of this post.
Lazy Loading isn’t a new concept; however, let's quickly recap what we know about it.
“Lazy loading is a strategy to identify resources as non-blocking (non-critical) and load these only when needed. It’s a way to shorten the length of the critical rendering path, which translates into reduced page load times.” Mozilla Developer Network.
Good performance offers many benefits.
How often have you given up and closed a web page because it took too long to load? Especially when you’re on your mobile or experiencing a poor connection. It’s easy to forget that not everyone has regular access to fast, reliable internet connections or devices.
There are plenty of benefits to lazy loading:
If you’d like to dive deeper into these metrics, check out Jake Archibald’s F1 series for before and after speed tests.
Because stylesheet files are render-blocking, we need to determine what is critical or above-the-fold CSS and inline it. We then defer the rest with Javascript attribute swapping and use Drupal’s Library system to reduce unused CSS.
We also need to determine our critical Javascript and consider inlining it. Definitely defer any JS that isn’t critical and load asynchronously where applicable.
ES6 modules are deferred by default and supported by modern browsers, so can be combined with code splitting. Again, we can use Drupal’s Library system to reduce unused Javascript.
Media can slow down pages too. That’s why the loading attribute is gaining support in both Drupal and browsers.
<img loading=”lazy”>
has the most comprehensive support, so you should avoid using Javascript for these and also avoid lazy loading images that are likely to be above the fold.
Always put height and width attributes to prevent layout shift and use the responsive images module.
And with the Intersection Observer API, we can.
“The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport.” Mozilla Developer Network
Simply put, it means: “tell me when an element has scrolled into or out of view”.
This API isn’t new (having been around for roughly five years); however, the recent demise of Internet Explorer now means it has full browser support.
Let's take iframe in Firefox as an example. If you have a lot of iframes (because, let’s face it, sometimes you just need to) and you’d like to save your Firefox users from long pages loads, then you can use oembed_lazyload (which uses Intersection Observer).
Alternatively, you can write a very simple Intersection Observer to populate the iframe src
attribute from the data-src
value.
In the video below, I ran through a basic introduction to the Intersection Observer API. I used all the default settings and checked the isIntersecting
property, swapping the attributes, and then Unobserving the iframe (so it didn’t trigger again when it scrolled into the viewport subsequent times).
// <iframe data-src="url" height="600" width="800" />
const lazyLoadIframe = iframe => {
const obs = new IntersectionObserver(items => {
items.forEach(({ isIntersecting }) => {
if (isIntersecting) {
iframe.src = iframe.dataset.src
obs.unobserve(iframe)
}
})
})
obs.observe(iframe)
}
window.addEventListener('load', () => {
document.querySelectorAll('iframe').forEach(iframe => lazyLoadiFrame(iframe))
})
We can expand on this idea of deferring assets that are below the fold and think about our progressively or partially decoupled projects.
In the following example, we’ll imagine a Drupal site with complex javascript applications embedded here and there. The apps are written in React, Vue or even Vanilla JS; they fetch data from Drupal as a JSON file and load their CSS. There may also be multiple apps on a page.
If we load these apps, as usual, we’ll load everything, including dependencies (JSON, CSS etc.) on the initial page load, regardless of whether we defer or async the javascript. It’s not render-blocking, but users who don’t scroll down to see the App are still downloading it.
Instead, we can combine the Intersection Observer with a Dynamic Import and truly defer loading all the apps’ resources until the mounting element is in the user's viewport.
In the below code example, the load()
function is only called upon intersection, so none of the apps’ dependencies are requested until the container scrolls into the viewport, significantly decreasing the initial page load time.
const lazyLoadApp = (
container,
componentPath,
props = {},
callback = () => []
) => {
const load = async () => Promise.all([
import("react-dom"),
import("react"),
import(componentPath),
]).then(([
{ render },
React,
{ default: Component }
]) =>
render(<Component {...props} />, container, callback)
)
const obs = new IntersectionObserver(items => {
items.forEach(({ isIntersecting }) => {
if (isIntersecting) {
load()
obs.unobserve(container)
}
})
})
obs.observe(container)
}
We use Promise.all to ensure all of our dependencies are met, and then we destructure what we need from those dependencies and render our app.
After this happens, we unobserve the container.
You can also adjust the load()
function as needed, i.e. import Vue and createApp instead–whatever your setup requires.
const load = async () => Promise.all([
import("vue"),
import(componentPath),
]).then(([
{ createApp },
{ default: Component }
]) => {
const app = createApp(Component, props)
callback(app)
app.mount(container)
})
const load = async () => Promise.all([
import("react-dom/client"),
import("react"),
import(componentPath),
]).then(([
{ createRoot },
React,
{ default: Component }
]) => {
const root = createRoot(container)
root.render(<Component {...props} />)
// callback function moves into Component.
})
Then usage would be something like:
// <div data-app-example id="my-app" data-title="Hello Drupal South" />
window.addEventListener('load', () => {
document.querySelectorAll('[data-app-example]').forEach(container =>
lazyLoadApp(
container,
'./path-to/component.jsx',
{
title: container.dataset.title,
id: container.id,
},
() => container.setAttribute('data-mounted', true)
))
})
Here's the breakdown;
drupalSettings
)We need to remember that the WCAG have findability rules for hidden content. Adding a heading (maybe even a description) inside the container with a button that triggers the load function might help with this. They get replaced by the rendered app but are available for screen readers and keyboard navigation.
You’ll also need to consider the following:
The unmounted container is also a good use case for Skeleton User Interfaces. Start by giving the container a grey background with a rough height/width for the rendered app, then add a loading animation, and you’ll help reduce the “jump” of the suddenly rendered app whilst also improving the perceived performance.
This approach is also a great way to prevent Layout Shift issues. Also, remember to notify the user if something has failed to load.
You can tweak the Intersection Observer’s settings to increase or decrease the point of intersection, allowing for sticky headers, for example.
Other use cases for the Intersection Observer include:
Let’s not forget that the post-Internet Explorer world is full of Observers, including:
These all follow the same Observe/Unobserve pattern, so once you learn one, you won’t be able to stop yourself from using them all!