tc39 / proposal-dynamic-import

import() proposal for JavaScript
https://tc39.github.io/proposal-dynamic-import/
MIT License
1.86k stars 47 forks source link

dynamic import should be an unary operator #23

Closed allenwb closed 7 years ago

allenwb commented 7 years ago

(this is the terse form of this issue, since somehow the nice extented form got lost)

dynamic import should not be syntactically defined as a special kind of CallExpression. This deviates from the pattern used for all other keyword based expression forms including yield, await, typeof, void, delete.

However, it can't be a UnaryExpression because that precedece would be error prone:

let fooP=import base+"/foo"; //as a UnaryExpression this would parse as: (import basePath)+"/foo"

Instead it should be defined as an alternative of AssignmentExpression, just like yield:

let fooP=import base+"/foo"; //as AssignmentExpression this would parse as: import (basePath+"/foo")
domenic commented 7 years ago

Hmm. This would be a pretty big change. The idea is that it behaves the same as super() (a call expression), and that in the future we might have a use for the second-onward argument. It's not an operator that is applied to a string and gives back a promise.

domenic commented 7 years ago

Another reason is to avoid confusion with the import statement. In the past discussions with @dherman @wycats and @benjamn have discussed the idea of creating an "expression form of the import statement" that would look similar to your proposed unary operator syntax, but would have the same semantics as import statements in that it contributes to the initial module graph, instead of causing a dynamic import. So e.g. you'd be able to refactor code from

import foo from "./foo.js";
import bar from "./bar.js";
let stuff = { a: foo, b: bar };

to

let stuff = {
  a: import "./foo.js",
  b: import "./bar.js";
};

This is all speculative and I might not be recalling the details, but the overall idea is that the function-call-ness is an import distinguisher to imply "dynamic", versus the paren-less "static" look that, even in expression form, should contribute to the import graph.

allenwb commented 7 years ago

Actually, I think it is a small change that very few would notice at the user level. My concern is mostly about internal consistency of the language's expression grammar but I'm also concerned about establishing the precedent that it is ok to define new built-in context sensitive operators that look like function calls.

You may argue that super() is context sensitive, but at least it is still fundamentally an invocation of an ECMAScript function. import is different because it really isn't a form of function call. It is something completely different. How is import not anything but a context sensitive "operator that is applied to a string and gives back a promise"? It sure look like one to me and its evaluation semantics appears to do exactly that.

I think your strongest argument for the parenthesized form is the future proofing one. But I think it is a weak argument. It's not at all clear what the use cases that supports a second argument might be or why, if they were strong, whyimport statement wouldn't also have to accommodate them. Regardless, they are likely to be corner cases that could be accommodate with an reflection-like API call or some future import.foo() variant.

Having both a parenthesized and unparenthesized form of import seems like a very bad idea. In JS (and most languages) redundant params can be free used in expressions and programmer do this every day. Even if both forms could be made grammatically unambiguous it would come at the cost of imposing seeming arbitrary rules on the use of parentheses. Such rules create internal uncertainty for some programmers and generally make the language less pleasant to use.

Also, an expression form of static import would create conceptual confusion similar to what we had when implementations provided non-standard function declarations in blocks that hoisted to function level:

 if (flag) {
   import "foo.js";  //this in an ExpressionStatement, not a ImportDeclaration
                     //and since it is static, it really isn't guarded by flag
}

If defining dynamic import as a prefix operator precludes such future foolishness, that would be a good thing.

domenic commented 7 years ago

I think your strongest argument for the parenthesized form is the future proofing one. But I think it is a weak argument. It's not at all clear what the use cases that supports a second argument might be or why, if they were strong, whyimport statement wouldn't also have to accommodate them.

They're actually pretty important, and we're indeed mulling over how to get the import statement to support them too. In brief: fetches in host environments can take lots of parameters, e.g. nonces or checksums or credentials modes, and we'd like a way to pass them along:

// very speculative
import foo from "./foo.js" with { credentials: "include", nonce: "deadbeef" };

// more straightforward
import("./foo.js", { credentials: "include", nonce: "deadbeef" });
matthewp commented 7 years ago

Sorry if I'm missing something obvious here, but without a parenthesized form, how can you distinguish between static vs. dynamic imports?

If I'm understanding your suggestion @allenwb:

let fooP=import base+"/foo";

Means that this is also possible:

import "/foo";

