-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
ES2015 specifies a sequentialized evaluation order of modules, where the particular syntactic location of an import statement in a module body has no effect on the order of evaluation. Instead, the semantics simply determines a module's list of direct dependencies and recursively calls ModuleEvaluation() on each of them in order before evaluating the body. I'll refer to this semantics as declarative imports.
An alternative semantics would be an interleaved evaluation order. In this semantics, the evaluation order is sensitive to the particular syntactic location of an import statement. The evaluation semantics would be simply to execute the module body in order, and each ImportStatement would recursively call ModuleEvaluation() on that dependency. I'll refer to this semantics as imperative imports.
It's not too late to change
Existing implementations of module systems in production use today vary between these two semantics. As I understand it, AMD and YUI have declarative imports, CJS And Node have imperative imports, some ES6 transpilers follow the currently specified declarative semantics, and others such as Babel and TypeScript can configurably target AMD or CJS and inherit the semantics of the target system depending on that configuration. Meanwhile, no browsers are yet shipping native support for ES6 modules.
So my conclusion is that it's not too late to change this part of the spec.
Impact on top-level await
One consideration we would have to work out is what becomes of the semantics of top-level await. The only two options I can think of are:
- the presence of any dependencies on asynchronous modules (i.e., modules that contain top-level await or that import from other asynchronous modules) automatically switches a module's evaluation order to declarative; or
- imperative imports from asynchronous modules implicitly
await.
(1) is pretty silly and a refactoring hazard to boot. I think (2) is natural and reasonable, but it does have the consequence of being a slightly nonobvious await point:
console.log("before");
import "some-async-module"; // implicitly blocks; remainder of module body does not execute in this turn
console.log("after");The case for imperative imports
Compatibility with Node
Node is clearly the largest ecosystem, and consequently the most likely one to hit on compatibility problems with declarative imports. This is IMO probably the strongest argument for imperative imports.
Connecting side effects to statements
Since module initialization is effectful, imperative imports arguably give programmers clearer control over those side effects. I don't find this argument quite as compelling, since memoization means you can't actually predict whether the side effects are occurring now or have already occurred some time earlier. You can at least claim that it's more consistent with the familiar precedent of require() -- although this is roughly equivalent to the previous argument.
Increased expressivity
In the presence of cycles, imperative imports are technically more expressive. For example, two mutually recursive classes can coordinate to initialize their definitions and then add static instances of one another, without requiring a third module to orchestrate the initialization:
// module A
export default class A { ... } // create class A
import B from "B"; // SYNCHRONIZATION POINT
A.B_INSTANCE = new B(); // use class B// module B
export default class B { ... } // create class B
import A from "A"; // SYNCHRONIZATION POINT
B.A_INSTANCE = new A(); // use class AThat said, this is an extremely subtle and fragile dependence on the execution order. In practice, most developers do not have a good mental model for the execution order of cycles and are unlikely to want to depend on these kinds of subtleties. So in that sense this argument is somewhat weak. But I suspect this means fewer cyclic dependency graphs will break.
The case for declarative imports
Simpler top-level await story
The story of top-level await is arguably more straightforward with declarative imports: the evaluation of the entire module body is simply blocked on completion of its dependencies. This doesn't introduce an implicit await, whereas imperative imports implicitly await at the point of importing an asynchronous module.
Refactoring
Since declarative imports do not depend on the order of imports, you can reorder them without changing the behavior of your program. This gives programmers more flexibility to group their module bodies the way feels best to them.
No harm done
Large ecosystems like the Ember community have been using the ES6 semantics for years without trouble, so it does not appear to have been a problem in practice.
Simpler spec
Declarative evaluation is simpler to spec than imperative evaluation, which requires the evaluation semantics of import statements to coordinate with the rest of the module semantics. I reject this argument entirely -- if @bterlson has to suffer to make JS developers' lives better, so be it! ;P
Conclusion
IMO, imperative imports come out ahead here: in particular, the compatibility and familiarity from existing module systems is the strongest argument I see. Of the arguments I've enumerated, the only one I can see that I find a bit concerning about imperative imports is the implicit await.
But I may well have missed arguments, so let's talk!