Dynamically Load JavaScript with webpack
I feel like dynamically loading modules with
webpack's code splitting was
discussed
everywhere
and
everyone
is doing it at this point. Thought I'd reiterate on how we do it in
Blocksy and on the tiny layer of
abstraction we built on top of the import()
function.
Disclaimer: It's not like I'm about to describe something revolutionary and an absolute game changer, I just think this approach is quite neat and is very convenient for our uses cases.
That being said, let's get started with the problem definition before we even start to present a potential solution for it. To give a context where this is very useful, in Blocksy's single product pages, we have a drag-n-drop carousel implemented with a library called Flexy, which we developed internally (maybe an opportunity for another post to describe its internals?). Even though this library is not that big, it still has some code that has to execute to do its thing (handle dragging and dropping, cycling layout, pills etc) and we'd like to avoid loading it when we don't need it:
- When we're on a page that doesn't have such a gallery
- On products that have only one image, thus no gallery involved
If you'd take a look at the page source in the above link, you'd see that the
container that wraps the carousel is a div
with the class of
.flexy-container
, so that's how we instantiate the carousel:
const container = document.querySelector('.flexy-container');
const flexyInstance = new Flexy(container, {
// other args
});
From the server side, we make sure that on pages where there's no carousel
involved, we don't output that .flexy-container
element at all. So now the
problem is trimmed down to loading the Flexy
library only if this element is
present on the page. Here's how this might look:
// main.js
document.addEventListener(
'DOMContentLoaded',
() => {
const container = document.querySelector('.flexy-container');
if (container) {
import('./flexy').then(({ Flexy }) => {
const flexyInstance = new Flexy(container, {
// other args
});
});
}
},
false
);
// flexy.js
export class Flexy {
// Flexy implementation
}
This all works very good, but it gets very clumsy with a lot of
conditionally loaded widgets on various pages, so we added some syntactic sugar
over it. Introducing the
handleEntryPoints()
and loadSingleEntryPoint()
helpers functions.
To not scare you away with the wall of code, I've extracted them in a gist.
This looks pretty scary at first, yes, but let's treat it as an investment into
a nicer experience for defining how things get loaded dynamically. Now our
example with Flexy
will look like this:
// main.js
import { handleEntryPoints } from './helpers';
handleEntryPoints([
{
els: '.flexy-container',
load: () => import('./flexy'),
},
]);
// ------
// ------
// flexy.js
class Flexy {
// Flexy implementation
}
export const mount = (sliderEl) => {
const flexyInstance = new Flexy(container, {
// other args
});
};
See how easy that was? Now we introduce a convention, the module that gets
dynamically loaded should export a function called mount()
that accepts
only one element as the first argument. If there is any element that matches
the selector defined in els
, the process will find all the elements that
matches this selector, will load the module dynamically and when that's done,
will call the mount()
function for each found element.
To summarize, if we'd have two carousels on any page for any reason, this code would have properly catched & initialized the library for each of them in isolation.
Now, the beautiful part is that you can add any number of other widgets in the
same handleEntryPoints()
call, like that:
// main.js
import { handleEntryPoints } from './helpers';
handleEntryPoints([
{
els: '.flexy-container',
load: () => import('./flexy'),
},
{
els: '.widget-1',
load: () => import('./widget-1-implementation'),
},
{
els: '.widget-2',
load: () => import('./widget-2-implementation'),
condition: () => {
// false -- skip loading
// true -- allow to load
//
// has to be syncronous, but the library can be extended to allow
// async conditions too
return false;
},
},
{
els: '.widget-3',
load: () => import('./widget-3-implementation'),
},
]);
Hope you can see how handy this can be when you have a lot of different dynamic and potentially big components scatered across different pages chaotically.
Note: Depending on your webpack config, loading modules like that will lead in
getting one generated chunk per import()
call, resulting in a lot of small JS
files that are dynamically loaded, and thus causing lots of requests. When that
is the case, you can skip the import()
part from the chunk definition, but
still use the same els
/mount()
convention. This can be done as following,
where we make widget-4.js
inlined in the main.js
chunk:
// main.js
import { handleEntryPoints } from './helpers';
import { mount as mountWidget4 } from './widget-4-implementation';
handleEntryPoints([
{
els: '.flexy-container',
load: () => import('./flexy'),
},
{
els: '.widget-1',
load: () => import('./widget-1-implementation'),
},
{
els: '.widget-3',
load: () => import('./widget-3-implementation'),
},
{
els: '.widget-4',
load: () => new Promise((r) => r({ mount: mountWidget4 })),
},
]);
// ------
// ------
// widget-4-implementation.js
export const mount = (el) => {
// Init widget 4
};
This can be controlled pretty easily with tools like
webpack-bundle-analyzer
.
Hope this was of help and don't hesitate to shoot me an e-mail if any of this resonated with you.
Cheers!
Andrei Glingeanu's notes and thoughts. You should follow him on Twitter, Instagram or contact via email. The stuff he loves to read can be found here on this site or on goodreads. Wanna vent or buy me a coffee?