Skip to content
Back to blog

Modularization

A summary of frontend modularization

Updated

The Evolution of Modularization

Goals

  • Reduce naming conflicts
  • Split code and load on demand
  • High reusability
  • High maintainability

The Global function Pattern

Form

function module1() {
  return {
    method1() {},
  }
}

function module2() {
  return {
    method1() {},
  }
}

Advantages

  • Simple to use; dependent modules are easy to call, reducing duplicate code
  • Isolates scope, avoiding conflicts between variable and function names that could cause variables or functions to be accidentally overwritten

Disadvantages

  • Pollutes the global scope at the function-name level; a large number of functions get attached to window
  • Getting a variable requires calling a function every time, making it hard to isolate variables (each call resets them)
  • Lacks access control; internal data may be accidentally modified by other modules due to things like typos

The namespace Pattern

Form

const myModule = {
  data: 'Naprte',
  foo() {
    console.log(`foo() ${this.data}`)
  },
  bar() {
    console.log(`bar() ${this.data}`)
  },
}
myModule.data = 'the data inside the module can be modified directly'
myModule.foo()

Advantages

  • Simple to use; dependent modules are easy to call, reducing duplicate code
  • Isolates scope, avoiding conflicts between variable and function names that could cause variables or functions to be accidentally overwritten
  • The module can remain a singleton — no need to call a function every time
  • Compared to the global function form, it reduces global pollution

Disadvantages

  • Pollutes the global scope at the variable-name level; a large number of global variables get attached to window
  • Lacks access control; internal data may be accidentally modified by other modules due to things like typos

The IIFE Pattern

Form

// module.js file
(function (window) {
  const data = 'www.naparte.com'
  // Functions that operate on the data
  function foo() {
    // Functions to be exposed
    console.log(`foo() ${data}`)
  }
  function bar() {
    // Functions to be exposed
    console.log(`bar() ${data}`)
    otherFun() // Internal call
  }
  function otherFun() {
    // An internal private function
    console.log('otherFun()')
  }
  // Expose the behavior
  window.myModule = { foo, bar } // ES6 syntax
})(window)

Advantages

  • Creates an independent scope, avoiding global pollution
  • Can perform internal initialization and stay a singleton
  • Tighter access control — thanks to the nature of anonymous functions, the outside cannot easily modify the inside of an IIFE directly

Disadvantages

  • The tight access control makes reuse difficult; calling and reusing requires nested IIFEs, resulting in an unclear code structure
  • Hard to handle scenarios where multiple modules use the same module

CommonJS Modules

Form

// example.js
const x = 5
function addX(value) {
  return value + x
}
module.exports.x = x
module.exports.addX = addX

// index.js
const example = require('./example.js')

console.log(example.x) // 5
console.log(example.addX(1)) // 6

Advantages

  • Scope is isolated at the file level; internals are private, and public content is exported via module.exports and exports
  • No pollution of the global scope
  • While access is restricted, reuse by other modules is still supported
  • Natively supported by Node.js, server-friendly

Disadvantages

  • Modules are parsed with DFS and loaded synchronously, which may block the page
  • Cumbersome to use in browsers; requires a build tool
  • Sensitive to module loading order; an abnormal order may cause module initialization errors that are hard to troubleshoot
  • When binding a primitive to module.exports, you must modify module.exports rather than the original variable, otherwise the outside cannot detect the change
  • Reusing modules is costly and may pull in large amounts of unused code (no tree-shaking support)

The AMD Specification

Form

// Configure module paths (must be set before loading the library)
const requireConfig = {
  paths: {
    math: 'js/modules/math',
    add: 'js/utils/add',
    multiply: 'js/utils/multiply'
  }
}

// Define the module math.js, depending on the 'add' and 'multiply' modules
define(['add', 'multiply'], (add, multiply) => {
  return {
    calculate(a, b) {
      return multiply(add(a, 5), b)
    }
  }
})

// Load the module
require(['math'], (math) => {
  console.log(math.calculate(2, 3)) // Output the calculation result
})

Advantages

  • Supports asynchronous module loading
  • Dependencies upfront — all dependencies must be declared before the module loads
  • Supports on-demand loading; a module is only loaded when require is executed
  • Scope is isolated at the file level; internals are private, and public content is exported via module.exports and exports
  • No pollution of the global scope
  • While access is restricted, reuse by other modules is still supported