But this is the same form used for static imports. So how do you know what the user intends? Unless you are saying that it must be assigned to something, in which case it wouldn't be like yield in that respect.

allenwb commented 7 years ago

If they are static, you could just define syntax for including them in the ModuleSpecifier. Totally strawman example:

import foo from "{credentials: include, nonce: deadbeef } ./foo.js";

But, that's really not very friendly for writing modules that will work across different hosting platforms. This is probably a perfect example of the old adage that you really don't want to merge logical module identification and configuration management. One possibly better way is provide a "config map" that an be defined at the HTML level. For example:

<script type=modulemap src="./foo.js">
logicalname: 'foo',
credentials: "include",
nonce: "deadbeef"
</script>
<script type=module>
  import "foo";  //logical name, gets mapped by host platform module map
  // ...
</script>

That approach could also probably better deal with dynamic valued parameters.

Getting back to dynamic imports, the extended ModuleSpecifier string would also work fine with the prefix operator interpretation of import. But there is another completely open ended way to extend a prefix opertor:

let FooP = import HTMLModuleSpecifier("./foo.js", { credentials: "include", nonce: "deadbeef" });

Here HTMLModuleSpecifier is a built-in function that creates a uniquely branded kind of built-in object. Since import is being used as an operator, it's specification can be extended (at the ES, host, or implementation level) to recognize and process such objects.

domenic commented 7 years ago

Yes, I didn't really want to get into the design of that feature here.

Did you have an answer to @matthewp's point?

allenwb commented 7 years ago

@matthewp Excellent point. It's the same ambiguity that JS currently has with function, async, class at the beginnng of ExpressionStatement.

Same solution could be applied. Look-ahead restriction prevents Expression statement from starting with import. My sense is that might not be such a big deal. Don't you usually need to capture the resulting promise?

allenwb commented 7 years ago

Yes, I didn't really want to get into the design of that feature here.

Of course, the real question is whether a prefix operator is sufficiently future proof and these are simply strawmen exploring that issue.

ljharb commented 7 years ago

You don't need to capture the resulting promise if you're importing something dynamically, for its side effects - like a shim/polyfill.

matthewp commented 7 years ago

Even if you're not importing for side effects, it's still normal not to capture the resulting promise if you don't need to pass it around (like any other promise usage). Look at the main example in the readme: https://github.com/tc39/proposal-dynamic-import#example

If this proposal were AssignmentExpression-like you'd have to do either:

let importPromise = import `./section-modules/${link.dataset.entryModule}.js`
importPromise.then(module => { ... })

Or

let importPromise = (import `./section-modules/${link.dataset.entryModule}.js`).then(module => { ... })

Neither of which feel as ergonomic, to me at least.

