Webpack 4.x 原理篇

webpack 的核心定位是面向现代 JavaScript 应用的静态模块打包工具

处理项目时,它会从一个或多个入口出发构建完整依赖图,把项目内所有用到的模块整合,输出一份或多份静态 bundle 文件,供浏览器加载运行。从底层实现角度看,webpack 是一套事件流驱动的系统,整套编译构建流程,依靠各类插件协同完成。

简单 bundle 源码解析

// src/assets/js/a.js
console.log('a')
// src/simple.js
import './assets/js/a.js'
console.log('done')

编写构建配置 webpack.simple.config.js

const Path = require('path')
const Webpack = require('webpack')

const webpackConfig = {
  mode: 'development',
  entry: './src/simple',
  output: {
      path: Path.resolve(__dirname, '../dist'),
      filename: 'simple.js'
  }
}
Webpack(webpackConfig, (err) => {
  if (err) console.error(err)
})

执行命令 node build/webpack.simple.config.js,查看 dist/simple.js 输出产物。

打包结果外层是 IIFE 立即执行函数,内部划分为两大部分:webpack 运行时代码业务模块集合

简化后结构如下:

(function(modules) {
  var installedModules = {}; // 模块缓存
  function __webpack_require__(moduleId) { /** ... **/ }; // 模块载入函数
  __webpack_require__.m = modules;          // 暴露全部模块
  __webpack_require__.c = installedModules; // 模块运行缓存
  __webpack_require__.d = function(exports, name, getter) { /** ... **/ };  // ESM导出getter处理
  __webpack_require__.r = function(exports) { /** ... **/ };      // 标记__esModule模块
  __webpack_require__.t = function(value, mode) { /** ... **/ };  // 生成模块命名空间
  __webpack_require__.n = function(module) { /** ... **/ };       // CommonJS模块default导出兼容

  __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
  __webpack_require__.p = ""; // __webpack_public_path__

  return __webpack_require__(__webpack_require__.s = "./src/simple.js");  // 执行入口模块
})({
  "./src/assets/js/a.js": (function(module, exports) {
    eval("console.log('a')...");
  }),
  "./src/simple.js": (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    eval("__webpack_require__.r...");
  })
});

产物内部各个成员承担的职责:

  • installedModules:缓存已经执行完毕的模块,规避重复执行;
  • __webpack_require__:浏览器环境模拟实现的模块加载函数,依据模块 ID 执行对应模块;
  • __webpack_require__.m:保存打包后的全部业务模块;
  • __webpack_require__.c:模块运行时缓存容器;
  • __webpack_require__.d:为 ES Module 命名导出定义 getter 访问器;
  • __webpack_require__.r:为对象打上 __esModule 标识,区分 ES 模块;
  • __webpack_require__.t:构建模块的伪命名空间对象;
  • __webpack_require__.n:处理 CommonJS 模块与 ES 模块之间 default 导出兼容逻辑;
  • __webpack_require__.o:封装 Object.prototype.hasOwnProperty.call,用于属性存在性判断;
  • __webpack_require__.p:对应配置项 output.publicPath,处理异步 chunk 资源请求路径。

IIFE 的末尾调用入口模块,启动业务逻辑。开发环境下模块源码会被 eval 包裹,便于调试;生产构建会开启压缩混淆。

备注:运行时代码和业务代码互相独立,runtimeChunk 配置就是把这部分运行时代码剥离为独立 JS 文件,以此充分利用浏览器缓存。

Tapable 钩子机制

webpack 的事件驱动能力全部由 Tapable 库实现。两个最核心的类 CompilerCompilation 均继承自 Tapable:

  • Compiler:管控全局完整编译生命周期;
  • Compilation:管控单次编译过程中模块、资源的构建工作。

插件通过在不同生命周期钩子上注册回调函数,以此介入 webpack 整个构建流程。

Tapable 本质是一套发布-订阅模型,相比 Node.js EventEmitter,它细分多种同步、异步、串行、并行、熔断、瀑布类型钩子,适配编译场景的复杂时序需求。

内置钩子

// tapable/lib/index.js
exports.SyncHook = require("./SyncHook");
exports.SyncBailHook = require("./SyncBailHook");
exports.SyncWaterfallHook = require("./SyncWaterfallHook");
exports.SyncLoopHook = require("./SyncLoopHook");

exports.AsyncParallelHook = require("./AsyncParallelHook");
exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
exports.AsyncSeriesHook = require("./AsyncSeriesHook");
exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");

按回调执行逻辑

  • 普通 Hook:各个回调独立运行,互不干扰;
  • BailHook 熔断钩子:任意回调返回非 undefined,直接终止后续回调执行;
  • WaterfallHook 瀑布钩子:上一个回调返回非 undefined,返回值会作为下一个回调的第一个入参;
  • LoopHook 循环钩子:回调返回非 undefined,重新从头执行全部回调,直到回调全部返回 undefined 才向后执行。

