Step 1 of 7 · Reading · ~3 min
Learn
Collections and Property Descriptors
Modules in Depth: Dynamic Imports
JavaScript Fundamentals showed import and export. Here is what happens underneath when modules load, and the tools that go with it: import() to load on demand, top-level await, and how ES modules differ from the older CommonJS system. By the end you can explain when a module's code runs, why loading it twice runs it once, and how asynchronous loading is orchestrated.
Static and dynamic imports
A static import sits at the top level. The whole graph of files it names is found and linked before any code runs, which is what lets tools see your dependencies in advance:
A dynamic import(path) is an expression. It can appear anywhere, its path can be computed, and it returns a promise of the module's namespace object (all its exports as properties):
Reach for it to load rarely used code on demand (an admin screen, a heavy chart library), to choose a module by name at run time, or to use an ES module from CommonJS code.
A module runs once
Every module is evaluated at most once, no matter how many files import it. The engine keeps a table from module to its evaluation, and later imports get the same result. Two things follow:
- Module-level state is shared by everyone: a counter in a module is a singleton.
- When two imports of the same module overlap in time, the second must wait for the first evaluation, not start another. What is stored in the table is therefore the evaluation in progress, not only its finished result.
Top-level await
In an ES module you may write await at the top level, outside any function:
The module counts as "still evaluating" until the await settles, and every module that imports it waits as well, while unrelated modules carry on. Use it sparingly: each one delays everything depending on it. Plain scripts (CommonJS), such as your exercises here, do not allow it, so you wrap the work in async function main() and call it.
ES modules versus CommonJS
| ES modules | CommonJS | |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| Loading | static analysis first; evaluated asynchronously | require runs the file immediately |
| Exports | live bindings: importers see later changes | a snapshot object |
| Where | browsers and Node (.mjs, or "type": "module") | Node (.js by default, or .cjs) |
| Extras | import.meta.url, top-level await | __dirname, __filename |
import.meta.url is the module's own address; derive a directory from it where CommonJS code used __dirname. Node needs the file extension in relative import paths. An ES module can import a CommonJS module, but classically CommonJS reached an ES module only through import(), which is what the dynamic form is also for.
Other points: import "./setup.js" (no names) runs a file only for its side effects; static imports allow tree shaking, where a bundler drops exports nobody imports, while a dynamic import with a computed path cannot be analysed this way, in exchange for code splitting.
Common mistakes
- Leaving out the
.jsextension in a Node ES-module import. - Using
require,__dirnameormodule.exportsin an ES module. - Expecting a module's top level to run again on every import.
- Caching only the finished result of a load, so two simultaneous imports both evaluate it.
- Using top-level
awaitin a plain script.
Your exercise
The practice runner cannot load real ES modules, so you will build the loader instead: the module table, the waiting, and the dependency order.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…