Skip to content

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 / export vocabulary
  • 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 module object can collide with other code
  • No circular dependency detection
  • Not suitable for production applications with many modules