按调用模型

  • Sync 同步钩子.tap() 注册,.call() 触发;
  • AsyncSeries 异步串行钩子:回调按注册顺序依次执行,上一个完成后才会执行下一个;支持 taptapAsynctapPromise,使用 .callAsync() 或者 .promise() 触发;
  • AsyncParallel 异步并行钩子:所有回调并发执行,全部回调结束之后才执行最终回调。

备注:注册钩子时,支持 stage(数值越大执行越靠后)、before(指定在某个命名回调之前执行)调整回调执行优先级。

同步钩子

SyncHook

基础钩子,并不关心回调的内部具体实现,只是单纯地执行回调,且回调都是独立的,互不干扰。

const { SyncHook } = require('tapable')

const tHook = new SyncHook(['name', 'greeting'])
tHook.tap('func1', (name, greeting) => {
  console.log(`func1:${name}${greeting}。`)
})
tHook.tap('func2', (name, greeting) => {
  console.log(`func2:${name}${greeting}。`)
  return 'func2'
})
tHook.tap('func3', (name, greeting) => {
  console.log(`func3:${name}${greeting}。`)
})

tHook.call('Lizhao', '你好')
// func1:Lizhao,你好。
// func2:Lizhao,你好。
// func3:Lizhao,你好。

利用 stage 改变执行顺序:

tHook.tap({
  name: 'func2',
  stage: 10
}, (name, greeting) => {
  console.log(`func2:${name}${greeting}。`)
  return 'func2'
})

tHook.call('Lizhao', '你好')
// func1:Lizhao,你好。
// func3:Lizhao,你好。
// func2:Lizhao,你好。即,func2 最后执行

利用 before 改变执行顺序:

tHook.tap({
  name: 'func4',
  before: 'func3'
}, (name, greeting) => {
  console.log(`func4:${name}${greeting}。`)
})
tHook.call('Lizhao', '你好')
// func1:Lizhao,你好。
// func4:Lizhao,你好。
// func3:Lizhao,你好。
// func2:Lizhao,你好。

SyncBailHook

熔断钩子,某个监听返回非 undefined 时后续不执行。

const { SyncBailHook } = require('tapable')

const tHook = new SyncBailHook(['name', 'greeting'])
tHook.tap('func1', (name, greeting) => {
  console.log(`func1:${name}${greeting}。`)
  return ''
})
tHook.tap('func2', (name, greeting) => {
  console.log(`func2:${name}${greeting}。`)
})

tHook.call('Lizhao', '你好')
// func1:Lizhao,你好。

SyncWaterfallHook

瀑布钩子,如果上一个回调的返回非 undefined,那么就将下一个的回调的第一个参数替换为这个值。

const { SyncWaterfallHook } = require('tapable')

const tHook = new SyncWaterfallHook(['name', 'greeting'])
tHook.tap('func1', (name, greeting) => {
  console.log(`func1:${name}${greeting}。`)
  return '李'
})
tHook.tap('func2', (name, greeting) => {
  console.log(`func2:${name}${greeting}。`)
})

tHook.call('Lizhao', '你好')
// func1:Lizhao,你好。
// func2:李,你好。

SyncLoopHook

循环钩子,如果当前未返回 undefined 则一直执行。也就是说,如果回调的返回值不是 undefined 时,会重新从第一个注册的事件回调处执行,直到当前执行的回调返回 undefined,才会执行后面的回调函数。

const { SyncLoopHook } = require('tapable')

const tHook = new SyncLoopHook(['name', 'greeting'])
let counter = 0
tHook.tap('func1', (name, greeting) => {
  console.log(`func1:${name}${greeting}。`)
})
tHook.tap('func2', (name, greeting) => {
  console.log(`func2:${name}${greeting}${counter + 1}`)
  counter ++
  return counter > 3 ? undefined : 'func2'
})
tHook.tap('func3', (name, greeting) => {
  console.log(`func3:${name}${greeting}。`)
})

tHook.call('Lizhao', '你好')
// func1:Lizhao,你好。
// func2:Lizhao,你好。1
// func1:Lizhao,你好。
// func2:Lizhao,你好。2
// func1:Lizhao,你好。
// func2:Lizhao,你好。3
// func1:Lizhao,你好。
// func2:Lizhao,你好。4
// func3:Lizhao,你好。

异步钩子

异步钩子支持三种注册方式:taptapAsync(callback 回调)、tapPromise(返回 Promise 对象)。

AsyncParallelHook 异步并行

const { AsyncParallelHook } = require('tapable')

