Lazily Lazy Loading
Lazy loading is one of the best accessibility wins available to front-end developers. By deferring the loading of assets until we need them, we save our users’ data—and consequently their money. For folks who pay disproportionately more for their data, this can be invaluable.
In this tutorial we will craft a lazy loader that can be deployed with minimal effort on any site. Because we’re lazy folks. So we’ll leverage the MutationObserver and IntersectionObserver interfaces to lazily lazy load lazy-loadable elements whenever they’re added to the DOM.
Please feel free to skip to the result at this GitHub repository.
Getting started
Our minimum viable product should:
- Use vanilla JavaScript without build steps or external dependencies
- Require no markup changes except for a
<script>
in the<head>
- Prevent every
<iframe>
and<img>
from loading until they’re visible - Be encapsulated such that it could be deployed on any site
- Leverage feature detection to target current browsers
- Be compatible with IE11 when combined with the appropriate polyfills
- Progressively enhance browsers that support the
loading
attribute, which will soon give us lazy loading for free 🎉 - Provide a helpful public interface for fellow lazy folks
Selecting a syntax
According to the ECMAScript compatibility table, we should primarily rely on ES5 language features for IE11 support. In other words, we can’t use syntactic sugar like arrow functions or the spread operator. So we’re stuck with how it was before JavsScript became cool again.
Picking a pattern
Our goals inform us that we’re seeking something with privacy. It should give us space to work without interfering with our users’ sites. But it should also provide a point of entry for troubleshooting or extension.
The revealing module pattern is especially helpful for this. Essentially it’s an immediately invoked function which returns a public interface that has closure over its private values. So it’s a facade for another sandbox.
Here we’ve initialized the Lazily
global with our pattern:
Sniffing for features
Our first priority inside our sandbox is determining whether the browser can do this at all.
Feature detection is asking the browser what we get for free and acting accordingly. So we should test whether we can observe elements for mutations and intersections. To do our lazy loading things.
This is a good use case for the in
operator because we can combine our requirements into a compound expression and receive a boolean constant:
Our pattern requires us to return at the end, so we can’t return early if isSupported
is false
.
Instead we’ll use this constant to protect against exceptions and trigger our initialization logic.
Embracing laziness
Typical lazy loading libraries require markup changes that expect all browsers to behave equally. This has its disadvantages:
- Template logic changes
- Mixing markup with presentational logic
- No fallbacks for users with outdated browsers or JavaScript disabled
Our library will leverage a MutationObserver
to dynamically process the document as it loads, adjusting the attributes of its target elements as needed.
Effortlessly we’ll keep our markup clean and semantic while our library handles the rest.
Let’s define one if it’s supported.
Whenever an element it’s observing changes, it will execute our onMutation()
callback function:
Our callback will receive an array of MutationRecord
objects.
Their addedNodes
property is a NodeList
, which we’ll convert to an array in order to iterate over them.
For each Node
, we’ll call our initialize()
function if it’s an <iframe>
or an <img>
:
The eponymous lazy
…
We’ll need to keep track of which elements we initialize()
.
This is a good use case for custom data attributes because we can keep this state local to the element and target them with CSS.
Let’s assign its key to a constant:
We’ll also leverage custom data attributes to lazy load our target elements.
To prevent them from loading, we’ll replace critical attributes with custom ones.
When it’s time to load we’ll perform the inverse operation.
Let’s define our lazyAttributes
as an array so we can easily iterate over them:
Our initialize()
function returns early if the element has already been initialized.
Otherwise it replaces our lazyAttributes
with custom attributes and tells our IntersectionObserver
to start watching:
Preferring native implementations
Progressive enhancement is where we apply what we learn from feature detection to improve a baseline experience. This library is a clear example because it enhances browsers that provide its requirements.
We can enhance this further if the element supports the loading
attribute.
Let’s replace that TODO
comment in Figure 7:
This will force lazy loading on elements whose loading
attribute is not explicitly set.
The early return skips our custom attributes and the next observer entirely.
A modern browser will handle the rest.
Classical lazy loading
For everyone else, we’ll resort to using an IntersectionObserver
to detect when to revert our elements’ attributes.
This is where the classic lazy loading strategy usually begins on the front end.
Let’s define one if it’s supported.
It will call our onIntersection()
callback function whenever an element it’s observing enters or exits the viewport:
Our callback will receive an array of IntersectionObserverEntry
objects.
Their isIntersecting
property reports whether the element is currently visible.
For each visible entry, we’ll unobserve()
their target
to load()
them only once:
…and the eponymous load
To load()
our elements, we’ll iterate over our lazyAttributes
and replace any custom attributes with their originals:
Putting it together
With all the pieces built, our MutationObserver
is ready to watch the entire document for changes.
By observing its subtree, we receive callbacks for all of its descendants too:
Improving printer-friendliness
We should ensure that pending elements also have their attributes reverted when printed. Otherwise they won’t be present in the output.
According to MDN, the beforeprint
event is not implemented by Safari browsers.
But they offer us a polyfill in the form of window.matchMedia()
.
We can engineer this to forceLoad()
everything when printing the page.
Let’s replace that FIXME
comment in Figure 12:
Our forceLoad()
function uses the takeRecords()
method to dequeue elements from our IntersectionObserver
and load()
them:
Perhaps someone may find forceLoad()
useful.
Let’s make it public.
Providing a public interface
When we’re done in our sandbox we can—at last—return
a small interface.
So users can interrogate it in their implementations.
We can provide additional safety with indirection; by avoiding direct references to our sandbox’s values, we maintain its purity:
For now it’s sparse but effective.
Deploying your lazy loader
Typical lazy loading libraries require users to make costly changes to their markup.
Our strategy of leveraging a MutationObserver
to react when our target elements are added to the document means that deployment couldn’t be simpler:
As long as it’s placed within the document <head>
, our library will automatically process the entire <body>
, including across asynchronous navigations.
That’s great news for lazy folks.
Next steps
This is only the beginning of a successful lazy loading library.
Documentation is a must, including: a README file, JSDoc comment blocks, a license, and contribution guidelines if you prefer. Likewise, integrations testing would prove its efficacy and help prevent breaking changes. And don’t forget to publish it through all the major channels.
A lacking feature is support for <picture>
and <video>
elements.
An IntersectionObserver
will never report visibility changes for their child <source>
elements because they have no geometry.
So we can’t simply observe them like <img>
and <iframe>
.
Ultimately our abstraction isn’t expressive enough because its solutions are a maze of conditional statements. Refactoring how we handle different elements to a command pattern would afford us more control and clarity.
Do you have the energy for any of that?