Disadvantages

  • Complex syntax, hard to read and write
  • Requires an extra require.js library at runtime
  • No dynamic dependency loading — only static declarations
  • AMD exports can be any value, lacking constraints
  • Reusing modules is costly and may pull in large amounts of unused code (no tree-shaking support)
  • AMD dependency loading and module initialization happen at runtime, which may increase the page's initial load latency (especially with long dependency chains)
  • Asynchronous loading can make code execution order hard to trace, increasing debugging difficulty

The CMD Specification

Form

// Define a module without dependencies
define((require, exports, module) => {
  exports.xxx = value
  module.exports = value
})

// Define a module with dependencies
define((require, exports, module) => {
  // Import a dependency module (synchronous)
  const module2 = require('./module2')
  // Import a dependency module (asynchronous)
  require.async('./module3', (m3) => {})
  // Expose the module
  exports.xxx = value
})

// Import the modules to use
define((require) => {
  const m1 = require('./module1')
  const m4 = require('./module4')
  m1.show()
  m4.show()
})

Advantages

  • Supports both synchronous and asynchronous code loading
  • Supports dynamic import
  • Controls exports via module.exports
  • Easy to reuse
  • Independent scope, avoiding global pollution

Disadvantages

  • Depends on the sea.js library at runtime
  • Reusing modules is costly and may pull in large amounts of unused code (no tree-shaking support)

ESM

Form

// The export command defines a module's public interface; the import command brings in functionality provided by other modules
/** Define the module math.js **/
var basicNum = 0;
var add = function (a, b) {
  return a + b;
};
export { basicNum, add };

/** Reference the module **/
import { basicNum, add } from "./math";
function test(ele) {
  ele.textContent = add(99 + basicNum);
}

// When using the import command, users need to know the name of the variable or function to load, otherwise it cannot be loaded. To make things easier for users — so they can load a module without reading the documentation — the export default command is used to specify a default export for the module.
// export-default.js
export default function () {
  console.log("foo");
}

// import-default.js
import customName from "./export-default";
customName(); // 'foo'

Advantages

  • Official ECMA standard with concise syntax
  • Supports static analysis optimization and tree-shaking
  • Module-level scope, avoiding global pollution
  • Cross-platform compatibility — natively supported by browsers, nodejs, and other environments

Disadvantages

  • Upgrading legacy code is relatively difficult
  • Importing via importmap in browser environments may have cross-origin issues
  • In some scenarios, a build tool like webpack is needed to convert to other formats before the code can be used by other libraries

The UMD Specification

Form

(function (root, factory) {
  if (typeof module === 'object' && typeof module.exports === 'object') {
    console.log('This is the CommonJS module spec, nodejs environment')
    module.exports = factory()
  } else if (typeof define === 'function' && define.amd) {
    console.log('This is the AMD module spec, e.g. require.js')
    define(factory)
  } else if (typeof define === 'function' && define.cmd) {
    console.log('This is the CMD module spec, e.g. sea.js')
    define((require, exports, module) => {
      module.exports = factory()
    })
  } else {
    console.log('No module environment; mount directly on the global object')
    root.umdModule = factory()
  }
})(this, () => {
  return {
    name: 'I am a UMD module',
  }
})

Advantages

  • Highly reusable — supports reuse across runtime environments, satisfying the CommonJS, AMD, and CMD specs at the same time
  • Strong compatibility, covering mainstream runtime environments
  • Supports progressive upgrades from older specs

Disadvantages

  • High code complexity
  • May pollute global variables, e.g. in browser environments
  • No tree-shaking support

Summary

  • The CommonJS spec is mainly used for server-side programming. It loads modules synchronously, which does not suit browser environments — synchronous means blocking loads, while browser resources load asynchronously — hence the AMD and CMD solutions;
  • The AMD spec loads modules asynchronously in browser environments and can load multiple modules in parallel. However, AMD has high development costs — the code is hard to read and write, and the semantics of its module definition style are awkward;
  • The CMD spec is very similar to AMD; both are used for browser programming, with nearby dependencies and lazy execution, and can easily run in Node.js;
  • ES6 implements module functionality at the language-standard level, in a fairly simple way, and can completely replace the CommonJS and AMD specs as a universal module solution for both browsers and servers;
  • UMD is an implementation that satisfies the CommonJS, AMD, and CMD standards at the same time;

CommonJS

Introduction

  • Implemented by the JS runtime — an upper-level remedy made when the underlying module mechanism was missing.
  • Modules analyze their dependencies during the execution phase, using depth-first traversal; the execution order is parent -> child -> parent
  • Module files are loaded and executed synchronously (no distinction between loading and parsing — everything runs synchronously)
  • The position of import/export statements affects the module code's execution result
  • A CommonJS module exports a single object containing several variables

