Search code examples
javascriptnode.jses6-modulescommonjs

Why does Node evaluate all exports when it is importing from an ES module?


I am the maintainer of an extremely large (CommonJS) JavaScript package. The JavaScript package is split into subparts, each of which are exported as a symbol from the top-level index.js file.

In TypeScript, this looks like:

export * as submodule1 from './submodule1';
export * as submodule2 from './submodule2';
// etc

The library is in fact so large that importing represents a noticeable performance hit. In particular, a customer doesn't necessarily want to use all submodules in every program, but because of the import style all files of all submodules end up being require()d at module load time.

Lazy loading

Because I wrote it, I know that loading the library does not have any side-effects. It occurred to me that I can delay loading the submodules until they are actually accessed by the user.

I would turn the above into:

Object.defineProperty(exports, 'submodule1', {
  get: () => require('./submodule1'),
});

Which makes it so submodule1/index.js and its entire subtree is only loaded if the user actually accesses the submodule1 symbol.

(Note: the actual syntax of what I'm emitting is slightly different than the above, because Node will not support importing the library above from an ES module... but the code above represents in essence what I'm trying to achieve).

Eager ESM loading

The above trick works great when the library is being loaded from CommonJS, but it doesn't work at all when the library is being loaded from ESM. It looks like Node is accessing all module members as soon as a library is loaded.

I conducted a small experiment to prove this:

//////////////////////////////////////////////////////////////////
//  node_modules/lazylib/index.js

// Trickery necessary to make the CJS exports recognized from an ESM context
exports.someSymbol = void 0;
Object.defineProperty(exports, 's' + 'omeSymbol', {
    get: () => {
        // Proof that the code is being evaluated
        console.log('evaluated');
        return 42;
    },
});


//////////////////////////////////////////////////////////////////
//  test.js
const ll = require('lazylib')

//////////////////////////////////////////////////////////////////
//  test.mjs
import * as ll from 'lazylib';

Now when I run these:

$ node test.js
                # nothing here, as expected

$ node test.mjs
evaluated       # <--- this is unexpected!

So it seems that Node will eagerly evaluate all symbols exported from a module as it is loading it. Potentially this only happens when loading a CJS module, but there's no way for me to tell the difference as this lazy loading behavior cannot be emulated using ESM.

Why is this happening? And is there anything I can do about it? (If your reaction would be to say: "your package shouldn't be so big that load time becomes a problem, the correct solution is to split it up", then rest assured I am aware, but a number of factors necessitate what we have right now. I am really only looking for an answer to my question.)


Solution

  • The reason that all getters are being read, is because the ESM importer iterates over all exports to call a method called this.setExport():

        for (const exportName of exportNames) {
          if (!ObjectPrototypeHasOwnProperty(exports, exportName) ||
              exportName === 'default') {
            continue;
          }
          // We might trigger a getter -> dont fail.
          let value;
          try {
            value = exports[exportName];
          } catch {
            // Continue regardless of error.
          }
          this.setExport(exportName, value);
        }
    

    https://github.com/nodejs/node/blob/1d220b55ac397f15758e83d1143789608e3fca4a/lib/internal/modules/esm/translators.js#L301C32-L301C32

    I'm still not sure about the motivation, but this is the code in Node.js as currently written.