Using ES6 modules for progressive enhancement

Orde Saunders' avatarPublished: by Orde Saunders

Recently I was looking at native support for ES6 modules in the browser and it struck me that it was ideally set up for a progressive enhancement approach.

How it works

I'm not going to go into the implementation details as they are covered much better by the V8 team and MDN but I'm going to highlight two features that are relevant to how I'm using them:

Mustard cut

To denote that a script is a module the type attribute is set to module. Given that browsers will ignore a <script/> with an unrecognised type this means that older browsers that do not support modules will ignore this code:

<script type="module" src="main.js"></script>

For older browsers it's possible to provide a fallback and to prevent this conflicting with module based code in newer browsers. Adding the nomodule attribute will instruct browsers that support modules (and hence recognise the nomodule attribute) to ignore this <script/>. By contrast, older browsers (that ignored the previous <script/> due to its type="module" attribute) will ignore this nomodule attribute and run the script as normal.

<script nomodule src="fallback.js"></script>

This gives a way to declare support directly in the HTML and browsers will only load the code they need. This is a very well thought out and elegant way of doing this and full credit goes to the people behind this spec.

Dynamic import

The other feature of these modules is dynamic imports which enable code to be included and utilised on demand:

import('./path_to/a_module.mjs').then((Module) => {
    Module.function();
});

Starting with the main.mjs entry point declared in a <script type="module"/> it's possible within that to check to see if an additional module should be loaded. For example, here I'm checking to see if Do Not Track has been enabled before including my analytics code:

if (navigator.doNotTrack !== "1") {
  // User agent is not opted out of tracking.
  import('./modules/analytics.mjs').then((analytics) => {
    analytics.ready();
  });
}

It's also possible to only load code if it's needed on the current page. In this example I'm only loading my portfolio lazy load image enhancements if there are elements in the document that need enhancing:

const portfolio_images = document.querySelectorAll('[data-dc-portfolio-src]');
if (portfolio_images.length) {
  import('./modules/portfolio.mjs').then((portfolio) => {
    portfolio.lazyLoad(portfolio_images);
  });
}

In both these examples the code is only being loaded, parsed, compiled and executed if it's actually needed.

Bringing it together

To start with I load a minimal main.mjs which loads other modules and runs their code as required. These modules may in tern load other modules (such as utility functions) but this dependency resolution is handled by the browser. Significantly this means there's no need for a bundle or compile step so there's no need to do any code splitting either.

Limitations

Obviously any technique has its limitations and, given my use case is fairly basic, I've not discovered many of them. However, there are some obvious ones:

  • Number of unbundled modules. The V8 team recommend a limit of less than 100 modules and a max dependency depth of 5 to limit performance problems caused by transferring lots of individual modules.
  • Complexity. With a lot of modules code can get a bit complex and there's a lot to test. This is an issue with a codebase of any significant size but dynamic imports can make the problem worse if not well managed.

The way I'm using this is neither particularly smart nor particularly optimised but it does at least prove the concept is valid.

But does this work in IE?

Yes, of course it does - that's the entire point of progressive enhancement. The site works without JavaScript anyway so the fact that I'm choosing not to run JavaScript in browsers that don't support these language features doesn't matter: those that don't support the JavaScript get something that works, those that support the JavaScript get something that works better.

The fact that my site has been built from progressive enhancement first principles means that getting this to work was easy: all I had to do was tweak the JavaScript module syntax and change the <script/> used to load the entry point.

Edge is truly the new IE

This was all going very well until I got to testing in Edge. Unfortunately Edge does support type="module" but it doesn't support dynamic imports so there's a syntax error when loading the main module. To get around this I had to revert to feature detection and a script injected script until Edge adds support and I can go back to a <script/> in the html.

Feature detect ES6 module dynamic import

try {
  new Function('import("").catch(function(){})')();
  var script = document.createElement('script');
  script.type = 'module';
  script.src = 'main.mjs';
  document.querySelector('head').appendChild(script);
} catch(err) {
  // Pass
}

I love it when a plan comes together

I went into this with half an idea and intended to have a play in code to see how it worked. After half an hour I realised this was not only working but it was working much better than I expected it would. Admittedly the functionality on my site isn't complex but it only took a couple of hours to get everything adapted and working with ES6 modules. Subsequently I've been able to rip out a big chunk of my build process and any time you are able to keep functionality whilst deleting code and streamlining your process is an augury that you're heading in the right direction.

Another sign the stars were right was the way the mustard cut aligns with HTTP2. For loading multiple modules the nature of this protocol is needed to make it efficient, the overheads of HTTP1 are going to be prohibitive. Given that only browsers that support type="module" also support HTTP2 it means we're not penalising older browsers in order to use a new platform feature.

Additional features of <script type="module"/> that differ from <script nomodule/> are that they act by default as deferred, in strict mode, and with no global scope - all of which are how my code worked anyway so I didn't have to make any changes to accommodate this behaviour.

A final benefit is that the way I'm detecting if modules are needed by counting the elements they are going to act upon means that I can then inject those elements into the module code as a dependency:

const portfolio_images = document.querySelectorAll('[data-dc-portfolio-src]');
if (portfolio_images.length) {
  import('./modules/portfolio.mjs').then((portfolio) => {
    portfolio.lazyLoad(portfolio_images);
  });
}

In this case the elements to be lazy loaded are passed into the lazyLoad() function which means it's easy to test the module code in isolation by passing in mock <img/> elements and examining what happens to them, there's no DOM selection happening in the modules:

import * as portfolio from './portfolio.mjs';

const parser = new DOMParser();

describe('Lazy load', () => {
  test('Source replace', () => {
    const images = parser.parseFromString(
      '<img data-data-dc-portfolio-src="cat.gif"/>',
    'text/html').querySelectorAll('img');

    portfolio.lazyLoad(images);

    expect(images[0].src).toBe('cat.gif');
    expect(images[0].getAttribute('data-data-dc-portfolio-src').toEqual(null);
  });
});

For the first time in a while I've found working with a new JavaScript feature going with the grain of the web.