tc39 / proposal-defer-import-eval

A proposal for introducing a way to defer evaluate of a module
https://tc39.es/proposal-defer-import-eval
MIT License
208 stars 12 forks source link

Bloomberg Feedback #18

Open mkubilayk opened 1 year ago

mkubilayk commented 1 year ago

At Bloomberg, we have been using lazy module evaluation in production for a long time. We have proved multiple times that it's essential for better application startup performance. Hence we are very happy to see this proposal and will invest in its standardization.

This proposal, as it stands, is generally inline with our view. Please see our detailed feedback below.

Existing Use Cases

Our module loader speaks AMD. Historically, AMD has also been the authoring syntax for modules. This has changed in the last few years. Modules are now authored in ESM syntax by default and AMD is considered legacy as an authoring format. ES modules can interoperate with AMD and vice versa. In terms of interop, it would be fair to consider an AMD module as an ES module with a single default export.

Currently we have two primary features that give users lazy evaluation capabilities. These are designed to be used more in the producer modules when building module exports.

Both of these are used in critical parts of the infrastructure. They don't take special care for asynchronous modules, i.e. ESM using top-level await, or AMD using a special async formulation. Users are expected to create safe dependency graphs by knowing what children to preload ahead of time. This makes the feature very brittle, which we are actively working to address (see below).

1. Default-exported Object with Lazy Properties

In this pattern, the default-exported object contains getter functions that load different target modules on first access.

define(["make-lazy", "sync-require"], (makeLazy, require) => {
    const exports = {};

    makeLazy(exports, "Label", "./label", require);
    makeLazy(exports, "Button", "./button", require);

    return exports;
});

Using the current proposal, this can be implemented as follows:

import * as Label from "./label" with { lazyInit: true };
import * as Button from "./button" with { lazyInit: true };

export default {
    get Label() { return Label.default },
    get Button() { return Button.default },
};

This requires .default for each namespace which is a little inconvenient.

2. Lazy Namespace of (Lazy) Namespaces

In this pattern, a mega lazy namespace is constructed by re-exporting other namespaces. A special pragma tells the build tooling to transform eager static re-exports to lazy evaluation.

/* "use special pragma to make re-exports lazy at build time" */
export * as TableUtils from "./table-utils";
export * as WindowUtils from "./window-utils";

This is possible to implement using the current proposal as it's allowed to pass the DeferredModuleNamespace exotic object around without breaking its lazy nature.

import * as TableUtils from "./table-utils" with { lazyInit: true };
import * as WindowUtils from "./window-utils" with { lazyInit: true };

export { TableUtils, WindowUtils };

It cannot be expressed concisely only using re-export statements, though.

3. Lazy Namespace of Named Re-exports

The same pragma can be used to construct a namespace where only selected named bindings are re-exported lazily. Although this pattern is supported, we don't have many users of it today.

/* "use special pragma to make re-exports lazy at build time" */
export { Table } from "./table";
export { Window } from "./window";

This cannot be expressed in the current proposal as it only allows namespace imports and there is no way to export a function that works like a getter.

Implementation of Deferred Module Evaluation

We have implemented deferred module evaluation in our module system. It is now used by a few early adopters in production. As background, this module system is not known to the JS engine. It is almost entirely implemented in JS and is constructed during an initial bootstrapping phase that runs before the main application module is loaded.

Most of the complexity we found when implementing this feature arises due to handling of asynchronous modules in the graph. Supporting top-level await is important in our system, which is why we have invested in standardization of it in the past. So this was a critical requirement for us. Handling of asynchronous modules is achieved by a combination of metadata collected at build time and preloading asynchronous nodes in the runtime. Build-time metadata is reliable because we enforce static analyzability of dependency graphs. This metadata enables an optimization that makes the preloading traversal in the runtime more efficient by avoiding parsing modules which makes discovery of asynchronous modules possible without the need to explore the whole static module graph.

We are aligning with the current state of the proposal as much as we can by