Home

Dynamically Load JavaScript with webpack

September 03, 2020

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:

  1. When we’re on a page that doesn’t have such a gallery
  2. 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

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?