Evaluating JavaScript code via import()

[2019-10-01] dev, javascript
(Ad, please don’t block)

The import() operator lets us dynamically load ECMAScript modules. But they can also be used to evaluate JavaScript code (as Andrea Giammarchi recently pointed out to me), as an alternative to eval(). This blog post explains how that works.

eval() does not support export and import  

A significant limitation of eval() is that it doesn’t support module syntax such as export and import.

If we use import() instead of eval(), we can actually evaluate module code, as we will see later in this blog post.

In the future, we may get Realms which are, roughly, a more powerful eval() with support for modules.

Evaluating simple code via import()  

Let’s start by evaluating a console.log() via import():

const js = `console.log('Hello everyone!');`;
const encodedJs = encodeURIComponent(js);
const dataUri = 'data:text/javascript;charset=utf-8,'
  + encodedJs;
import(dataUri);

// Output:
// 'Hello everyone!'

What is going on here?

  • First we create a so-called data URI. The protocol of this kind of URI is data:. The remainder of the URI encodes the full resource instead pointing to it. In this case, the data URI contains a complete ECMAScript module – whose content type is text/javascript.
  • Then we dynamically import this module and therefore execute it.

Warning: This code only works in web browsers. On Node.js, import() does not support data URIs.

Accessing an export of an evaluated module  

The fulfillment value of the Promise returned by import() is a module namespace object. That gives us access to the default export and the named exports of the module. In the following example, we access the default export:

const js = `export default 'Returned value'`;
const dataUri = 'data:text/javascript;charset=utf-8,'
  + encodeURIComponent(js);
import(dataUri)
  .then((namespaceObject) => {
    assert.equal(namespaceObject.default, 'Returned value');
  });

Creating data URIs via tagged templates  

With an appropriate function esm (whose implementation we’ll see later), we can rewrite the previous example and create the data URI via a tagged template:

const dataUri = esm`export default 'Returned value'`;
import(dataUri)
  .then((namespaceObject) => {
    assert.equal(namespaceObject.default, 'Returned value');
  });

The implementation of esm looks as follows:

function esm(templateStrings, ...substitutions) {
  let js = templateStrings.raw[0];
  for (let i=0; i<substitutions.length; i++) {
    js += substitutions[i] + templateStrings.raw[i+1];
  }
  return 'data:text/javascript;base64,' + btoa(js);
}

For the encoding, we have switched from charset=utf-8 to base64. Compare:

  • Source code: 'a' < 'b'
  • Data URI 1: data:text/javascript;charset=utf-8,'a'%20%3C%20'b'
  • Data URI 2: data:text/javascript;base64,J2EnIDwgJ2In

Each of the two ways of encoding has different pros and cons:

  • Benefits of charset=utf-8 (percent-encoding):
    • Much of the source code is still readable.
  • Benefits of base64:
    • The URIs are usually shorter.
    • Easier to nest because it doesn’t contain special characters such as apostrophes. We’ll see an example of nesting in the next section.

btoa() is a global utility function that encodes a string via base 64. Caveats:

  • It is not available on Node.js.
  • It should only be used for characters whose Unicode code points range from 0 to 255.

Evaluating a module that imports another module  

With tagged templates, we can nest data URIs and encode a module m2 that imports another module m1:

const m1 = esm`export function f() { return 'Hello!' }`;
const m2 = esm`import {f} from '${m1}'; export default f()+f();`;
import(m2)
  .then(ns => assert.equal(ns.default, 'Hello!Hello!'));

Further reading