const tHook = new AsyncParallelHook(['name', 'greeting'])
tHook.tapAsync('func1', (name, greeting, callback) => {
  setTimeout(() => {
    console.log(`func1:${name}${greeting}。`)
    callback()
  }, 2000)
})
tHook.tapAsync('func2', (name, greeting, callback) => {
  setTimeout(() => {
    console.log(`func2:${name}${greeting}。`)
    callback()
  }, 1000)
})
tHook.tapAsync('func3', (name, greeting, callback) => {
  console.log(`func3:${name}${greeting}。`)
  callback()
})

tHook.callAsync('Lizhao', '你好', () => {
  console.log('Done!')
})
// func3:Lizhao,你好。
// func2:Lizhao,你好。
// func1:Lizhao,你好。
// Done!

注意callback(err) 传入错误参数后,最终回调会立即失败;并行场景下已经启动的回调仍可能跑完。

AsyncSeriesHook 异步串行

const { AsyncSeriesHook } = require('tapable')
const tHook = new AsyncSeriesHook(['name', 'greeting'])
tHook.tapPromise('func1', (name, greeting) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`func1:${name}${greeting}。`)
      resolve()
    }, 2000)
  })
})
tHook.tapPromise('func2', (name, greeting) => {
  return new Promise((resolve, reject) => {
    console.log(`func2:${name}${greeting}。`)
    resolve()
  })
})
tHook.promise('Lizhao', '你好').then(() => {
  console.log('Done!')
})
// func1:Lizhao,你好。
// func2:Lizhao,你好。
// Done!

核心流程解析

本节基于 webpack 4.44.1 源码阅读整理,保留源码摘录片段,每个阶段内部附带该阶段专属精简调用栈,便于对照源码调试阅读。

webpack 的核心工作:对图片、CSS、JS 等各类资源做转译、依赖收集、模块拼接,输出浏览器可执行的 bundle。

webpack 的运行流程是一个串行的过程:

  • 合并配置:读取配置文件、命令行参数,与默认配置合并
  • 创建编译器:生成 Compiler 实例,注册插件,调用 run 启动
  • 开始编译:触发钩子,创建 Compilation 对象,准备管理本次构建
  • 处理模块:从入口出发,递归加载依赖,用 Loader 转换代码,解析 AST 找出更多依赖,直到全部处理完
  • 生成资源:将处理好的模块打包成 chunks,生成最终文件列表
  • 写入文件:按配置将文件写入硬盘指定位置

webpack 的运行流程可划分为初始化阶段、构建阶段、生成阶段

初始化阶段

webpack(options, callback)
├─ schema 校验配置
├─ WebpackOptionsDefaulter 合并默认配置
├─ new Compiler() 创建全局编译器实例
├─ 遍历 plugins,逐个执行 plugin.apply(compiler),注册插件钩子
├─ compiler.hooks.environment.call()
├─ compiler.hooks.afterEnvironment.call()
├─ WebpackOptionsApply().process() 注册全部内置插件
└─ 如果传入 callback → compiler.run(),否则返回 compiler 实例
// webpack@4.44.1:/lib/webpack.js
const webpack = (options, callback) => {
    const webpackOptionsValidationErrors = validateSchema(
        webpackOptionsSchema,
        options
    );
    // ...
    let compiler;
    if (Array.isArray(options)) {
        // ...
    } else if (typeof options === "object") {
        options = new WebpackOptionsDefaulter().process(options);

        compiler = new Compiler(options.context);
        compiler.options = options;
        new NodeEnvironmentPlugin({
            infrastructureLogging: options.infrastructureLogging
        }).apply(compiler);
        if (options.plugins && Array.isArray(options.plugins)) {
            for (const plugin of options.plugins) {
                if (typeof plugin === "function") {
                    plugin.call(compiler, compiler);
                } else {
                    plugin.apply(compiler);
                }
            }
        }
        compiler.hooks.environment.call();
        compiler.hooks.afterEnvironment.call();
        compiler.options = new WebpackOptionsApply().process(options, compiler);
    } else {
        throw new Error("Invalid argument: options");
    }
    if (callback) {
        // ...
        compiler.run(callback);
    }
    return compiler;
};

执行逻辑梳理:

  1. 利用 validateSchema 校验传入配置,WebpackOptionsDefaulter 合并默认配置,生成最终生效配置;
  2. 实例化 Compiler 全局编译器,加载 Node 环境相关插件;
  3. 循环遍历 plugins 数组,调用每个插件 apply(compiler),插件向 compiler 的各个钩子注册回调;
  4. 触发 environmentafterEnvironment 钩子;
  5. WebpackOptionsApply().process() 自动注册 webpack 内置插件,如 JavascriptModulesPluginJsonModulesPluginEntryOptionPlugin 等;
  6. 如果调用 webpack() 时传入 callback,自动调用 compiler.run() 启动编译;没有传入 callback,则返回 compiler 实例,交给外部手动调用 run。

