Load JavaScript files dynamically

Usually when we need to include a JavaScript file on an HTML page we just do this:

<script src="the-javascript-file.js"></script>

And with modern JavaScript maybe we throw an async or defer attribute on that script tag for a little extra performance. Better yet, we could set type="module" to use the JavaScript module system.

If we are using JavaScript modules, we can include other JavaScript module files directly by using an import statement:

import otherModule from '/other/module.js'

However, there are times when none of these options are available. For example, if we don’t have access to edit the original HTML markup being served, we are forced to load JavaScript dynamically.

Real world use cases for this include bookmarklets and web extensions.

Loading JavaScript dynamically

A <script> element can be created and appended to the DOM just like any other HTML element. For example:

const script = document.createElement('script')
script.src = '/my/script/file.js'
document.head.append(script)

Once a script element has been appended to the DOM, it will be executed. This means that inline scripts will have their contents interpreted and executed as JavaScript just as we would expect if they had been part of the HTML when it was first loaded. Similarly, external script files will be loaded and executed.

Here’s an inline example:

const inlineScript = document.createElement('script')
script.innerHTML = 'alert("Inline script loaded!")'
document.head.append(script)

As you can see, it’s easy to create and append new script elements, allowing us to include any number of external JavaScript files dynamically after a page has loaded.

Determining when a JavaScript file is loaded

The real challenge isn’t loading the file – it’s knowing when the file has finished loading. For example, maybe we have code that uses a library like jQuery or AngularJS or Vue (listed in order of ancientness, not preference). We need to make sure the library is loaded before we execute our own code, otherwise our code will break.

We could do something silly like call setInterval and continually check if the library has loaded by looking for its global window variable:

const jqueryScript = document.createElement('script')
jqueryScript.src = 'https://code.jquery.com/jquery-3.4.1.min.js'
document.head.append(jqueryScript)

const jqueryCheckInterval = setInterval(() => {
  if (typeof window.jQuery !== 'undefined') {
	clearInterval(jqueryCheckInterval)
	// do something with jQuery here
  }
}, 10)

However, this code is ugly and wastes resources. Instead, we should listen directly for the script element to fire its onload event:

const jqueryScript = document.createElement('script')
jqueryScript.src = 'https://code.jquery.com/jquery-3.4.1.min.js'
jqueryScript.onload = () => {/* do something with jQuery */}
document.head.append(jqueryScript)

We’ve already cut the size of our code in half, making it much easier to read and work with. It’s also slightly more performant.

The code would be even easier to read if we used Promises, which would allow us to chain multiple scripts together to load one after the other. Here’s a function we can use:

/**
 * Loads a JavaScript file and returns a Promise for when it is loaded
 */
const loadScript = src => {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.type = 'text/javascript'
    script.onload = resolve
    script.onerror = reject
    script.src = src
    document.head.append(script)
  })
}

Notice we have also introduced error handling by listening for the script element’s onerror event.

Here’s what the script looks like in action:

loadScript('https://code.jquery.com/jquery-3.4.1.min.js')
  .then(() => loadScript('https://code.jquery.com/ui/1.12.1/jquery-ui.min.js'))
  .then(() => {
    // now safe to use jQuery and jQuery UI, which depends on jQuery
  })
  .catch(() => console.error('Something went wrong.'))

Dueling with dinosaurs

If you don’t have access to the original HTML source of the page you’re working with, there’s a chance you’re facing other limitations as well. For example, you could be forced to work with Internet Explorer.

IE may be old and behind the times, but thankfully we can accommodate it with just a few modifications. First, we need to drop the Promises API and go back to using callbacks. Second, we need to account for IE’s unique way of handling script load events. Namely, IE doesn’t fire an onload event, but it does give scripts an onreadystatechange event just like XMLHttpRequests.

Here’s the callback-based version that works with Internet Explorer as well as other browsers:

/**
 * Plays well with historic artifacts
 */
function loadScript(src, callback) {

  var script = document.createElement('script')
  script.type = 'text/javascript'

  // IE
  if (script.readyState) {
    script.onreadystatechange = function () {
      if (script.readyState === 'loaded' || script.readyState === 'complete') {
        script.onreadystatechange = null
        callback()
      }
    }
  }
  // Others
  else {
    script.onload = callback
  }

  script.src = src
  document.head.appendChild(script)
}