How to Build a Module Bundler: Complete Guide to 8 Core Techniques with Code Examples
Source: Dev.to
Module Resolution
The resolver starts at the entry point (e.g., index.js) and determines the exact file location for each import.
For relative paths it joins the request with the directory of the importing file; for package names it walks up the directory tree looking for a node_modules folder, reads the package’s package.json to find the main entry, and tries common extensions.
// A simplified look at the resolution logic
async resolve(request, fromDir) {
// Is it a direct path?
if (request.startsWith('.')) {
let candidate = path.resolve(fromDir, request);
// Does it have an extension? If not, add one.
if (!path.extname(candidate)) {
candidate = await this.tryWithExtensions(candidate);
}
return candidate;
}
// It's a package name, look in node_modules
let dir = fromDir;
while (dir !== path.parse(dir).root) {
const pkgPath = path.join(dir, 'node_modules', request);
if (await this.exists(pkgPath)) {
// Found the package folder, now find its main file
return this.resolvePackageMain(pkgPath);
}
// Go up one directory and check again
dir = path.dirname(dir);
}
throw new Error(`Module not found: ${request}`);
}
Building the Dependency Graph
To understand how modules depend on each other we parse each file into an Abstract Syntax Tree (AST). The AST lets us reliably locate import and export statements.
const parser = require('@babel/parser');
const code = `import { formatDate } from './dateUtils.js';`;
const ast = parser.parse(code, { sourceType: 'module' });
console.log(ast.program.body[0].type); // 'ImportDeclaration'
console.log(ast.program.body[0].source.value); // './dateUtils.js'
We then traverse the AST and collect dependencies:
const traverse = require('@babel/traverse').default;
const dependencies = [];
traverse(ast, {
ImportDeclaration(path) {
// Record where this import is pointing to
dependencies.push(path.node.source.value);
},
ExportNamedDeclaration(path) {
// Also check for re-exports: export { x } from './module'
if (path.node.source) {
dependencies.push(path.node.source.value);
}
}
});
// Now 'dependencies' contains ['./dateUtils.js']
The result is a map of every file and the modules it requires, forming the dependency graph.
Transforming Imports and Exports
Bundling requires converting ES module syntax into a format the bundle runtime can understand. Imports become calls to a custom __require__ function, and exports are attached to module.exports.
Transforming an Import
const t = require('@babel/types');
// Suppose we know the './stringLib' module has ID = 2
const moduleId = 2;
// Create the call to our runtime function: __require__(2)
const requireCall = t.callExpression(
t.identifier('__require__'),
[t.numericLiteral(moduleId)]
);
// Create the destructuring: const { capitalize } = __require__(2);
const newDeclaration = t.variableDeclaration('const', [
t.variableDeclarator(
t.objectPattern([
t.objectProperty(
t.identifier('capitalize'),
t.identifier('capitalize'),
false,
true // shorthand
)
]),
requireCall
)
]);
// 'path' is the AST node for the import statement
path.replaceWith(newDeclaration);
Transforming an Export
Exports are rewritten to assign values to module.exports:
// Example: export const version = '1.0';
module.exports.version = '1.0';
Wrapping Modules
Each transformed module is wrapped in a function to give it a private scope and to expose module, exports, and __require__ to the code inside.
function (module, exports, __require__) {
// The transformed module code lives here
const { capitalize } = __require__(2);
module.exports.greet = (name) => capitalize(name);
}
Runtime Loader
The bundle ends with a small runtime that stores all module wrappers in an array and provides a __require__ function to load them on demand. It also caches executed modules to handle circular dependencies.
// The final bundle output starts with this runtime
(function (modules) {
// A cache for modules that have already been executed
const moduleCache = [];
// The require function our transformed code will call
function __require__(moduleId) {
// Return cached module if it exists
if (moduleCache[moduleId]) {
return moduleCache[moduleId].exports;
}
// Create a new module object for this id
const module = { exports: {} };
// Store it in cache immediately to handle circular dependencies
moduleCache[moduleId] = module;
// Get the module's wrapper function and execute it
// It receives the module object, its exports, and the __require__ function
modules[moduleId].call(module.exports, module, module.exports, __require__);
// Return the now-populated exports
return module.exports;
}
// Start the application by requiring the entry module (id 0)
__require__(0);
})([
// Our modules registry array starts here
// Each element is a wrapped module function
function (module, exports, __require__) {
// Transformed code for module ID 0 (entry point)
const utils = __require__(1);
console.log(utils);
},
// ...more wrapped modules...
]);