构建阶段

compiler.run()
├─ hooks.beforeRun.callAsync()
├─ hooks.run.callAsync()
├─ readRecords() 读取编译缓存记录
└─ compiler.compile()
   ├─ hooks.beforeCompile.callAsync(params)
   ├─ hooks.compile.call(params)
   ├─ newCompilation() 实例化 Compilation
   └─ hooks.make.callAsync(compilation)
      └─ SingleEntryPlugin make 钩子回调
         ├─ compilation.addEntry()
         ├─ _addModuleChain()
         ├─ moduleFactory.create() → NormalModule
         ├─ buildModule() → module.build()
         │  └─ doBuild()
         │     ├─ loader-runner.runLoaders() 执行全部 loader
         │     └─ parser.parse() acorn 生成 AST
         └─ processModuleDependencies() 收集依赖,递归 _addModuleChain
            └─ 全部模块处理完成 → compilation.finish()
// webpack@4.44.1:/lib/Compiler.js
const {
    Tapable,
    SyncHook,
    SyncBailHook,
    AsyncParallelHook,
    AsyncSeriesHook
} = require("tapable");

class Compiler extends Tapable {
    constructor(context) {
        super();
        this.hooks = {
            // 各种钩子 ...
        };
        // ...
    }
    run(callback) {
        // ...
        this.hooks.beforeRun.callAsync(this, err => {
            if (err) return finalCallback(err);
            this.hooks.run.callAsync(this, err => {
                if (err) return finalCallback(err);

                this.readRecords(err => {
                    if (err) return finalCallback(err);

                    this.compile(onCompiled);
                });
            });
        });
    }
    // ...
    compile(callback) {
        const params = this.newCompilationParams();
        this.hooks.beforeCompile.callAsync(params, err => {
            if (err) return callback(err);

            this.hooks.compile.call(params);

            const compilation = this.newCompilation(params);

            this.hooks.make.callAsync(compilation, err => {
                if (err) return callback(err);

                compilation.finish(err => {
                    if (err) return callback(err);

                    compilation.seal(err => {
                        if (err) return callback(err);

                        this.hooks.afterCompile.callAsync(compilation, err => {
                            if (err) return callback(err);

                            return callback(null, compilation);
                        });
                    });
                });
            });
        });
    }
}

构建阶段Compiler 调用 Compilation 实例的生命周期和方法,核心流程如下:

  • compiler.run 依次触发 beforeRunrun 钩子,随后调用 compiler.compile
  • compiler.compile 触发 beforeCompilecompile 钩子,然后实例化 Compilation
  • Compilation 实例创建后,执行 compilermake 钩子,从 Entry 配置开始读取并编译文件
  • 编译完成后,调用 compilation.finishseal 方法,进入资源生成阶段

关键点make 钩子是构建阶段的核心,其回调由各类插件注册实现。

webpack 源码中搜索 hooks.make.tap,可找到大量注册该钩子的插件,例如:SingleEntryPluginMultiEntryPluginDynamicEntryPluginDllEntryPluginPrefetchPlugin 等。

因此,hooks.make.callAsynccompilation.finish 之间的编译过程,正是由这些插件的回调逻辑串联执行的。

// webpack@4.44.1:/lib/SingleEntryPlugin.js
class SingleEntryPlugin {
    apply(compiler) {
        compiler.hooks.compilation.tap(
            "SingleEntryPlugin",
            (compilation, { normalModuleFactory }) => {
                compilation.dependencyFactories.set(
                    SingleEntryDependency,
                    normalModuleFactory
                );
            }
        );

        compiler.hooks.make.tapAsync(
            "SingleEntryPlugin",
            (compilation, callback) => {
                const { entry, name, context } = this;

                const dep = SingleEntryPlugin.createDependency(entry, name);
                compilation.addEntry(context, dep, name, callback);
            }
        );
    }
}

