Skip to content

Instantly share code, notes, and snippets.

@domenic
Last active July 7, 2022 19:47
Show Gist options
  • Save domenic/4748675 to your computer and use it in GitHub Desktop.
Save domenic/4748675 to your computer and use it in GitHub Desktop.
`module.exports =` and ES6 Module Interop in Node.js

module.exports = and ES6 Module Interop in Node.js

The question: how can we use ES6 modules in Node.js, where modules-as-functions is very common? That is, given a future in which V8 supports ES6 modules:

  • How can authors of function-modules convert to ES6 export syntax, without breaking consumers that do require("function-module")()?
  • How can consumers of function-modules use ES6 import syntax, while not demanding that the module author rewrites his code to ES6 export?

@wycats showed me a solution. It involves hooking into the loader API to do some rewriting, and using a distinguished name for the single export.

This is me eating crow for lots of false statements I've made all over Twitter today. Here it goes.

Case 1: require + ES6 export syntax

Problem

Given this on the consumer side:

require("logFoo")();

and this ES5 on the producer side:

module.exports = function () {
  console.log("foo");
};

how can the producer switch to ES6 export syntax, while not breaking the consumer?

Answer

The producer rewrites logFoo's main file, call it logFoo/index.js, to look like this:

export function distinguishedName() {
  console.log("foo");
};

Then, the following hypothetical changes in Node.js make it work:

  • require is rewritten to look at logFoo/package.json and sees an "es6": true entry.
  • It then switches to loading logFoo/index.js with the ES6 module loader API.
  • Once the ES6 module loader API has given the results back, it plucks off the distinguishedName property and returns that to the caller of require.

This means require("logFoo")() will work, since require retrieves the distinguishedName export of logFoo/index.js.

Case 2: import + Node.js module.exports = syntax

Problem

Given this ES5 on the producer side:

module.exports = function () {
  console.log("foo");
};

and this ES5 on the consumer side:

require("logFoo")();

how can the consumer switch to ES6 import syntax, while not demanding that the consumer rewrite his code to accomodate yet-another-module-system?

Solution

The consumer rewrites his code as

import { distinguishedName: logFoo } from "logFoo";
logFoo();

Then, the following hypothetical changes in Node.js make it work:

  • The default ES6 module loader API is overriden to intercept any module loads
  • It sees the module identifier string "logFoo", goes to look at logFoo/package.json, and sees no entry of the form "es6": true.
  • It reads logFoo/index.js into memory, and executes it in a special context.
  • Once execution is done, it sees that module.exports now has a value in this context.
  • It creates a new module object with a single property, distinguishedName, whose value is filled out by pulling module.exports out of this context.
  • It returns this new module object back as the imported module.

This means import { distinguishedName: logFoo } from "logFoo" will work, since the module loader API ensures distinguishedName exists before importing.

Conclusion

Elegant? No. Considerate of Node idioms? No. But does it work? Yes.

With a solution like this, you can interoperably use require on ES6 modules and import on ES5 modules, even in the function-module case. And the burden is entirely on the ES6 user to twist his code into awkward shapes, which is as it should be: updating 22K+ and growing packages is not an acceptable path forward.

@ForbesLindesay
Copy link

@rwldrn I'm optimistic that if we end up with a module system that's better it may eventually catch on even if it has almost zero interoperability with current modules/module systems. The problem is that it it's current state, I hope it doesn't catch on. If it fails completely then at least we still have CommonJS. CommonJS is a far cry from perfect, but it does work, and it's better than the current ES6 spec.

Personally I think on the producer side, CommonJS is pretty much perfect. You can write modules that check for support of CommonJS and export a global if it's not there. This is something I see no way to do with ES6 modules.

On the consumer side, the syntax import * from foo is ugly, and so is import {baz, boz} from foo and so is import foo from foo we have destructuring assignment in ES6:

foo.js:

module.exports = {
  baz: 'baz',
  boz: 'boz'
};

consumer:

let foo = import foo;
let {baz, boz} = import foo;

When for some reason you think import * from ... is a good idea (incidentally it isn't):

with (import foo) {
}

If you don't like that because it doesn't work in strict mode remember why it was taken out. The only valid reason to use with is in templating (EJS, Jade etc.) and the only other place I'd ever consider it is when writing a bit mathematical expression:

with (Math) {
  var res = pow(sin(5), 2) + pow(cos(5), 2)
}
assert.aproximatelyEqual(res, 1);

But that's probably a bad idea on my part. I should probably write those out in full.

@domenic
Copy link
Author

domenic commented Feb 11, 2013

@ForbesLindesay the problem with the expression form is that this should not work:

if (false) {
  let foo = import "foo";
}

@MajorBreakfast
Copy link

Quick tip for everyone reading this:
var RSVP = require('rsvp') -> module RSVP from 'rsvp'

@SeanMcMillan
Copy link

I assume this discussion is what led to the export default syntax being added to the ES6 module system.

@miketheprogrammer
Copy link

Im sorry. late to this, but is the new module loading actually better.
I particularly dont care for the import from syntax

import x from a
x = require("a/x");

Split code into files, keep your code size down, and manageable.

Just my late 2 cents

@silvenon
Copy link

I think there's a mistake.

…while not demanding that the producer rewrite his code…

@emarinizquierdo
Copy link

Hi, and what would replace those lines?:

x = require("a/x");
y = require("b/y")(x);

I am using something like this:

import x from 'a';
import y from 'b';

But when 'b' wants to use 'x' it fails.

Thanks

@kruncher
Copy link

ES6 specification doesn't seem to support the dynamic loading of modules:

function renderView(viewName) {
    const view = require(path.join('./views', viewName));
    ...
}

@balupton
Copy link

Has anything happen within node to support ES modules?

@jsg2021
Copy link

jsg2021 commented Dec 14, 2015

@kruncher dynamic module loads go through System

function renderView (viewName) {
  System.import(path.join('./views', viewName))
    .then(view => ...)
    .catch(error => console.error(error.stack));
}

@balupton See https://nodejs.org/en/docs/es6/ ... tl;dr --es_staging flag turns it on.

@dead-claudia
Copy link

@jsg2021 @balupton

V8 has parsing support, but not runtime support. From the few times I've asked the V8 devs themselves, they said they're waiting on the WHATWG Loader spec (which will be the ES6 loader spec) to solidify enough for them to reliably figure out what hooks they need to put in for Chrome/Opera and Node/etc. But as it stands, it isn't even complete enough for a tentative attempt at a polyfill yet.

Also, it'll be relatively difficult to merge that in with the current module loader for Node, as Node's is currently synchronous. It'll require some highly inelegant C++ at first (JS wouldn't work), which will likely have to run largely outside of the event loop just to ensure that require calls remain synchronous, while import ... remains asynchronous, required by the Loader spec.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment