Part 7 · 2 chapters · ~18 min
M7: Bundler
From an entry file to two chunks: building the module graph with Node-style resolution and TypeScript stripping, marking used exports and removing the rest, splitting at dynamic import() without duplicating shared modules, and generating a runtime registry that the test executes.
14
Graph, tree shaking and code splitting
many modules in, a few files out
- Graph: static edges, plus dynamic edges as chunk boundaries.
- Resolution probes files and walks node_modules, preferring ESM.
- Tree shaking removes unused exports, which works only because ES module imports are static.
- Chunking: the entry's closure becomes main, and each
import()starts a new chunk. - Codegen: a registry with
__requireand__load. - Output is checked by running it.
where this connects
The Architecture course part 1 is the production view: Vite, Rollup, esbuild and webpack, content hashes, budgets and analysers. The Terms course's build layer defines each word. Scope hoisting is what this repo leaves out. Rollup and esbuild concatenate modules into one scope and rename collisions, which produces smaller and faster output than a registry of functions.
TINY-BUNDLER: GRAPH, SHAKE, SPLIT, EMIT
a small app followed from its entry file to two output chunks with the dead code gone
swipe the figure sideways, or tap expand for full screen
1/6
graph
The graph: main.js imports money.js, greet.ts and fee.js; money.js imports the package tiny-money; main.js has a dynamic import() of settings.js, which imports money.js and chart.js. Static edges are solid; the dynamic edge is a chunk boundary.
15
Building it: tiny-bundler
Repo: repos/bundler. Scanner, resolver, bundler and CLI, plus an example app with a real package in node_modules.
code
// src/scan.ts: mask strings and comments first, so regexes never match inside them
export function mask(src: string): string {
return src.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`/g,
m => (m[0] === '/' ? m.replace(/[^\n]/g, ' ') : m[0] + m.slice(1, -1).replace(/[^\n]/g, ' ') + m[m.length - 1]));
}
// offsets are preserved, so positions found in the masked text index straight into the originalcode
// src/bundle.ts: the chunking rule
const main = closure(entry, new Set()); // static closure of the entry
for (const m of mods.values()) for (const p of m.dynamic.values()) {
if (chunkOf.has(p) || main.has(p)) continue;
const file = `chunk-${++n}-${basename(p).replace(/\.\w+$/, '')}.js`;
chunkOf.set(p, file);
chunks.push({ file, paths: closure(p, main) }); // what it needs, minus what main has
}code
$ npm run demo dist/main.js 1.7 KB [./main.js, ./fee.js, ./greet.ts, ./money.js, ./node_modules/tiny-money/index.js] dist/chunk-1-settings.js 0.4 KB [./settings.js, ./chart.js] tree-shaken: ./money.js#formatDollars, ./money.js#CURRENCIES, ./node_modules/tiny-money/index.js#toMinor
Run it. In
repos/bundler: npm test then npm run demo, and read dist/main.js. Add formatDollars to the import in main.js and run it again. It reappears in the output, which is tree shaking in reverse.exercises
1. Scope hoisting for acyclic graphs: concatenate in dependency order and rename collisions. 2. Content hashes in chunk names, and a manifest for
modulepreload. 3. The sideEffects field: drop whole modules from side-effect-free packages when none of their exports are used. 4. CSS imports extracted to a stylesheet per chunk.