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.'))

Keeping Things Fresh

The above script works great for libraries and modules that never change, such as those loaded from a CDN. Once the script is loaded, the browser will automatically cache it. The next time the script is needed, the browser will reuse the copy it saved earlier. This saves bandwidth and makes the page load faster.

This built-in behavior is actually a problem for scripts that change. For example, you might want to create a bookmarklet with just enough code to load an external script, allowing that script to do all of the heavy lifting. That script might change over time as you add new features. If you use the above loadScript function, those new features might not show up because the browser has already cached your script, and it now reuses that cached version instead of checking your server.

To ensure your script is actually loaded from your server each time, you can add a meaningless query value to the end of the script URL. As long as this value is different each time the script is loaded, it will cause the browser to treat the URL as a new resource and load it directly from the server each time.

Here’s what that can look like in code:

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

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)
}