Use Cases

  • Supported by default in nodejs
  • Other scenarios need a build tool to bundle the code and simulate it with a runtime polyfill, e.g. running in browsers

Module Loading Errors

As a runtime solution, loading errors are thrown via throw

Execution Order

CommonJS modules execute in order. When a require is encountered, the corresponding module's code is loaded and executed, and then execution returns to the current module. As shown below, module A depends on modules B and C, and module A is split top-down by 2 require statements into 3 segments, denoted A1, A2, A3.

---
title: CommonJS Module Relationship Diagram
---
flowchart TD
  A[Module A]
  B[Module B]
  C[Module C]
  A --> B
  A --> C

The execution order is: A1 -> B -> A2 -> C -> A3

That is, the following sequence diagram

---
title: Module Resolution Sequence Diagram
---
sequenceDiagram
  participant A
  participant B
  participant C

  Note over A: Execute A1
  A ->> B: A require B
  Note over B: Execute B
  B ->> A: Return control
  Note over A: Execute A2
  A ->> C: A require C
  Note over C: Execute C
  C ->> A: Return control
  Note over A: Execute A3

Circular Dependencies

Module A depends on module B, and module B depends on module A. Modules A and B are each split top-down by require statements into 2 segments, denoted A1, A2, B1, B2.

The corresponding reference diagram is as follows:

---
title: CommonJS Circular Dependency Module Diagram
---
flowchart TD
  A[Module A]
  B[Module B]
  A --> B
  B --> A

That is, the following sequence diagram:

---
title: CommonJS Circular Dependency Resolution Diagram
---
sequenceDiagram
  participant A
  participant B

  Note over A: Execute A1
  A ->> B: A require B
  Note over B: Execute B1
  B ->> A: B require A
  Note over A: Get the execution result of A1
  A -->> B: Return the context after A1 finished
  Note over B: Execute B2
  B ->> A: Return control
  Note over A: Execute A2

If code from the A2 part is referenced in B1, Runtime-level CommonJS usually cannot detect it, so an undefined value is obtained; if the code has fault tolerance, no exception is thrown.

ESM (ECMAScript Module)

Introduction

  • Implemented inside the JS engine — a low-level implementation at the language level
  • Modules analyze their dependencies in the preprocessing phase and execute in the execution phase; both phases use depth-first traversal, and the execution order is child -> parent
  • Module files are loaded and executed ahead of time (all modules are loaded first, then executed one by one)
  • The position of import/export statements does not affect the execution result of module code
  • ESM exports individual variables separately. Looking only at the parent module, the parent module does bind the child module's exported variables during the preprocessing phase, but at that point the child's exported variables have not yet been assigned their final values, so it does not count as a real export

Use Cases

  • Node 16+ supports ESM by default, but files need the mjs extension and package.json needs to declare "type": "module"
  • Non-IE browsers and newer mobile browsers support declaring ESM code via the <script type="module"> syntax, and declaring where dependency packages load from via importmap
  • Other scenarios need a build tool to bundle the code and simulate it with a runtime polyfill

Module Loading Errors

As an engine-level solution, loading errors point directly to the specific erroneous statement, with an effect similar to a throw statement

Execution Order

ESM has 5 states — unlinked, linking, linked, evaluating, evaluated — represented by the Status field of the cyclic module record (Module Environment Records). ESM processing consists of two steps: linking and evaluation. Evaluation can only happen after linking succeeds.

Linking is mainly implemented by the function InnerModuleLinking. InnerModuleLinking calls InitializeEnvironment, which initializes the module's Environment Records — mainly creating the module's Execution Contexts, creating bindings for imported module variables and initializing them to the corresponding variables of the child module, creating bindings for var variables initialized to undefined, creating bindings for function declarations initialized to the instantiated function body, and creating bindings for other variables without initializing them.

---
title: ESM Module Relationship Diagram
---
flowchart TD
  A[Module A]
  B[Module B]
  C[Module C]
  A --> B
  A --> C

The execution order is: preprocess B -> preprocess C -> preprocess A -> execute B -> execute C -> execute A

That is, the following sequence diagram