构建阶段的核心是 Compilation 对象,主要分为三个环节:

  • 初始化模块:实例化 Compilation 时,同时创建 NormalModuleFactory(创建普通模块)和 ContextModuleFactory(处理 require.context 一类上下文模块)
  • 构建模块
    • compilation.addEntry 是构建的真正起点,负责将入口模块添加到依赖列表
    • 调用链:addEntry_addModuleChainbuildModule,获取模块路径、文件类型及所需 Loader 等基本信息
    • 核心在 compilation.buildModule 中调用 module.build(具体实现在 webpack/lib/NormalModule.js
    • NormalModule.builddoBuild
      1. 使用 loader-runnerrunLoaders 转译模块内容,将各类资源(如图片、CSS 等)转换为标准 JavaScript 文本
      2. 在回调中调用 this.parser.parse,通过 acorn 将 JavaScript 文本解析为 AST
  • 模块依赖处理
    • 只递归处理会生成新模块的依赖(在 dependencyFactories 中注册了工厂的依赖,如 importrequire
    • 导出、占位替换等依赖不会创建新模块,不在此阶段递归
    • 处理逻辑位于 Compilation.buildModuleprocessModuleDependencies

所有模块及依赖完成 Loader 转换后,在 make 钩子回调中调用 compilation.finishcompilation.seal,进入资源生成阶段。

// webpack@4.44.1:/lib/Compilation.js
class Compilation extends Tapable {
    _addModuleChain(context, dependency, onModule, callback) {
        // ...
        this.semaphore.acquire(() => {
            moduleFactory.create(
                // ...
                (err, module) => {
                    // ...
                    const afterBuild = () => {
                        if (addModuleResult.dependencies) {
                            this.processModuleDependencies(module, err => {
                                if (err) return callback(err);
                                callback(null, module);
                            });
                        } else {
                            return callback(null, module);
                        }
                    };
                    // ...
                    if (addModuleResult.build) {
                        this.buildModule(module, false, null, null, err => {
                            // ...
              afterBuild();
                        });
                    }
          // ...
                }
            );
        });
    }
    addEntry(context, entry, name, callback) {
        // ...
        this._addModuleChain(/** ... **/);
    }

    buildModule(module, optional, origin, dependencies, thisCallback) {
        // ...
        module.build(
            this.options,
            this,
            this.resolverFactory.get("normal", module.resolveOptions),
            this.inputFileSystem,
            error => {
        // ...
      }
        );
    }
}
// webpack@4.44.1:/lib/NormalModule.js
class NormalModule extends Module {
    doBuild(options, compilation, resolver, fs, callback) {
        // ...
        runLoaders(
            {
                resource: this.resource,
                loaders: this.loaders,
                context: loaderContext,
                readResource: fs.readFile.bind(fs)
            },
            (err, result) => {}
        );
    }

    build(options, compilation, resolver, fs, callback) {
        // ...
        return this.doBuild(options, compilation, resolver, fs, err => {
            // ...
            try {
                const result = this.parser.parse(
                    this._ast || this._source.source(),
                    {
                        current: this,
                        module: this,
                        compilation: compilation,
                        options: options
                    },
                    (err, result) => {
                        if (err) {
                            handleParseError(err);
                        } else {
                            handleParseResult(result);
                        }
                    }
                );
            }
        });
    }
}

NormalModule.doBuild() 调用 loader-runner.runLoaders() 执行全部 loader,将图片、CSS 等资源转换为 JS 源码;之后调用 parser.parse(),使用 acorn 生成 AST 语法树。

processModuleDependencies() 遍历 AST,识别 importrequire,递归处理全部依赖模块。

当所有模块处理完毕,调用 compilation.finish(),紧接着调用 compilation.seal() 流转进入生成阶段。

说明
Compiler 全局编译器,一个进程一般仅有 1 个实例;管控编译生命周期、文件监听、启动编译
Compilation 单次编译实例;watch 模式文件每改动就重新生成;保存 modules、chunks、assets、编译报错
NormalModule 代表每一个被 webpack 处理的源文件
NormalModuleFactory 模块工厂,用来实例化 NormalModule
loader-runner 独立库,专门负责执行 loader,和 webpack 解耦

生成阶段

生成阶段围绕 chunks 展开,与构建阶段围绕 module 形成对应:

  • 构建完成后,调用 compilation.seal 方法,开始生成最终资源
  • seal 方法的核心工作是将 module 转化为 chunks,过程中包含大量优化处理:
    • 创建资源 hash
    • 执行 tree shaking 删除无用代码
    • 生成最终代码内容
  • 生成的资源保存在 compilation.assetscompilation.chunks

最后触发 compiler.emit 钩子,根据 output.path 配置,将资源写入磁盘指定位置。

compilation.seal()
  ├─ hooks.optimizeTree.callAsync(chunks, modules)
  ├─ splitChunks、tree-shaking、hash 计算等优化处理
  └─ createChunkAssets() 填充 compilation.assets
hooks.afterCompile.callAsync(compilation)
compiler.emitAssets()
  ├─ hooks.emit.callAsync(compilation)
  ├─ fs 将资源写入磁盘
  └─ hooks.afterEmit.callAsync(compilation) → 一次编译完成
// webpack@4.44.1:/lib/Compilation.js
class Compilation extends Tapable {
    // ...
    seal(callback) {
        // ...
        this.hooks.optimizeTree.callAsync(this.chunks, this.modules, err => {
            // ...
            if (this.hooks.shouldGenerateChunkAssets.call() !== false) {
                this.hooks.beforeChunkAssets.call();
                this.createChunkAssets();
            }
            // ...
        });
    }

    createChunkAssets() {
    for (let i = 0; i < this.chunks.length; i++) {
      // ...
    }
  }
}
// webpack@4.44.1:/lib/Compiler.js
class Compiler extends Tapable {
  // ...
  emitAssets(compilation, callback) {
    // ...
    this.hooks.emit.callAsync(compilation, err => {
      // ...
    });
  }
}

备注compilation.assets 保存全部输出资源对象,每个 asset 提供 source() 读取源码、size() 获取字节数,自定义插件修改输出文件本质就是操作这个对象。

编写一个 loader

loader 的本质是导出函数的 JS 模块,由 loader-runner 调用;接收文件源码,返回转换完成后的源码。

loader 执行顺序规则:use: ['a-loader', 'b-loader', 'c-loader']pitch 阶段从左向右执行;normal 主体阶段从右向左执行,对应 compose 函数的思想

loader 函数签名

/**
 * @param {string|Buffer} content 源文件内容
 * @param {object} [map] sourceMap 对象
 * @param {any} [meta] 元信息
 */
function webpackLoader(content, map, meta) {
  // loader 业务逻辑
}
module.exports = webpackLoader

同步 loader

无论是 return 还是 this.callback 都可以同步地返回转换后的 content 内容。

// return 直接返回
module.exports = function(content) {
  return content.replace('hello', 'hi')
}

// this.callback 支持多返回值 content、sourcemap、meta
module.exports = function(content, map, meta) {
  const res = content.replace('hello', 'hi')
  this.callback(null, res, map, meta)
  return undefined
}

异步 loader

IO、网络耗时逻辑使用异步模式,调用 this.async() 拿到 callback。

module.exports = function(content) {
  const callback = this.async()
  setTimeout(() => {
    const res = content.toUpperCase()
    callback(null, res)
  }, 100)
}

Raw Loader 二进制 Buffer 模式

默认传入 content 是 UTF-8 字符串;设置 module.exports.raw = true,loader 拿到原始二进制 Buffer,适合图片、字体等二进制资源。

module.exports = function(buffer) {
  return buffer
}
module.exports.raw = true

Pitching loader

loader 分为两个阶段:

  • pitch 阶段:从左向右执行各个 loader 的 .pitch()
  • normal 阶段:从右向左执行 loader 主体函数。
use: [
  'a-loader',
  'b-loader',
  'c-loader'
]

执行顺序:

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution
// b-loader.js
module.exports = function(content) {
  console.log(this.data.msg)
  return content
}
module.exports.pitch = function(remainingRequest, precedingRequest, data) {
  data.msg = '共享数据'
  // return 字符串会截断后面 loader 执行
}
  • pitch 的第三个参数 data 对象,可以在 pitch 与 normal 之间共享数据;normal 阶段通过 this.data 访问;
  • 如果任意 pitch 返回非 undefined,直接跳过后续所有 loader 的 pitch 和 normal,回头执行前面 loader 的 normal。

常用上下文 API

  • this.callback(err, content, sourceMap, meta):同步、异步返回处理结果;
  • this.async():标记 loader 为异步,获取 callback;
  • this.data:pitch、normal 之间共享对象;
  • this.cacheable(boolean):设置是否开启缓存,默认 true;
  • this.resourcePath:当前正在处理文件的绝对路径;
  • this.getOptions():读取 loader 配置项(Webpack 5 新增;Webpack 4 使用 loader-utils.getOptions(this));
  • this.emitFile():输出文件到产物目录;
  • this.addDependency():添加文件依赖,文件变更触发重新编译。

简易自定义示例

// ./src/my-loader.js
const LoaderUtils = require("loader-utils");
module.exports = function(source) {
  console.log(source)
  console.log(this)
  const options = LoaderUtils.getOptions(this)
  console.log(options)
  if (this.resourcePath.match('/src/simple.js')) {
    source += `\nconsole.log("This is ${options.name}'s ${options.value}.")`
  }
  return source
};

webpack 配置使用:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: require.resolve('./src/my-loader.js'),
            options: {
              name: "Lizhao",
              value: "MyLoader"
            }
          }
        ]
      }
    ]
  }
}

