This gist had a far larger impact than I imagined it would, and apparently people are still finding it, so a quick update:
- TC39 is currently moving forward with a slightly different version of TLA, referred to as 'variant B', in which a module with TLA doesn't block sibling execution. This vastly reduces the danger of parallelizable work happening in serial and thereby delaying startup, which was the concern that motivated me to write this gist
- In the wild, we're seeing
(async main(){...}())
as a substitute for TLA. This completely eliminates the blocking problem (yay!) but it's less powerful, and harder to statically analyse (boo). In other words the lack of TLA is causing real problems - Therefore, a version of TLA that solves the original issue is a valuable addition to the language, and I'm in full support of the current proposal, which you can read here.
I'll leave the rest of this document unedited, for archaeological reasons.
Follow-ups:
- Why imperative imports are slower than declarative imports
- Dynamic module loading done right
- Non-deterministic module ordering is an even bigger footgun
As the creator of Rollup I often get cc'd on discussions about JavaScript modules and their semantics. Recently I've found myself in various conversations about top-level await.
At first, my reaction was that it's such a self-evidently bad idea that I must have just misunderstood something. But I'm no longer sure that's the case, so I'm sticking my oar in: Top-level await
, as far as I can tell, is a mistake and it should not become part of the language. I'm writing this in the hope that I really have misunderstood, and that someone can patiently explain the nature of my misunderstanding.
ES2017 will introduce async
and await
, which make it much easier to write a series (take a mental note of that word, 'series') of asynchronous operations. To borrow from Jake:
// this Promise-based code...
function loadStory() {
return getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
return story.chapterURLs.map(getJSON)
.reduce(function(chain, chapterPromise) {
return chain.then(function() {
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("All done");
}).catch(function(err) {
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
});
}
// ...becomes this:
async function loadStory() {
try {
let story = await getJSON('story.json');
addHtmlToPage(story.heading);
for (let chapter of story.chapterURLs.map(getJSON)) {
addHtmlToPage((await chapter).html);
}
addTextToPage("All done");
} catch (err) {
addTextToPage("Argh, broken: " + err.message);
}
document.querySelector('.spinner').style.display = 'none';
}
Lovely. The intent is much clearer, and the code is more readable. You can use async
and await
in modern browsers via async-to-gen, or, if you need to support older browsers and don't mind a bit more transpiled code, via Babel with the ES2017 preset.
Note that await
can only be used inside an async
function. Top-level await is a proposal to allow await
at the top level of JavaScript modules.
Yes, it does, at first. One of the things some people don't like about JavaScript modules is that you can't load modules dynamically – whereas in Node.js you can do this...
var x = condition ? require( './foo' ) : require( './bar' );
doSomethingWith( x );
...there's no JavaScript module equivalent, because import
declarations are entirely static. We will be able to load modules dynamically when browsers eventually support them...
// NB: may not look exactly like this
import( condition ? './foo.js' : './bar.js' ).then( x => {
doSomethingWith( x );
});
...but as you can see, it's asynchronous. It has to be, because it has to work across a network – it can't block execution like require
does.
Top-level await
would allow us to do this:
const x = await import( condition ? './foo.js' : './bar.js' );
doSomethingWith( x );
Edit: it's actually worse than I thought – see this follow-up
The problem here is that any modules that depend on modules with a top-level await
must also wait. The example above makes this seem harmless enough, but what if your app depended on this module?
// data.js
const data = await fetch( '/data.json' ).then( r => r.json() ).then( hydrateSomehow );
export default data;
You've just put your entire app – anything that depends on data.js
, or that depends on a module that depends on data.js
– at the mercy of that network request. Even assuming all goes well, you've prevented yourself from doing any other work, like rendering views that don't depend on that data.
And remember before, when I asked you to make a note of the word 'series'? Since module evaluation order is deterministic, if you had multiple modules making similar network requests (or indeed anything asynchronous) those operations would not be able to happen in parallel.
To illustrate this, imagine the following contrived scenario:
// main.js
import './foo.js';
import './bar.js';
// foo.js
await delay( Math.random() * 1000 );
console.log( 'foo happened' );
// bar.js
await delay( Math.random() * 1000 );
console.log( 'bar happened' );
What will the order of the logs be? If you answered 'could be either', I'm fairly sure you're wrong – the order of module evaluation is determined by the order of import
declarations.
As far as I can tell (and I could be wrong!) there's simply no way for a CommonJS module to require
a JavaScript module with a top-level await
. The path towards interop is already narrow enough, and we really need to get this right.
No, you won't. You'll educate some of them, but not all. If you give people tools like with
, eval
, and top-level await
, they will be misused, with bad consequences for users of the web.
Hogwash. There's nothing wrong with this:
import getFoo from './foo.js';
import getBar from './bar.js';
import getBaz from './baz.js';
async function renderStuff () {
const [ foo, bar, baz ] = await Promise.all([ getFoo(), getBar(), getBaz() });
doSomethingWith( foo, bar, baz );
}
renderStuff();
// code down here can happily execute while all three
// network requests are taking place
doSomeOtherStuffWhileWeWait();
The version that uses top-level await – where network requests happen serially, and we can't do anything else while they happen – is slightly nicer, but certainly not to a degree that justifies the degraded functionality:
import foo from './foo.js';
import bar from './bar.js';
import baz from './baz.js';
doSomethingWith( foo, bar, baz );
// code down here has to wait for all the data to arrive
apologiseToTheUserForMakingThemWait();
True, dynamic module loading is a bit trickier in this context (though still absolutely possible – also, see dynamic module loading done right). Robert Palmer suggests that a compromise would be to allow the await
keyword at the top level but only for await import(...)
, not anything else. I'm ambivalent about having a keyword mean slightly different things in different places, but this seems worth exploring.
Say that to my face.
No, I'm not – I don't believe that tooling should drive these sorts of conversations (though at the same time, great things can happen when language designers think deeply about tooling and workflow). But yes, since you asked, top-level await
probably will make it harder for tools like Rollup to create really tightly-optimised bundles of code.
Lots of smarter and more experienced developers than me seem to think top-level await
is a great idea, so perhaps it is just me. It seems unlikely that none of this has occurred to people on TC39. But then again maybe joining those echelons involves a transformation that makes it hard to relate to mortals, like Jon Osterman becoming Doctor Manhattan.
Hopefully someone will be able to explain which it is.
Just a general though regarding the argument for trying to protect developers from themselves... It's kind of like saying "since people have the ability to drink and drive, alcohol should be outlawed." Would it save some lives? Sure, but you also inconvenience those who can use it responsibly. You also haven't prevented cases where people obtain alcohol illegally or use their car recklessly while sober.
PHP attempted the same paradigm when they introduced 'magic quotes'. The idea was that all GET and POST data automatically had special characters quoted so when put into SQL statements query injection would be mitigated. They assumed this would protect bad or inexperienced programmers and help them write safer code. In the short term it helped, but in the long run it ended up lulling developers into a false sense of security. It ended up creating more problems, didn't full protect, and didn't help developers be better. In then end they removed this feature.
If you let this kind of thinking hold progress back it does everyone a disservice and you'll just end up stressing yourself out over someone else's problem.