---
title: ESM Module Resolution Flowchart
---
sequenceDiagram
  participant A
  participant B
  participant C

  A ->> B: A import B
  Note over B: Preprocess B
  B ->> A: Return control
  A ->> C: A import C
  Note over C: Preprocess C
  C ->> A: Return control
  Note over A: Preprocess A

  A ->> B: A import B
  Note over B: Execute B
  B ->> A: Return control
  A ->> C: A import C
  Note over C: Execute C
  C ->> A: Return control
  Note over A: Execute A

Because of the preprocessing phase, the following code executes normally

// Preprocessing defines the variable a — effectively variable hoisting — so no error is thrown
import { a } from './child.js'

console.log(a)

Circular Dependencies

Module A depends on module B, and module B depends on module A. Modules A and B are each split top-down by require statements into 2 segments, denoted A1, A2, B1, B2.

The corresponding reference diagram is as follows:

---
title: ESM Circular Dependency Module Diagram
---
flowchart TD
  A[Module A]
  B[Module B]
  A --> B
  B --> A

That is, the following sequence diagram:

---
title: ESM Circular Dependency Resolution Diagram
---
sequenceDiagram
  participant A
  participant B

  A ->> B: A import B
  Note over B: Preprocess B
  B ->> A: Return control
  Note over A: Preprocess A
  A ->> B: A import B
  Note over B: Execute B
  B ->> A: Return control
  Note over A: Execute A

If code from A is referenced in B, since B executes first, the variables imported from A at execution time are only defined but not initialized — using them directly causes a JS error.

Module Resolution Strategies

  • When discussing module resolution strategies, the type of file being looked up does not matter. css, png, html, and wasm files can all be treated as modules.
  • Which tool looks up the module does not matter either. tsc, nodejs, vite, esbuild, webpack, and rspack all need to handle import/require, all need to resolve modules, and all need to choose a lookup strategy — and the vast majority use the node strategy
  • The node module resolution strategy itself keeps evolving. For example, early node did not support the exports field of package.json

The classic Strategy

// File: /root/src/folder/index.js
import 'pkg'

It goes through the following steps to find pkg:

  1. /root/src/folder/pkg.js
  2. /root/src/pkg.js
  3. /root/pkg.js
  4. /pkg.js

Simply put, this module resolution strategy keeps recursively looking upward for a file with the same name — if it is not found in the current directory, it looks in the parent directory. However, this strategy is not widely used in the frontend world today.

The node Strategy

The most common strategy today is the node strategy. The other strategies — node16, nodenext, bundler, etc. — resolve in much the same way as node, with only some conditions added on top of file names and module specs; see Summary of module resolution strategies for details.