备注:借助 loader-runner,可以脱离 webpack 环境独立调试 loader。

编写 Plugin(插件)

loader 专注单个模块源码转换;plugin 可以介入 webpack 全生命周期,做更广范围工作:修改输出文件、HTML 生成、代码优化、注入环境变量、拷贝静态资源等。

插件标准结构:

  • JS 类,构造函数接收 options 配置;
  • 实现 apply(compiler) 方法;webpack 实例化插件之后自动调用 plugin.apply(compiler)
  • apply 内部,向 compiler、compilation 的 hooks 注册回调;
  • 在回调中操作 compilation、assets、modules、chunks,实现业务逻辑。
// src/my-plugin.js
const { RawSource } = require('webpack-sources')
class BannerPlugin {
  constructor(options) {
    this.options = options
  }
  apply(compiler) {
    // emit 钩子:磁盘写入文件之前触发
    compiler.hooks.emit.tap('BannerPlugin', (compilation) => {
      for(const filename in compilation.assets) {
        const asset = compilation.assets[filename]
        const source = `/* author: ${this.options.author} */\n` + asset.source()
        compilation.assets[filename] = new RawSource(source)
      }
    })
  }
}
module.exports = BannerPlugin

配置文件引入插件:

const BannerPlugin = require('./src/my-plugin')
module.exports = {
  plugins: [
    new BannerPlugin({ author: 'demo' })
  ]
}

