Appearance
Browser Module System
When to use
Use this module system when building prototypes or demos in the browser without a bundler and you need multiple <script> blocks to share code. Native ES modules require a server for import statements, and loading order matters with global variables. This pattern gives you module.import() and module.export() — a Promise-based registry that lets scripts load in any order and resolve dependencies asynchronously.
The pattern
A global module object exposes two functions:
module.export(name, value)— registers a value under a name and resolves any pending imports.module.import(...names)— returns a Promise that resolves when all named modules have been exported. The result supports both array and object destructuring.
Minimal example
Scripts can appear in any order — the import waits for the export to happen. The first script below runs before the second, but module.import() suspends until the matching module.export() resolves the name:
html
<!-- this runs first, but waits for "greeter" -->
<script type="module">
const [greeter] = await module.import("greeter");
document.body.textContent = greeter("World");
</script>
<!-- this runs second, resolving the import above -->
<script type="module">
module.export("greeter", (name) => `Hello, ${name}!`);
</script>Array destructuring
When importing multiple modules, destructure the resolved array positionally to bind each module to a local variable:
js
const [utils, dom] = await module.import("utils", "dom");Object destructuring (auto camelCase)
Module names containing hyphens are automatically converted to camelCase, so you can use object destructuring instead of positional array access:
js
const { utils, domHelpers } = await module.import("utils", "dom-helpers");Example: Bridging CDN ES modules
Load a library with native import from a CDN, then re-export into the module system so non-module scripts can consume it:
html
<script type="module">
import * as Vue from
"https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js";
module.export("vue", Vue);
</script>
<script type="module">
const { createApp } = await module.import("vue");
createApp({ /* ... */ }).mount("#app");
</script>Example: Vue app without build tools
Combine the module system with <template> elements to build a component-based Vue app with no bundler. The first script exports a template() helper, and the second imports both Vue and the helper to mount a reactive counter:
html
<template name="counter">
<button @click="count++">
Count: {{ count }}
</button>
</template>
<script type="module">
function template(name) {
const el = document.querySelector(`template[name="${name}"]`);
return el.innerHTML;
}
module.export("template", template);
</script>
<script type="module">
const { createApp } = await module.import("vue");
const template = await module.import("template");
createApp({
template: template("counter"),
setup() {
return { count: 0 };
},
}).mount("#app");
</script>Implementation
A Map stores one entry per module name. Each entry holds a Promise created with Promise.withResolvers() so export can resolve it at any time. importModules returns a hybrid array with both positional and named properties (auto-camelCased) so callers can destructure either way. exportModule throws if the same name is exported twice to catch accidental collisions:
js
{
const modules = new Map();
function get(name) {
if (!modules.has(name)) {
const { promise, resolve } = Promise.withResolvers();
modules.set(name, {
resolved: false,
value: undefined,
promise,
resolve,
});
}
return modules.get(name);
}
function toCamelCase(name) {
return name
.replace(/^[@_]/g, "")
.replace(/[@/.]/g, "-")
.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
globalThis.module = {
export(name, value) {
const entry = get(name);
if (entry.resolved) {
throw new Error(`"${name}" already exported`);
}
entry.value = value;
entry.resolved = true;
entry.resolve(value);
},
async import(...args) {
const names = args.flat();
const entries = await Promise.all(
names.map((n) => get(n).promise)
);
const result = [...entries];
for (const [i, name] of names.entries()) {
result[toCamelCase(name)] = entries[i];
result[name] = entries[i];
}
return result;
},
};
}Trade-offs
Advantages:
- No build step or dev server required
- Scripts can load in any order
- Familiar
import/exportvocabulary - Works with inline
<script>tags and CDN modules - Hybrid destructuring adapts to caller preference
Disadvantages:
- Not real ES modules — no static analysis or tree shaking
- Global
moduleobject can collide with other code - No circular dependency detection
- Not suitable for production applications with many modules