- Why Should You Use top-level Await in JavaScript?
- By Mahdhi Rezvi
- Translator: Chor
As a very flexible and powerful language, JavaScript has had a profound impact on the modern Web. One of the main reasons for its dominance in Web development is the constant improvement that frequent updates bring.
Top-level await is a new feature covered in proposals in recent years. This feature allows the ES module to behave as an async function, allowing the ES module to await data and block other modules that import the data. Only when the data is identified and ready can the module that imports the data execute the corresponding code.
The proposal for this feature is still in stage 3, so we can’t use it directly in production. But given that it’s coming out in the near future, it pays to get a preview.
It doesn’t matter if you sound confused, keep reading and I’ll work with you on this new feature.
What was the problem with the old way of writing it?
If you try to use the await keyword outside an async function before introducing the top-level await, it will cause a syntax error. To avoid this problem, developers often use immediately executed function expressions (IIFE)
await Promise.resolve(console.log('❤ ️'));
/ / an error
(async() = > {await Promise.resolve(console.log('❤ ️'));
/ / ❤ ️}) ();Copy the code
Yet this is only the tip of the iceberg
When using ES6 modularity, you often encounter import and export scenarios. Consider the following example:
//------ library.js ------
export const sqrt = Math.sqrt;
export function square(x) {
return x * x;
}
export function diagonal(x, y) {
return sqrt(square(x) + square(y));
}
//------ middleware.js ------
import { square, diagonal } from './library.js';
console.log('From Middleware');
let squareOutput;
let diagonalOutput;
// IIFE
(async() = > {await delay(1000);
squareOutput = square(13);
diagonalOutput = diagonal(12.5); }) ();function delay(delayInms) {
return new Promise(resolve= > {
setTimeout(() = > {
resolve(console.log('❤ ️'));
}, delayInms);
});
}
export {squareOutput,diagonalOutput};
Copy the code
In this example, we import and export variables between library.js and middleware.js (filename arbitrary, not important here)
If you read carefully, you’ll notice that there is a delay function that returns a Promise that will be resolved when the timer ends. Since this is an asynchronous operation (in a real business scenario, this would be a FETCH call or some asynchronous task), we use await in async IIFE to wait for the result of its execution. Once a promise is resolved, we execute the function imported from library.js and assign the calculated result to both variables. This means that both variables will be undefined until a promise is resolved.
At the very end of the code, we export the two calculated variables for use by another module.
The following module is responsible for importing and using the above two variables:
//------ main.js ------
import { squareOutput, diagonalOutput } from './middleware.js';
console.log(squareOutput); // undefined
console.log(diagonalOutput); // undefined
console.log('From Main');
setTimeout(() = > console.log(squareOutput), 2000);
/ / 169
setTimeout(() = > console.log(diagonalOutput), 2000);
/ / 13
Copy the code
Run the code above and you’ll see that the first two prints get undefined, and the next two get 169 and 13. Why is that?
This is because main.js accesses the middleware.js exported variables before the async function completes execution. Remember? We still have a promise to resolve…
To solve this problem, we need to find a way to tell the module to import variables when it is ready to access them.
The solution
There are two widely used solutions to the above problems:
1. Export a Promise to indicate initialization
You can export an IIFE and rely on it to determine when you can access the exported results. The async keyword asynchronizes a method and returns a promise accordingly. Therefore, in the following code async IIFE returns a promise.
//------ middleware.js ------
import { square, diagonal } from './library.js';
console.log('From Middleware');
let squareOutput;
let diagonalOutput;
// Solution
export default (async() = > {await delay(1000);
squareOutput = square(13);
diagonalOutput = diagonal(12.5); }) ();function delay(delayInms) {
return new Promise(resolve= > {
setTimeout(() = > {
resolve(console.log('❤ ️'));
}, delayInms);
});
}
export {squareOutput,diagonalOutput};
Copy the code
When accessing the exported result in main.js, you can wait for async IIFE to be resolved before accessing the variable.
//------ main.js ------
import promise, { squareOutput, diagonalOutput } from './middleware.js';
promise.then(() = >{
console.log(squareOutput); / / 169
console.log(diagonalOutput); / / 13
console.log('From Main');
setTimeout(() = > console.log(squareOutput), 2000);/ / 169
setTimeout(() = > console.log(diagonalOutput), 2000);/ / 13
})
Copy the code
While the scheme works, it also introduces new problems:
- Everyone has to follow this pattern as a standard, and they have to find and wait for the right promise;
- If there is another module dependency
main.js
The variables in thesquareOutput
和diagonalOutput
, then we need to write a similar IIFE Promise again and export it so that another module can access the variable correctly.
To solve these two new problems, a second solution has emerged.
2. Use exported variables to resolve IIFE Promise
In this scenario, instead of exporting variables individually as before, we return variables as async IIFE return values. In this case, main.js simply waits for the promise to be resolved, and then grabs the variable directly.
//------ middleware.js ------
import { square, diagonal } from './library.js';
console.log('From Middleware');
let squareOutput;
let diagonalOutput;
export default (async() = > {await delay(1000);
squareOutput = square(13);
diagonalOutput = diagonal(12.5);
return{squareOutput,diagonalOutput}; }) ();function delay(delayInms) {
return new Promise(resolve= > {
setTimeout(() = > {
resolve(console.log('❤ ️'));
}, delayInms);
});
}
//------ main.js ------
import promise from './middleware.js';
promise.then(({squareOutput,diagonalOutput}) = >{
console.log(squareOutput); / / 169
console.log(diagonalOutput); / / 13
console.log('From Main');
setTimeout(() = > console.log(squareOutput), 2000);/ / 169
setTimeout(() = > console.log(diagonalOutput), 2000);/ / 13
})
Copy the code
But the scheme has its own complexities.
According to the proposal, “The downside of this model is that it requires extensive refactoring of relevant data to use dynamic models; At the same time, it places most of the contents of the module in the callback function of.then() to use dynamic imports. From a static analysis, testability, engineering and other perspectives, this is a significant step backward from the modularity of ES2015.”
How does the top-level Await solve the above problem?
The top-level await allows us to let the modular system handle the coordination between promises, which makes our side of the story incredibly easy.
//------ middleware.js ------
import { square, diagonal } from './library.js';
console.log('From Middleware');
let squareOutput;
let diagonalOutput;
// Use top-level await
await delay(1000);
squareOutput = square(13);
diagonalOutput = diagonal(12.5);
function delay(delayInms) {
return new Promise(resolve= > {
setTimeout(() = > {
resolve(console.log('❤ ️'));
}, delayInms);
});
}
export {squareOutput,diagonalOutput};
//------ main.js ------
import { squareOutput, diagonalOutput } from './middleware.js';
console.log(squareOutput); / / 169
console.log(diagonalOutput); / / 13
console.log('From Main');
setTimeout(() = > console.log(squareOutput), 2000);/ / 169
setTimeout(() = > console.log(diagonalOutput), 2000); / / 13
Copy the code
None of the statements in main.js will be executed until the await promise in middleware.js is resolved. This approach is much cleaner than the solution mentioned earlier.
Pay attention to
It is important to note that the top-level await is only valid in the ES module. In addition, you must explicitly declare dependencies between modules for the top-level await to work as expected. This code in the proposal repository illustrates this problem nicely:
// x.mjs
console.log("X1");
await new Promise(r= > setTimeout(r, 1000));
console.log("X2");
// y.mjs
console.log("Y");
// z.mjs
import "./x.mjs";
import "./y.mjs";
//X1
//Y
//X2
Copy the code
This code does not print in the expected order X1,X2,Y. This is because X and Y are independent modules and do not depend on each other.
It is recommended that you read the documentation q&A to get a fuller understanding of the new top-level await feature.
The trial
V8
You can try using the top-level await feature, as the documentation says.
I use the V8 method. Find where Chrome is installed on your computer, make sure to close all processes in the browser, open the command line and run the following command:
chrome.exe --js-flags="--harmony-top-level-await"
Copy the code
This will enable support for the top-level await feature when Chrome reopens.
Of course, you can also test in the Node environment. Read this guide for more details.
ES module
Be sure to declare this property in the script tag: type=”module”
<script type="module" src="./index.js" >
</script>
Copy the code
Note that unlike normal scripts, declared modularized scripts are subject to CORS policies, so you need to open the file through the server.
Application scenarios
Here are the relevant use cases mentioned in the proposal:
Dynamic dependency paths
const strings = await import(`/i18n/${navigator.language}`);
Copy the code
Allows modules to calculate dependencies using runtime values. This is very useful for differentiating production/development environments, internationalization efforts, etc.
Resource initialization
const connection = await dbConnector();
Copy the code
This helps to treat the module as some kind of resource and can throw errors if the module doesn’t exist. Errors can be handled in the fallback described below.
Rely on the backup plan
The following example shows how to load dependencies with backup with top-level await. If CDN A cannot import jQuery, CDN B will try to import jQuery from CDN A.
let jQuery;
try {
jQuery = await import('https://cdn-a.example.com/jQuery');
} catch {
jQuery = await import('https://cdn-b.example.com/jQuery');
}
Copy the code
attack
Rich Harris raises many critical questions about the nature of top-level await:
- At the top
await
Blocks code execution - At the top
await
It blocks resource retrieval - CommonJS modules have no explicit interoperability scheme
The Stage 3 proposal addresses these issues directly:
- Because sibling modules can execute, there is no blocking;
- At the top
await
Comes into play during the execution phase of the module diagram, when all resources have been fetched and linked, so there is no risk of resources being blocked; - At the top
await
It is limited to ES6 modules and is not intended to support normal scripts or CommonJS modules
I highly recommend that you read the proposed FAQ to further understand this new feature.
By now, you already know something about this cool new feature. Can’t wait to use it? Let’s talk in the comments section.