I can definitely see bugs happening because a developer forgot an assignment (because they didn't need to use the resulting promise) and getting a static import they did not intend.

allenwb commented 7 years ago

I can definitely see bugs happening because a developer forgot an assignment (because they didn't need to use the resulting promise) and getting a static import they did not intend.

Well, the only situation where this could occur without a syntax error would be at the top level of a module (not including nested blocks) and the expression to the right of import was a top-level leaf single or double quoted string. In that case a static import should be equivalent and probably what should be used. I don't see silent bugs emerging from that.

Note because ImportDeclaration is only allowed in a ModuleItemList there really is no ambiguity with situations like:

//a module body
if (flag) {
   import "./foo.js"  //this could unambiguously be an import expression
}

or even

//a script body
import "./foo.js" ; //this could unambiguously be an import expression

If would take some relatively minor engineering of the grammar to make those legal, leaving only a Module's ModuleItemList as the place where ExpressionStatements couldn't start with the import keyword.

It isn't clear to me how you can ignore the Promise if you actually are doing a dynamic import of a shim/polyfill. You want want to just blindly race forward assuming it will get loaded before you or somebody you call does something that depends upon side-effects of the dynamic load.

allenwb commented 7 years ago

We certainly should consider is the readability and reliability of:

(import `./section-modules/${link.dataset.entryModule}.js`).then(module => { ... })

vs

import(`./section-modules/${link.dataset.entryModule}.js`).then(module => { ... })

or

import(computeModuleName(...args)).then(module => { ... }) 

vs

(import computeModuleName(...args)).then(module => { ... })

I'm not sure if I see very much difference from a readability perspective.

ljharb commented 7 years ago

having to wrap the unary operator form in parens seems significantly less readable to me.

caridy commented 7 years ago

@allenwb, as @ljharb mentioned, the promise might not be needed. e.g.: a very common use-case is to defer some initialization routines for non-critical parts of your app. e.g.: deferring section-two of the app:

import "section-one";
import("section-two");

In the example above, there is a subtle distinction that might be sufficient for devs to understand what's going on, but if we drop the parents, then this feature can't really be implemented since it will clearly conflict with the existing import statement, e.g.:

import "section-one";
import "section-two";
allenwb commented 7 years ago

In the example above, there is a subtle distinction that might be sufficient for devs to understand what's going on, but if we drop the parents, then this feature can't really be implemented since it will clearly conflict with the existing import statement, e.g.:

import "section-one";
import "section-two";
import "section-one";
(import "section-two");

Just like an IFFE.

allenwb commented 7 years ago

as @ljharb mentioned, the promise might not be needed. e.g.: a very common use-case is to defer some initialization routines for non-critical parts of your app. e.g.: deferring section-two of the app:

Still not clear to me why it's ok to assume that section-two will have finished loading by the time you need it.

ljharb commented 7 years ago

@allenwb you might not ever need to know for sure that it's loaded - it might be code that, say, progressively enhances, whenever it maybe ends up loading, all PNG images into SVGs; or detects the user's bandwidth and silently upgrades all videos on the page (or sets some global variable that video code respects) from low quality to a higher quality format.

Not saying this would be the best way to achieve these goals, or that it's particularly good coding, but these sorts of contrived and valid use cases where you don't actually care if or when your code loads are legion.

allenwb commented 7 years ago

@ljhard yup, and there are valid use cases for goto

But we should not be using rare and error prone use cases (ignoring races almost always bites you) as a driver for selecting syntactic design details.

domenic commented 7 years ago

So my conclusion on this issue is that there are valid arguments on both sides, and we should present it to the committee for their opinions. I intend to keep the current spec draft for now.

Let me try to summarize the arguments for function-like and Allen can write a separate post summarizing his for a unary operator, for easier later presentation to the committee.

Arguments for function-like import():

benjamn commented 7 years ago

Isn't the keyword proposal somewhat hostile to polyfilling the import(...) API?

One of the things that most excites me about the dynamic import(id) proposal is that it improves upon and fully replaces System.import(id, parentId). The keyword proposal would introduce a disadvantage w.r.t. System.import: having to transpile the import keyword to something that can be implemented by an ~ES5 runtime library.

domenic commented 7 years ago

I don't see how this discussion has any impact for polyfills or transpilers. Both proposals use syntax that is invalid in the current language, so a transpiler is necessary.

benjamn commented 7 years ago

Thanks for pointing that out. I hereby cede the transpiler complexity argument.

Instead, I'd like to explore a possible expansion of the keyword proposal.

In the July TC39 meeting, there was some talk of broadening the dynamic import(...) proposal to support import specifiers, so that we could retain the benefits of static analyzability.

Here's what it might look like to allow specifiers as well:

(import { a, b as c } from "./module").then(m => {
  // Only m.a and m.c are exposed, because that's all we asked for.
});

// Alternatively (again, only m.a and m.c are exposed):
const m = await (import { a, b as c } from "./module");
// Note: I'm not sure the parentheses would be necessary.

In both cases, our static analysis doesn't have to worry that some other hard-to-analyze code might expect m.foo, because we know statically that we didn't ask for anything but { a, b as c }. This enables optimizations like tree-shaking (pruning the parts of ./module that are statically known to be unnecessary to other modules) and helpful warnings about nonexistent exports.

Note that this form also supports live binding, assuming m.a and m.c get updated whenever the a and b exports of ./module change.

domenic commented 7 years ago

@benjamn does your proposal allow arbitrary strings, not just string literals?

I don't intend to champion such a complex proposal myself, with the live bindings and tree shaking and so on.

benjamn commented 7 years ago

Optimizations like tree shaking don't need to be a part of the proposal; it's just good if they are enabled by the proposal. Live bindings are already enabled by your proposal, as long as properties of the namespace object stay up to date.

Regarding arbitrary source identifiers, I don't believe the addition of specifier syntax introduces any new considerations. With or without specifiers, we still have to answer the question of how the parser distinguishes between import expressions and import declarations.

We could say that import expressions are distinguished from import declarations because they appear in a syntactic position where an expression is required.

So

(import { a, b as c } from dynamicId).then(...)

is legal, because the parentheses require an expression, and dynamic source identifiers are legal for import expressions.

But is this legal?

import { a, b as c } from dynamicId;

By one interpretation, this is just an expression statement with a Promise-producing import expression, so it should be legal. But this expression statement looks a lot like an import declaration with a dynamic source identifier, which could be extremely confusing, because it's suddenly asynchronous and probably wouldn't be hoisted like import declarations.

Of course, the same ambiguity arises when there are no specifiers:

import "./module"; // Guarantees "./module" has finished evaluating.
import dynamicId; // Evaluates to an ignored Promise, asynchronously resolved?
domenic commented 7 years ago

Maybe such discussions would be better had on your proposal's repo. I think they are off-topic for this thread, which is considering just a small tweak to the existing proposal.

benjamn commented 7 years ago

This is not part of any proposal that I'm currently championing. I'm only trying to assess whether @allenwb's keyword proposal could generalize to more use cases, because that becomes an argument in favor of his proposal that is absolutely relevant to this thread.

domenic commented 7 years ago

I don't really agree. It's an argument in favor of abandoning this proposal in favor of a new one; as I said I don't have any intention to expand this proposal in those ways. But as I said, I think building up an alternate proposal is better done in a separate repo.

allenwb commented 7 years ago

@domenic I agree that this is something TC39 should more broadly consider. I'll write up a summary for the operator formulation.

allenwb commented 7 years ago

Adding binding forms to dynamic import seems problematic for several reasons.

  1. The script/module containing the dynamic import couldn't reference them outside of nested functions. That's because the actual load/import processing doesn't take place until after the importer has run to completion. Whether the invocation of a nested function can successfully use such a nested binding depends on whether it is called before or after the dynamic load completes.
  2. It doesn't work with the current semantics of importing bindings. Imports aren't copies of imported values (if they were we could say they were in a TDZ until the load completes). Instead imported bindings are transparent aliases for accessing a binding in the original exporting bindings. The design intent for this is that an implementation can compile a reference to an imported name as a direct access to the the originally exported binding (ie, turns into a direct reference to the same variable slot that is referenced within the exporting module). With static imports the bindings can all be "linked" prior to evaluating module bodies. But with dynamic import we can't do that linking because we don't, prior to completing the dynamic import, have a defining exported binding for the import to alias.

Making this work is going to require new specification and implementation mechanisms. We currently have a nice clean why to export/import bindings via static imports. We also have a nice clean proposal for how to do value (ie, property access based) imports via dynamic imports. I think trying to embellish dynamic imports with binding semantics is just a significant and unnecessary complication.

matthewp commented 7 years ago

Glad to hear that TC39 will be discussing this and I agree that there are good arguments on both sides here. Since I'm not on TC39 I'd like to give my final opinion here just in case it's at all valuable.

import "./foo.js";
(import "./foo.js");

This was compared to an IIFE earlier in the comments, but in the case of an IIFE it is a SyntaxError without the parens that makes it an expression. In this case without the parens it is a static import statement. That it would do something completely different without the parens is what I'm struggling with.

Are there any other examples in the language where code inside of an expression is treated as differently as this is?

My biggest issue with the unary proposal is that it breaks my (perhaps naive) understanding of what an expression is in JavaScript. I would never expect to get different behavior by adding parens when not inside another expression. Parens are for grouping (sub)expressions and aren't strictly required when not needed for grouping purposes. Or so I thought, maybe I've been wrong all along.

Will be quite interested to hear how this turns out!

allenwb commented 7 years ago

@matthewp

I would never expect to get different behavior by adding parens when not inside another expression.

function foo() {}
(function foo() {})
ljharb commented 7 years ago

There's also ({ a: 1 }) vs { a: 1 } - but that doesn't mean it's not surprising and weird, it just means it's not a new behavior :-)

allenwb commented 7 years ago

Summary of arguments in factor of treating dynamic import keyword as an operator:

ljharb commented 7 years ago

Conceptually JS devs should not think about import as a "function call" with hidden contextual arguments. It really is just an operator applied to a string producing a promise to a significant effect.

why? what's harmful about thinking of it as an async function that takes a string and returns a promise?

allenwb commented 7 years ago

@ljharb Because it takes other information (metadata about the referring module/script) that cannot be accessed or passed to a regular function.

It's not first class. It's a special form that doesn't manifest itself as a function object.

There is no way to self-host such a function that takes such hidden arguments.

The ImportCall from is most similar to direct eval (in a backwards sort of way). eval() appears to be a function, eval even has a function object value. But direct eval is actually a special form that does things contextually that the eval function object (or any other ES function) can't do.

We should avoid new warts like that. It's a teachability/learnability issue here.

ljharb commented 7 years ago

Doesn't super() take that same metadata (ie, a reference to the [[HomeObject]]) and use it to obtain the function that it then calls?

allenwb commented 7 years ago

It's a special form that uses metadata to find the function, but it still ultimately calls a user written function after performing the [[Construct]] semantics. SuperCall is like a normal function call in that it use Arguments syntax and semantics. Note in particular, that means the spread operator works in the arguments list of a SuperCall but does not work with ImportCall as defined in the proposal.

So let's flip this question around: why isn't yield a good exemplar for the the dynamic import operator? It is applied to a single subexpression, just like import. It uses contextual metadata, just list import. It performs a unique operation that can be expressed as an ES function, just like import. In fact, I believe TC39 had a very similar discussion regarding parens when yield was being introduced into ES6.

domenic commented 7 years ago

TC39 conclusion: keep it as a function-like form. One argument that people found particularly compelling was that, in the unary operator version, the following code acts strangely:

import("foo").then(...);

String.prototype.then = function () {
  // this gets called!!
};

People were also amused by the behavior of the following in the unary operator case:

import { toString };

function toString() {
  return "foo";
}

We also discussed whether to revisit #15 and use ArgumentList instead of a syntactic restriction to a single argument. The conclusion was to keep it restricted to a single argument for now, and revisit in the future if people get confused by not being able to do import(...['foo']) and similar.

aluanhaddad commented 7 years ago

Sorry for posting on a closed issue, but as relatively novice user, I find the function-like-form to be extremely off putting. This is JavaScript, we are talking about and functions are ubiquitous. Making import('foo') a special syntactic token (perhaps I am misunderstanding something here), that returns a Promise is completely counter-intuitive because I, and I suspect many others, will instinctively want to abstract over it and then be frustrated that it doesn't work.

E.g. say I have this kind of fairly common-in-the-wild code

// dynamically kick off imports (mentioned as a motivating scenario in this discussion)
Promise.all([
    import('α'),
    import('β'),
    import('γ'),
    import('δ'),
    import('ε'),
    import('ζ'),
    import('η'),
    import('θ')
  ])
  .then(console.info.bind(console)
  .catch(console.error.bind(console);

At some point later I revisit this code, perhaps to add another import( I think to myself this is verbose and redundant, (or maybe I want to get the list of modules from an XHR response). So I refactor this to

Promise.all(['α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ'].map(import))
  .then(console.info.bind(console))
  .catch(console.error.bind(console));

I get errors, learn what I am doing wrong and fix it, but it feels completely arbitrary, especially when the following works

const {import} = System;
Promise.all(
    ['α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ'].map(import)
  )
  .then(console.info.bind(console))
  .catch(console.error.bind(console));
matthewp commented 7 years ago

You'll get syntax errors if you try to use dynamic import as a map function. I agree that this isn't ideal but a unary form wouldn't be any better in that regard. And it's not unprecedented; you can't pass super in a class constructor as a function.

System.import isn't in any current proposals and doesn't fulfill the goals of this proposal as it is not contextual.

forceuser commented 7 years ago

@aluanhaddad actually the same example applies to unary operator form import 'a' can be used as import('a') (similarly with typeof 'a' and typeof('a') or typeof(('a')))

Promise.all([
    import('α'),
    import('β'), ...
  ])

will work with unary operator and will be a syntax error if you try to use import as a call parameter

aluanhaddad commented 7 years ago

@forceuser Indeed but I don't think it is quite the same as the redundant parenthesization of operators in expressions. We can add superfluous parentheses to most expressions but in practice it is rare. I find the call syntax counter-intuitive because import('x') looks like a function invocation.

@matthewp That is definitely a strong argument from precedence. With respect to System.import it was just what came to mind when I saw the syntax.

A lot of other languages have these sorts of function-invocation-like tokens, (C++, PHP, etc.) and they work just fine.

bergus commented 7 years ago

@aluanhaddad What is even weirder, the following works again:

Promise.all(['α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ'].map(name => import(name)))
  .then(console.info, console.error);