高频钩子参考:

  • compiler.hooks.emit:写入磁盘前,修改 compilation.assets
  • compiler.hooks.afterEmit:文件写入磁盘完成;
  • compilation.hooks.optimizeChunkAssets:chunk 资源优化阶段。

部分第三方插件对外暴露自身钩子,支持其他插件扩展,典型代表 html-webpack-plugin

极简模拟 webpack(原理演示)

仅用于原理学习,不具备生产环境能力。

整体流程:读取入口文件 → AST 解析 → 收集全部依赖 → 递归处理所有模块 → 拼接简易 runtime + 模块代码,输出 bundle 文件。

安装依赖(与下方 Babel 6 示例代码对应):npm i babel-core babel-preset-env babel-traverse babylon

// parser.js
const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const { transformFromAst } = require('babel-core');
module.exports = {
  getAST: (path) => {
    const source = fs.readFileSync(path, 'utf-8');
    return babylon.parse(source, {
      sourceType: 'module'
    });
  },
  getDependencies: (ast) => {
    const dependencies = [];
    traverse(ast, {
      ImportDeclaration: ({ node }) => {
        dependencies.push(node.source.value);
      }
    });
    return dependencies;
  },
  getCodeFromAst: (ast) => {
    const { code } = transformFromAst(ast, null, {
      presets: ['env']
    });
    return code;
  }
}
// Compiler.js
const fs = require('fs');
const path = require('path');
const { getAST, getDependencies, getCodeFromAst } = require('./parser');
module.exports = class Compiler {
  constructor(options) {
    this.entry = options.entry;
    this.output = options.output;
    this.modules = [];
  }

  run() {
    const entryModule = this.buildModule(this.entry);
    this.modules.push(entryModule);
    this.modules.map((_module) => {
      _module.dependencies.map((dependency) => {
        this.modules.push(this.buildModule(dependency));
      });
    });
    this.emitFiles();
  }

  buildModule(filename) {
    const ast = getAST(path.join(process.cwd(), 'src', filename));
    return {
      filename,
      dependencies: getDependencies(ast),
      transformCode: getCodeFromAst(ast)
    };
  }

  emitFiles() {
    const outputPath = path.join(this.output.path, this.output.filename);
    let modules = '';
    this.modules.map((_module) => {
      modules += `
        '${_module.filename}': function (require, module, exports) { ${_module.transformCode} },
      `
    });
    const bundle = `
      (function(modules) {
        function require(fileName) {
          const fn = modules[fileName];
          const module = { exports : {} };
          fn(require, module, module.exports);
          return module.exports;
        }
        require('${this.entry}');
        console.log('${new Date()}');
        console.log('This is myWebpack!');
      })({${modules}})`;
    fs.writeFileSync(outputPath, bundle, 'utf-8');
  }
};
// index.js
const Compiler = require('./lib/Compiler.js');
module.exports = function webpack (options) {
  return new Compiler(options)
}
const Path = require('path')
const Webpack = require('../src/myWebpack/index.js')

const webpackConfig = {
  mode: 'development',
  entry: './simple.js',
  output: {
    path: Path.resolve(__dirname, '../dist'),
    filename: 'simple.js'
  }
}
const compiler = Webpack(webpackConfig)
compiler.run()

Webpack 5 新特性

长期缓存

  • 全新 ID 分配算法,生成简短稳定数字 ID,兼顾包体积与缓存命中率;
  • [contenthash] 使用文件真实内容哈希;仅注释、变量名改动,压缩后无实质变化时 hash 保持不变。

持久化缓存

Webpack 4 需要借助 cache-loaderbabel-loadercacheDirectory 做磁盘缓存;

Webpack 5 原生支持持久化缓存,默认内存缓存,也可配置写入磁盘;开启磁盘缓存时默认路径为 node_modules/.cache/webpack,自带缓存淘汰策略。

