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 库实现。两个最核心的类 Compiler、Compilation 均继承自 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 异步串行钩子:回调按注册顺序依次执行,上一个完成后才会执行下一个;支持
tap、tapAsync、tapPromise,使用.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,你好。
异步钩子
异步钩子支持三种注册方式:tap、tapAsync(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;
};
执行逻辑梳理:
- 利用
validateSchema校验传入配置,WebpackOptionsDefaulter合并默认配置,生成最终生效配置; - 实例化
Compiler全局编译器,加载 Node 环境相关插件; - 循环遍历
plugins数组,调用每个插件apply(compiler),插件向 compiler 的各个钩子注册回调; - 触发
environment、afterEnvironment钩子; WebpackOptionsApply().process()自动注册 webpack 内置插件,如JavascriptModulesPlugin、JsonModulesPlugin、EntryOptionPlugin等;- 如果调用
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依次触发beforeRun、run钩子,随后调用compiler.compilecompiler.compile触发beforeCompile、compile钩子,然后实例化CompilationCompilation实例创建后,执行compiler的make钩子,从 Entry 配置开始读取并编译文件- 编译完成后,调用
compilation.finish和seal方法,进入资源生成阶段
关键点:make 钩子是构建阶段的核心,其回调由各类插件注册实现。
在 webpack 源码中搜索 hooks.make.tap,可找到大量注册该钩子的插件,例如:SingleEntryPlugin、MultiEntryPlugin、DynamicEntryPlugin、DllEntryPlugin、PrefetchPlugin 等。
因此,hooks.make.callAsync 到 compilation.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→_addModuleChain→buildModule,获取模块路径、文件类型及所需 Loader 等基本信息 - 核心在
compilation.buildModule中调用module.build(具体实现在webpack/lib/NormalModule.js) NormalModule.build→doBuild:- 使用
loader-runner的runLoaders转译模块内容,将各类资源(如图片、CSS 等)转换为标准 JavaScript 文本 - 在回调中调用
this.parser.parse,通过acorn将 JavaScript 文本解析为 AST
- 使用
- 模块依赖处理:
- 只递归处理会生成新模块的依赖(在
dependencyFactories中注册了工厂的依赖,如import、require) - 导出、占位替换等依赖不会创建新模块,不在此阶段递归
- 处理逻辑位于
Compilation.buildModule→processModuleDependencies
- 只递归处理会生成新模块的依赖(在
所有模块及依赖完成 Loader 转换后,在 make 钩子回调中调用 compilation.finish → compilation.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,识别 import、require,递归处理全部依赖模块。
当所有模块处理完毕,调用 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.assets和compilation.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-loader、babel-loader 的 cacheDirectory 做磁盘缓存;
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-loader、url-loader、raw-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替代forEach,Map、Set替代普通对象; - 默认使用更快 md4 哈希算法;
- AST 在 loader 链路尽量复用,减少重复解析;
- 大量逻辑替换字符串处理,降低正则表达式开销;
- 内部数据结构优化,减少对象实例创建开销。
模块热替换 HMR 原理
HMR 即模块热替换,修改模块无需整页刷新,保留页面运行时状态。
主要组件:
webpack-dev-server:开发服务器,提供静态资源 + WebSocket 长连接;webpack-dev-middleware:使用内存文件系统(Webpack 4 配套版本常见为 memory-fs),产物不写入硬盘;HotModuleReplacementPlugin:向浏览器注入 HMR runtime。
工作流程:
- 文件变更触发 webpack 重新编译,生成更新模块 chunk;
- WDS 通过 WebSocket 向浏览器推送更新 hash;
- 浏览器 HMR runtime 发起 HTTP 请求拉取更新模块;
- runtime 更新内存模块缓存,执行模块替换逻辑;
- 如果模块没有编写 accept 热更新逻辑,降级整页刷新。
备注:webpack 只提供底层模块替换能力;vue-loader、react-hot-loader 在之上封装组件级热更新。
Loader 为什么从右向左执行
配置 use: ['a-loader', 'b-loader', 'c-loader']。
- pitch 阶段:从左向右;
- normal 主体阶段:从右向左。
底层实现参考 loader-runner 内部函数 iteratePitchingLoaders、iterateNormalLoaders。
本质是函数式编程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、编译报错 |
| 使用场景 | 注册全局插件,读取原始配置 | 操作模块、修改输出产物、获取编译错误 |