Deep Dive into Webpack: Core Compilation Process, Plugins, Loaders, and Asset Emission
This article provides a comprehensive walkthrough of Webpack’s internal architecture, detailing the core JavaScript bundling workflow, the roles of Compiler and Compilation objects, the lifecycle of plugins and loaders, the parsing of modules into an AST, chunk creation, and the final emission of assets to the output directory.
Even though many modern build tools exist, Webpack remains indispensable in the frontend toolchain due to its flexibility and rich ecosystem. However, its growing feature set and codebase make learning difficult, prompting a need to understand its underlying principles.
The article splits the discussion into three parts: the core JS bundling flow, the purpose and mechanics of plugins, and the purpose and mechanics of loaders.
Basic Concepts
Webpack is a static module bundler that starts from an entry point, builds a dependency graph , and transforms various file types (JS, CSS, images, etc.) into modules.
Entry : the starting file that defines the dependency graph.
Module : any file treated as a module; Webpack recursively builds a network of modules.
Chunk : a collection of modules that are emitted together, enabling code splitting and lazy loading.
Loader : transforms non‑JavaScript resources into modules.
Plugin : taps into compiler hooks to extend the build process.
Core Compilation Process
The entry point of Webpack is ./lib/webpack.js . The main function creates a compiler via createCompiler and returns it:
const webpack = (options, callback) => {
const create = () => {
let compiler;
let watch = false;
// ...handle array configs, etc.
return { compiler, watch, watchOptions };
};
const { compiler, watch, watchOptions } = create();
if (watch) {
compiler.watch(watchOptions, callback);
} else if (callback) {
compiler.run(callback);
}
return compiler;
};The compiler.run() method initiates the build, invoking hooks such as beforeRun , run , reading records, and finally calling this.compile(onCompiled) . The compilation lifecycle triggers hooks in this order: beforeCompile → compile → make → finishMake → afterCompile .
Compiler and Compilation Objects
Compiler holds global configuration and persists across builds, while Compilation represents a single build instance, tracking modules, assets, and dependency graphs.
Creating the Compiler
const createCompiler = rawOptions => {
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
const compiler = new Compiler(options.context, options);
new NodeEnvironmentPlugin({ infrastructureLogging: options.infrastructureLogging }).apply(compiler);
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") plugin.call(compiler, compiler);
else if (plugin) plugin.apply(compiler);
}
}
applyWebpackOptionsDefaults(options);
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
new WebpackOptionsApply().process(options, compiler);
compiler.hooks.initialize.call();
return compiler;
};The WebpackOptionsApply().process() step registers built‑in plugins and prepares the compiler for the upcoming compilation.
Compilation Phases
During compiler.run() , after hook preparation, the compile method creates a new Compilation instance and triggers the make hook. The make hook ultimately invokes EntryPlugin , which adds entries via compilation.addEntry and starts building the module graph.
compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
compilation.addEntry(context, dep, options, err => {
callback(err);
});
});Adding an entry leads to addModuleTree , which creates modules using registered factories, registers them in the module graph, and recursively resolves dependencies.
Module Creation and Build
addModuleTree({ context, dependency, contextInfo }, callback) {
const Dep = dependency.constructor;
const moduleFactory = this.dependencyFactories.get(Dep);
this.handleModuleCreation({
factory: moduleFactory,
dependencies: [dependency],
originModule: null,
contextInfo,
context
}, (err, result) => {});
}The actual build occurs in module.build() , which for normal JavaScript modules delegates to NormalModule._doBuild . This method runs loaders via loader-runner , parses the resulting source with acorn to produce an AST, and records module dependencies.
module.build(options, compilation, resolver, fs, err => {
this.parser.parse(this._ast || source, {
source,
current: this,
module: this,
compilation,
options
});
});Loader Execution
Loaders are processed by loader-runner.runLoaders , which iterates through each loader’s pitch (if present) and then the normal loader function, passing the transformed resource along the chain.
Chunk Creation and Sealing
After modules are built, compilation.seal() creates a ChunkGraph , generates a chunk for each entry point, associates modules with chunks, and builds the final chunk graph.
seal(callback) {
this.chunkGraph = new ChunkGraph();
for (const [name, { dependencies, includeDependencies, options }] of this.entries) {
const chunk = this.addChunk(name);
const entrypoint = new Entrypoint(options);
entrypoint.setRuntimeChunk(chunk);
entrypoint.setEntrypointChunk(chunk);
this.namedChunkGroups.set(name, entrypoint);
this.entrypoints.set(name, entrypoint);
// connect modules to chunk
for (const dep of [...this.globalEntry.dependencies, ...dependencies]) {
const module = this.moduleGraph.getModule(dep);
if (module) {
this.chunkGraph.connectChunkAndEntryModule(chunk, module, entrypoint);
}
}
}
buildChunkGraph(this, chunkGraphInit);
this.hooks.beforeChunkAssets.call();
this.createChunkAssets(err => { callback(); });
}Asset Emission
Finally, compiler.emitAssets writes each generated asset to the output directory defined by output.path . It calls the emit hook, ensures the output folder exists, and writes files using the underlying file system. emitAssets(compilation, callback) { const outputPath = compilation.getPath(this.outputPath, {}); const emitFiles = () => { const assets = compilation.getAssets(); for (const { name: file, source } of assets) { const targetPath = join(this.outputFileSystem, outputPath, file); const content = typeof source.buffer === "function" ? source.buffer() : Buffer.from(source.source(), "utf8"); this.outputFileSystem.writeFile(targetPath, content, err => { if (err) return callback(err); compilation.emittedAssets.add(file); }); } }; this.hooks.emit.callAsync(compilation, err => { if (err) return callback(err); mkdirp(this.outputFileSystem, outputPath, emitFiles); }); } The article concludes with common questions (Asset vs. Bundle, writing custom plugins, comparing build tools, and tips for reading source code) and encourages further exploration of Webpack’s internals.
ByteFE
Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.