可配置输出运行时代码 ES 语法

Webpack 4 运行时代码只能输出 ES5;Webpack 5 通过 output.environment,控制 runtime 可以使用箭头函数、const、for-of 等 ES6+ 语法。

module.exports = {
  output: {
    environment: {
      arrowFunction: true,
      bigIntLiteral: false,
      const: true,
      destructuring: true,
      dynamicImport: false,
      forOf: true,
      module: false,
      optionalChaining: true,
      templateLiteral: true,
    },
  },
};

内置资源模块

内置资源模块类型,不再依赖 file-loaderurl-loaderraw-loader,直接配置模块类型处理图片、字体等静态资源。

移除 Node.js 核心模块自动 polyfill

Webpack 4 会自动注入 Node.js 核心模块 polyfill,造成包体积臃肿;Webpack 5 不再自动 polyfill,如果业务确实需要,开发者手动引入对应 polyfill 包。

增强 Tree-shaking

新增 optimization.innerGraph,生产模式默认开启;粒度细化到模块内部变量,可以删除没有被使用的导出变量。

// inner.js
export const a = 1;
export const b = 2;

// module.js
export * as inner from './inner';

// user.js
import * as module from './module';
console.log(module.inner.a);

生产打包,变量 b 会被 tree-shaking 删除。

模块联邦 Module Federation

支持多份 webpack 构建产物运行时互相远程加载模块,实现微前端组件共享,多个应用运行时共享依赖。

消费端 app1:

// app1
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "app1",
      remotes: {
        app2: "app2@http://localhost:3002/remoteEntry.js",
      }
    })
  ],
};

提供方 app2:

// app2
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "app2",
      library: {
        type: "var",
        name: "app2"
      },
      filename: "remoteEntry.js",
      exposes: {
        "./Button": "./src/Button",
      }
    })
  ]
};

常见原理问题

Webpack 4 编译速度提升原因

  • V8 语法层面优化:for-of 替代 forEachMapSet 替代普通对象;
  • 默认使用更快 md4 哈希算法;
  • AST 在 loader 链路尽量复用,减少重复解析;
  • 大量逻辑替换字符串处理,降低正则表达式开销;
  • 内部数据结构优化,减少对象实例创建开销。

模块热替换 HMR 原理

HMR 即模块热替换,修改模块无需整页刷新,保留页面运行时状态。

主要组件:

  • webpack-dev-server:开发服务器,提供静态资源 + WebSocket 长连接;
  • webpack-dev-middleware:使用内存文件系统(Webpack 4 配套版本常见为 memory-fs),产物不写入硬盘;
  • HotModuleReplacementPlugin:向浏览器注入 HMR runtime。

工作流程:

  1. 文件变更触发 webpack 重新编译,生成更新模块 chunk;
  2. WDS 通过 WebSocket 向浏览器推送更新 hash;
  3. 浏览器 HMR runtime 发起 HTTP 请求拉取更新模块;
  4. runtime 更新内存模块缓存,执行模块替换逻辑;
  5. 如果模块没有编写 accept 热更新逻辑,降级整页刷新。

备注:webpack 只提供底层模块替换能力;vue-loaderreact-hot-loader 在之上封装组件级热更新。

Loader 为什么从右向左执行

配置 use: ['a-loader', 'b-loader', 'c-loader']

  • pitch 阶段:从左向右
  • normal 主体阶段:从右向左

底层实现参考 loader-runner 内部函数 iteratePitchingLoadersiterateNormalLoaders

本质是函数式编程compose组合思想:

// compose 从右向左执行函数,前一个输出作为后一个入参
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
// pipe 从左向右执行
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x)

bundle、chunk、module 概念区分

  • module:被 webpack 处理的每一个源文件,JS、CSS、图片全部属于 module;
  • chunk:内存中的模块集合;由 entry、动态 import()、splitChunks 规则生成;
  • bundle:chunk 输出到磁盘的真实物理文件;一个 chunk 可以输出一个或者多个 bundle。

Compiler 和 Compilation 的区别

对象 Compiler Compilation
实例数量 进程全局通常只有 1 个实例 每次编译生成全新实例;watch 模式文件改动就重新生成
生命周期 掌控全局全部生命周期,监听文件、触发编译 掌控单次编译:modules、chunks、assets、编译报错
使用场景 注册全局插件,读取原始配置 操作模块、修改输出产物、获取编译错误

参考资料

webpack 中文文档

webpack 中文文档 - 编写一个 loader

webpack 中文文档 - 编写一个插件

webpack 中文文档 - api

webpack 5 官方发布说明

© lizhao all right reserved,powered by Gitbook文件修订时间: 2026-08-25 21:10:35

results matching ""

    No results matching ""