nodejs (module#all-together), webpack (enhanced-resolve), vite (resolve.exports), and rspack (nodejs_resolver) all use this strategy to resolve modules (rollup has no built-in resolution strategy)

// File: /root/src/folder/index.js
import 'pkg'

It goes through the following steps to find pkg:

  1. Look for a js file with the same name in the sibling node_modules: /root/src/node_modules/pkg.js
  2. Look in the sibling node_modules for a folder named pkg containing a package.json: /root/src/node_modules/pkg/package.json
  3. Look in the sibling node_modules for a folder named pkg containing an index.js: /root/src/node_modules/pkg/index.js
  4. If still not found, go up one directory level and repeat the previous lookup steps
  5. /root/node_modules/pkg.js
  6. /root/node_modules/pkg/package.json
  7. /root/node_modules/pkg/index.js

Summary

The classic Strategy

  • The directories searched recursively are parent folders, not node_modules
  • package.json is not parsed; the overall resolution strategy is fairly crude
  • Folder modules are not supported — that is, indexing into pkg/index.js via require('pkg')
node (Node.js CommonJS Resolution)
  • Optional extensions: file extensions may be omitted and are auto-completed with .js, .json, .node, .ts, .d.ts, .tsx
  • Folder modules: folder modules are supported — a folder's module can be indexed via an index file or package.json
  • node_modules-based lookup: searches node_modules directories level by level, with a fixed directory name
  • Complex strategies configured via package.json: package.json has many fields that can declare a module's entry, affecting the resolution strategy. Declaring multiple fields at once achieves good resolution compatibility, but also introduces complexity such as priority issues.
node16 / nodenext (Node.js ESM Strict Mode)
  • ESM support: supports import / export syntax and top await
  • Mandatory extensions: in ESM, the extension must be specified explicitly, otherwise an error is thrown
  • Module type distinguished by module type: CommonJS and ESM are distinguished by .mjs / .cjs or the type field of package.json (which declares what format .js files use), and different module types use different resolution capabilities
  • Subpath exports: configured via the exports field; see The exports Field for details
bundler (Lenient Mode Aimed at Build Tools)
  • Optional extensions: omitting extensions is supported, and they are not auto-completed — left to the build tool
  • Module type: module types are not distinguished — left to the build tool
  • Partial exports support: ignores the node and browser fields, preferring the import and module fields
  • No recursive node_modules: left to the build tool, which flattens node_modules

Module Main Entry

The classic strategy does not consider package.json, so this chapter mainly concerns strategies other than classic.

package.json is unavoidable in frontend development — many frontend tools support writing configuration in package.json. Under node_modules, a folder containing a package.json can be treated as a module, and we can use package.json to define the resolution rules for this module when it is imported by another module.

The main Field

Using the main field to define how a module is exported is currently the most common practice. The most minimal package.json declaration is as follows:

{
  "name": "lodash",
  "version": "1.1.1",
  "main": "lodash.js"
}

When no other fields are present, the node strategy resolves a module without a subpath to the file pointed to by the main field.

Paths in main are all computed relative to the directory containing the package.json; the node resolution strategy, file name completion, folder modules, and so on no longer apply.

So libraries like lodash need to publish their code flattened next to package.json

The module Field

To solve the problem of libraries wanting to ship both cjs and esm versions of their JS code, we can use the module field to specify the entry of the esm version. For example redux, with a simplified package.json:

{
  "name": "redux",
  "version": "1.1.1",
  "main": "lib/redux.js",
  "module": "es/redux.js"
}

The exports Field

The Node.js official documentation on package.json is mostly about the exports field — it can be called the ultimate solution, adapting to all current scenarios.

Main Entry Export

Like the main and module fields, we can use the following syntax to configure how a module is exported when no subpath is written — also called the main entry:

All paths in exports must start with . — you can simply think of . as the module name

{
  "name": "xxx",
  "exports": {
    ".": "./index.js"
  }
}

For the example above, the object can be simplified to a single path:

{
  "name": "xxx",
  "exports": "./index.js"
}

Subpath Exports

When matching rules in the exports field, subpaths must be declared in exports to be usable — otherwise an error is thrown

When declaring subpaths, a subpath must start with ./, and the subpath suffix is strictly distinguished: if ./bar.js is declared, the import must be pkg/bar.js — importing pkg/bar throws an error.

{
  "name": "pkg",
  "exports": {
    // Must be imported as `pkg/bar.js`
    "./bar.js": "./src/bar.js",
    // Must be imported as `pkg/foo`
    "./foo": "./src/bar.js"
  }
}

Wildcard Exports

To adapt to multi-subdirectory scenarios, the exports field supports wildcard exports, which also perform strict string replacement to generate the imported file path. Example:

{
  "name": "lodash",
  "exports": {
    // For any subpath, look up the `.js` file with the corresponding name in the ./lib directory
    // Even importing `lodash/get.js` looks up `./lib/get.js.js`
    // `*` supports arbitrarily deep paths — e.g. lodash/a/b also matches, looking up `./lib/a/b.js`
    "./*": "./lib/*.js"
  }
}

The specific lookup rules are as follows:

  1. Given a module id lodash/add
  2. Replace the . in the left-hand pattern ./* with the module name lodash, giving lodash/*
  3. Pattern-match lodash/* against the module id lodash/add; the value of * is add
  4. Replace the * in the target pattern ./lib/*.js with the value obtained in step 3, giving ./lib/add.js — the path relative to the lodash package
  5. Replace the . in the relative path with the absolute path of the lodash package to get the absolute path of module id lodash/add: /xxx/node_modules/lodash/lib/add.js

Forbidden Exports

Setting a module's target pattern to null forbids a subpath from being imported by other modules:

{
  "name": "xxx",
  "exports": {
    "./forbidden": null
  }
}

Priority of Multiple matches

Given the following package.json definition:

{
  "name": "xxx",
  "exports": {
    "./*": "./*",
    "./a/*": "./a/*.js",
    "./a/b": "./a/b.js",
    "./*.js": "./*.js"
  }
}

When importing the package with import 'pkg/a/b', the ./* rule matches — this is the short-circuit matching strategy

Short-circuit matching: matching proceeds from front to back; once a key pattern matches, matching ends regardless of whether the file for the target pattern can be found

However, the matching may differ between specific module loading libraries — there is currently no standard definition of matching strategy priority

Conditional Exports

When the exports field defines entries / subpaths, different conditions can be defined for matching, e.g.:

{
  "exports": {
    "./foo": {
      // Keys like types, node-addons, node, and import represent conditions
      "types": "./types.d.ts",
      "node-addons": "./c-plus-native.node",
      "node": "./can-be-esm-or-cjs.js",
      "import": "./index-module.mjs",
      "require": "./index-require.cjs",
      "default": "./fallback-to-this-pattern.js"
    }
  }
}
  • In the nodejs + esm case, "import": "./index-module.mjs" is used
  • In the CommonJS case, "require": "./index-require.cjs" is used
  • When none of the conditions are met, "default": "./fallback-to-this-pattern.js" is used

The priority of the conditions in a conditional export depends on the order in which they are declared — the earlier, the higher.

In other words, matching short-circuits from front to back. Therefore, importing the following module with commonjs in node throws an error:

{
  "name": "xxx",
  "exports": {
    ".": {
      "default": null,
      "require": "./dist/hello.js"
    }
  }
}

This means that when using conditional exports, we must write them in priority order — put the more specific conditions first.

Other Important package.json Fields

files

Declares which files or directories the current package exports; only the declared content is published to npm

types / typings

Declares the directory of type definitions (.d.ts) corresponding to the modules exported by the current dependency package, so TS projects can obtain the package's TS types

typesVersions

Solves TS type syntax compatibility issues across versions — different .d.ts type definition files are used for specific TS version ranges; besides versioning, it also supports loading types per submodule.

{
  "name": "pkg",
  "version": "1.1.1",
  "types": "dist/index.d.ts",
  "typesVersions": {
    "<=4.8": {
      // Declares the type declaration file for the `pkg/subpath` module; wildcards are supported
      // If using `node16` / `nodenext`, `ts4.8/*.d.ts` is needed
      "subpath": ["ts4.8/*"]
    },
    // Fallback strategy; may be omitted. By default types are imported from `.`,
    // e.g. `pkg/foo` imports types from the `./pkg/foo.d.ts` file
    "*": {
      // Declares the type declaration file for the `pkg` module; wildcards are supported
      // If using `node16` / `nodenext`, `dist/*.d.ts` is needed
      "*": ["dist/*"]
    }
  }
}

When both types / typings and typeVersions are configured, types / typings takes higher priority — typeVersions is only used when types / typings cannot be resolved

unpkg / jsdelivr / cdn / browser

Provided for cdn vendors — usually an entry to a full JS file containing everything; see the discussion thread on cdn entries in package.json

Taking jsdelivr as an example, the priority is:

  • jsdelivr
  • browser
  • main

How Vite Resolves npm Packages

vite uses esbuild to convert ts files into js files. During conversion, esbuild directly discards ts types without doing type checking, so it does not care how types are resolved and does not need to handle fields like typings.

When both main and module entries exist, build tools — especially ESM-based ones like rollup and vite — prefer the module field.

Vite bundling comes in several flavors:

  • pre bundling: pre-bundling with esbuild
  • esm dev server: the built-in vite:resolve plugin handles module id resolution
  • prod build: production build — essentially rollup + the vite:resolve plugin + the @rollup/plugin-commonjs plugin

By default, vite pre-bundles your third-party dependencies regardless of whether they support esm. The main consideration is that dependencies with an especially large number of modules, like lodash-es, would cause too many http requests if not pre-bundled.

If you don't want pre-bundling, you have to manually add the dependency to the pre-bundling exclude list. Once a dependency is in the exclude list, vite will not perform commonjs -> esm conversion on it — even if its main field points to a commonjs module, vite respects the user's configuration and treats it as an esm module.

Both vite and rollup extend their capabilities through plugin systems: they first determine a module's final file path through a resolve plugin, then use the @rollup/plugin-commonjs plugin to convert it to esm when conversion is needed. If both an esm entry and a generic entry exist, the esm entry is preferred.

The main entry can also serve esm — it is a generic entry. A similar one is the default field in exports.

{
  "type": "module",
  "exports": {
    ".": {
      "import": {
        // Use the source code under src in development, so our source changes hot-reload too
        "development": "./src",
        // In production — i.e. when the app runs vite build — use the bundled, compiled dist
        "default": "./dist/es/index.mjs"
      }
    }
  },
  "publishConfig": {
    // When publishing, we don't need to keep the development condition
    // If kept, users of this library would also resolve to src
    // Not all fields can be overridden in publishConfig — for example npm does not support overriding typesVersion, but pnpm does
    "exports": {
      ".": {
        "import": "./dist/es/index.mjs"
      }
    }
  }
}