Dynamically/conditionally importing JavaScript modules

Dynamically/conditionally importing JavaScript modules

16 Nov 2020 modules promises javascript

One of the best things to arrive in ECMAScript 2015 (6th edition) was JavaScript modules. Finally, there was a structured way to effortless import and export code to and from other code.

This is an article about dynamic imports of modules, not modules per se. You can get acquainted with JS modules via the MDN blog post introducing them.

This brought JavaScript into line with other languages such as PHP, which has long been able to 'require' or 'include' other PHP scripts into the current one, or even JavaScript's server-side cousin, Node.js, which has its require() function for such purposes.

The problem 🔗

But there were two problems with this:

  1. You couldn't specify the import path dynamically - it had to be a string
  2. Worse, you couldn't conditionally import - you either imported or you didn't

The reason for both was that  import declarations, like their export counterparts, had to appear in the top-level of a script and be identifiable without uncertainty or dynamism - making JavaScript act uncharacteristically like a compiled, rather than procedural, language, such as XSLT.

import foo from './bar.js'; //OK! let imp = './bar2.js;l import foo2 from imp; //error - can't use variable in import declaration function someFunc() { import foo from './bar.js'; //error - must be in top-level of script }

Meet import() 🔗

Thankfully, both problems were solved via the 2017 draft's import() function. This returns a promise which resolves with data from the export of the module being loaded.

Actually, import() isn't a true function - it just has the syntax of one.

Suddenly, we can dynamically import, which is GREAT. Let's see it in action, and see how it solves the above problems.

if (localStorage.doneTour) { let importFrom = 'tour.js'; import(importFrom).then(exports => { //do something with @exports... localStorage.doneTour = 1; }); }

There, we're loading a script which gives the user a tour of the page (like my own Page Wizard project) - but only if they haven't experienced it before. Notice how we both import conditionally and pass the module filepath as a variable.

Because import() returns a promise, this means we can also use it with the mega-cool async-await combo.

(async () => { let importFrom = 'tour.js'; let exports = await import(importFrom); //do something with @exports })();

Unpacking 🔗

If you're familiar with non-dynamic imports, you may be wondering, "where do I specify the modules I want to import, or even the namespace?"

With import() you don't. This doesn't really matter, though, since the exports arrive as a nice, structured object that you can unpack - the same as if you do a non-dynamic import with * (i.e. "import everything").

Consider the following module:

let foo = 'bar', func = function() { }; export {foo, func};

Let's import it (all of it) via a non-dynamic import.

import * as modules from './module.js';

We end up with modules, an object of export data. This is exactly the same as we get as our promise resolution value with dynamic imports.

let modules2 = await import('module.js');

modules will have exactly the same structure as modules2.

Dynamic imports are non-blocking 🔗

Because dynamic imports use promises, they're non-blocking! Ordinarily, non-dynamic imports are synchronous - which is to say blocking - just like script tags lacking the defer attribute.

import foo from './bar.js'; console.log('Etc); //<-- happens only after import completes

That makes sense; non-dynamic imports have no mechanism to alert us as to when then import completes, and so they have to be done synchronously.

Not so, dynamic imports.

import('./bar.js').then(exports => console.log(1)); console.log(2); //"2", "1", NOT "1", "2"

This also means we can fire off several concurrent imports and continue merrily with script execution until they're all ready.

let imports = ['./bar.js', './foo.js', './etc.js'];, promises = imports.map(file => import(file)); Promise.all(promises).then(data => console.log('Imports complete!')); console.log('This happens before imports complete...');

Summary 🔗

Dynamic imports are a great way to specify dynamic paths to import modules, as well as conditionalise whether they should be imported at all.

They're non-blocking, unlike non-dynamic imports, because they're based on promises, which are always asynchronous.

Dynamic imports also means, potentially, performance gains because imported code is evaluated only when you run import(), not immediately at runtime.

They also mean you can import code that does not exist at runtime.