Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。 Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。 –吴浩麟《深入浅出webpack》
Tapable
Webpack 的事件流机制是靠 Tapable 实现的,Tapable 是 Webpack 自带的模块,不需要单独安装,在 Webpack 被安装的同时它也会一并被安装。Tapable 主要提供了以下钩子,分为同步 / 异步,异步又分为并行和串行。
同步:SyncHook / SyncWaterfallHook / SyncBailHook / SyncLoopHook
异步
- 异步并行:AsyncParallelHook, AsyncParallelBailHook
- 异步串行:AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook
- 其中 AsyncParallelBailHook 和 AsyncSeriesBailHook 在 Webpack 中没有使用,在此就不讲了
注册 & 触发
- 同步:tap & call
- 异步:tap & call, tapAsync & callAsync, tapPromise & promise
用法
-
创建钩子
const hook = new SyncHook(['arg1', 'arg2'])
所有类型的钩子都有一个可选的参数,参数是一个字符串数组,每个字符串代表注册函数的参数名。
-
注册
hook.tap('plugin1', (arg1, arg2) => { // TODO: }) hook.tap('plugin2', (arg1, arg2) => { // TODO: })
传入一个名字,注册函数可以接收创建钩子时定义的参数。一个钩子可以注册多个函数。
-
触发
hook.call(arg1, arg2) // 同步 hook.callAsync(arg1, arg2, callback) // 异步
异步钩子在触发时可以在最后传递一个
callback
。实际使用的时候,创建钩子和触发的代码通常在一个类中,注册的代码在另一个类(插件)中。
表述统一
为了表述上的统一,我定义了几个概念
-
注册函数、注册函数的回调、最终回调
const hook = new AsyncParallelHook() hook.tapAsync('plugin1', function listener(callback) { // listener 是注册函数,callback 是注册函数的回调 console.log('注册函数') callback() }) hook.callAsync(function finalCb(err) { console.log('最终回调') }) // finalCb 是最终回调
-
注册函数中报错:调用 callback 时传了 truthy 类型的参数;
注册函数中没报错:调用 callback 时传了 falsy 类型的参数(undefined, false, null);
调用 callback 时,AsyncParallelHook & AsyncSeriesHook 接收一个参数 err,表示报错信息;AsyncParallelBailHook & AsyncSeriesBailHook 接收两个参数 err & result,分别表示报错信息和注册函数的返回值。
SyncHook
-
注册函数按注册顺序执行
-
demo
// Car.js import { SyncHook } from 'tapable' export default class Car { constructor() { this.hooks = { start: new SyncHook(), } } start() { this.hooks.start.call() } } // index.js import Car from './Car' const car = new Car() car.hooks.start.tap('startPlugin1', () => console.log('系安全带1')) car.hooks.start.tap('startPlugin2', () => console.log('系安全带2')) car.start() // 打印 // 系安全带1 // 系安全带2
-
demo 触发时执行的函数
function anonymous(arg1, arg2, arg3) { "use strict"; var _context; var _x = this._x; var _fn0 = _x[0]; _fn0(arg1, arg2, arg3); }
SyncWaterfallHook
-
上一个注册函数的返回值作为下一个注册函数的输入值
-
demo
// Car.js import { SyncWaterfallHook } from 'tapable' export default class Car { constructor() { this.hooks = { speed: new SyncWaterfallHook(['speed']), } } speed(spd) { this.hooks.speed.call(spd) } } // index.js import Car from './Car' const car = new Car() car.hooks.speed.tap('speedPlugin1', (speed) => { console.log(`加速到${speed}`); return speed + 50; }) car.hooks.speed.tap('speedPlugin2', (speed) => { console.log(`加速到${speed}`); return speed+ 50; }) // 上一个回调返回了 100,它会作为第二个回调的参数 car.hooks.speed.tap('speedPlugin3', (speed) => console.log(`加速到${speed}`)) car.speed(100) // 打印 // 加速到100 // 加速到150 // 加速到200
-
demo 触发时执行的函数
function anonymous(arg1) { "use strict"; var _context; var _x = this._x; var _fn0 = _x[0]; var _result0 = _fn0(arg1); if (_result0 !== undefined) { arg1 = _result0; // 这里保存结果,给下一个函数使用 } var _fn1 = _x[1]; var _result1 = _fn1(arg1); if (_result1 !== undefined) { arg1 = _result1; } return arg1; }
SyncBailHook
-
如果上一个注册函数有返回值,剩余的注册函数都不执行
-
demo
// Car.js import { SyncBailHook } from 'tapable' export default class Car { constructor() { this.hooks = { brake: new SyncBailHook(), } } brake() { this.hooks.brake.call() } } // index.js import Car from './Car' const car = new Car() car.hooks.brake.tap('brakePlugin1', () => { console.log('刹车1')}) car.hooks.brake.tap('brakePlugin2', () => { console.log('刹车2'); return 1;}) car.hooks.brake.tap('brakePlugin3', () => { console.log('刹车3')}) // 上一个回调返回了 1,所以这个回调不会执行 car.brake() // 打印 // 刹车1 // 刹车2
-
demo 触发时执行的函数
function anonymous(/*``*/) { "use strict"; var _context; var _x = this._x; var _fn0 = _x[0]; var _result0 = _fn0(); if (_result0 !== undefined) { // 如果undefined直接返回,如果不是则需要进入下一个函数 return _result0; } else { var _fn1 = _x[1]; var _result1 = _fn1(); if (_result1 !== undefined) { return _result1; } else { } } }
SyncLoopHook
-
只要注册函数有返回值,就一直循环执行它
-
demo
// Car.js import { SyncLoopHook } from 'tapable' export default class Car { constructor() { this.hooks = { startEngine: new SyncLoopHook(), } } startEngine() { this.hooks.startEngine.call() } } // index.js import Car from './Car' const car = new Car() let index = 1; car.hooks.startEngine.tap('startEnginePlugin1', () => { console.log(`启动${index}次`); if (index < 3) { index++ return 1 } }) car.hooks.startEngine.tap('startEnginePlugin2', () => { console.log('启动成功') }) car.startEngine() // 打印 // 启动1次 // 启动2次 // 启动3次 // 启动成功
AsyncParallelHook
-
如果在某个注册函数中报错,立刻执行最终回调,并且只执行一次,不影响其他注册函数执行;如果所有的注册函数都没报错,那么先执行完所有注册函数,最后执行最终回调。
-
demo
// Car.js import { AsyncParallelHook } from 'tapable' export default class Car { constructor() { this.hooks = { calculateRoutes: new AsyncParallelHook(), } } calculateRoutes(callback) { this.hooks.calculateRoutes.callAsync(callback) } } // index.js import Car from './Car' const car = new Car() car.hooks.calculateRoutes.tapAsync('calRoutesPlugin1', callback => { setTimeout(() => { console.log('计算路线1') callback() }, 3000) }) car.hooks.calculateRoutes.tapAsync('calRoutesPlugin2', callback => { setTimeout(() => { console.log('计算路线2') callback() }, 1000) }) car.calculateRoutes(err => { console.log('最终的回调, err: ', err) }) // 最终的回调 // 打印 // 如果 calRoutesPlugin1 & calRoutesPlugin2 调用 callback 的时候没有传参数,会在1s的时候打印'计算路线2',3s的时候打印'计算路线1',紧接着打印'最终的回调, err: undefined' // 如果 calRoutesPlugin1 & calRoutesPlugin2 分别调用 callback(1) & callback(2) ,会在1s的时候打印'计算路线2',紧接着打印'最终的回调, err: 2',3s的时候打印'计算路线1'
-
demo 触发时执行的函数
function anonymous(_callback) { "use strict"; var _context; var _x = this._x; do { var _counter = 2; // 注册函数的数量 var _done = () => { _callback(); }; if (_counter <= 0) break; // 执行注册函数之前,检查 _counter,如果小于等于 0,说明某个注册函数的回调中出错,直接终止。 var _fn0 = _x[0]; _fn0(_err0 => { // _fn0 函数的回调调用时间不确定 if (_err0) { // 如果有注册函数报错 if (_counter > 0) { _callback(_err0); _counter = 0; } } else { if (--_counter === 0) _done() // 所有注册函数执行完都没有报错,则执行最终回调 } }); if (_counter <= 0) break; var _fn1 = _x[1]; _fn1(_err1 => { if (_err1) { if (_counter > 0) { _callback(_err1); _counter = 0; } } else { if (--_counter === 0) _done(); } }); } while (false); }
AsyncSeriesHook
-
如果在某个注册函数中报错,立刻执行最终回调,并且只执行一次,其后的注册函数不再执行;如果所有的注册函数都没有报错,那么先执行完所有注册函数,最后执行最终回调。
-
demo
// Car.js import { AsyncSeriesHook } from 'tapable' export default class Car { constructor() { this.hooks = { calculateRoutes2: new AsyncSeriesHook(), } } calculateRoutes2(callback) { this.hooks.calculateRoutes2.callAsync(callback) } } // index.js import Car from './Car' const car = new Car() car.hooks.calculateRoutes2.tapAsync('calRoutesPlugin1', callback => { setTimeout(() => { console.log('计算路线1') callback() }, 3000) }) car.hooks.calculateRoutes2.tapAsync('calRoutesPlugin2', callback => { setTimeout(() => { console.log('计算路线2') callback() }, 1000) }) car.calculateRoutes2(err => { console.log('最终的回调, err: ', err) }) // 打印 // 如果 calRoutesPlugin1 & calRoutesPlugin2 调用 callback 的时候没有传参数,会在3s的时候打印'计算路线1',4s的时候打印'计算路线2',紧接着打印'最终的回调, err: undefined' // 如果 calRoutesPlugin1 & calRoutesPlugin2 分别调用 callback(1) & callback(2) ,会在3s的时候打印'计算路线1',紧接着打印'最终的回调, err: 1',不会执行 calRoutesPlugin2 的回调,所以不会打印'计算路线2'
-
demo 触发时执行的函数:
function anonymous(_callback) { "use strict"; var _context; var _x = this._x; var _fn0 = _x[0]; _fn0(_err0 => { if (_err0) { _callback(_err0); } else { var _fn1 = _x[1]; _fn1(_err1 => { if (_err1) { _callback(_err1); } else { _callback(); // 串行执行,最后返回 _callback } }); } }); }
Webpack 中的 tapable
Compiler
Compiler 模块是 Webpack 主要引擎,Webpack 启动后实例化了一个 Compiler 对象,然后调用它的 run 方法开始编译。
// /webpack/lib/Compiler.js class Compiler extends Tapable { constructor(context) { super(); this.hooks = { // 声明事件 ... /** @type {AsyncSeriesHook<Compiler>} */ run: new AsyncSeriesHook(["compiler"]), /** @type {SyncHook<Compilation, CompilationParams>} */ compilation: new SyncHook(["compilation", "params"]), /** @type {AsyncParallelHook<Compilation>} */ make: new AsyncParallelHook(["compilation"]), }; } run(callback) {} }
Compilation
一个 Compilation 实例代表了一次版本构建和生成资源。当运行 Webpack 开发环境中间件时,每当检测到一个文件变化,就会开启一次新的编译,从而生成一组新的资源。一个 Compilation 实例表现了当前的模块资源、生成资源、变化的文件、以及被跟踪依赖的状态信息,也提供了很多关键点钩子供插件做自定义处理时选择使用。
// /webpack/lib/Compilation.js class Compilation extends Tapable { constructor(compiler) { super(); this.hooks = { ... /** @type {SyncHook} */ seal: new SyncHook([]), }; modules: [], _modules: [], entries: [], } }
模块对象
前面讲了 Tapable 各种类型的钩子,因为和构建息息相关的 Compiler & Compilation 都是继承 Tapable,学习它能更好理解 Webpack 的编译过程,下面正式开始。
注意:下面的 Webpack 源码是部分截取或源码的简单写法,重点在主要构建流程,不纠结细枝末节。
我们先定义下入口文件和配置文件
// src/make/index.js import add from './b.js' add(1, 2) import('./c.js').then(del => del(1, 2)) // src/make/b.js export default function add(n1, n2) { return n1 + n2 } // src/make/c.js export default function add(n1, n2) { return n1 + n2 } // webpack.config.js module.exports = { entry: { app: './src/make/index.js' // 单入口文件 } }
启动 Webpack
// 这里模仿 webpack-cli 中的代码,相当于在命令行里输入 webpack const options = require("./webpack.config.js"); const compiler = webpack(options); compiler.run();
初始化
这个阶段主要做了以下几件事:
- 校验参数 options
- 整合 options
- 创建 compiler 对象
- 加载外部插件
- 加载内部插件
- 返回 compiler 对象
我们来看看主要代码:
// /webpack/lib/webpack.js const webpack = (options, callback) => { // 1: 校验参数 options const webpackOptionsValidationErrors = validateSchema( webpackOptionsSchema, options ); // 2: 整合 options:有命令行中的配置,webpack.config.js 中的配置,Webpack 的默认配置 options = new WebpackOptionsDefaulter().process(options); // 3: 创建 compiler 对象 compiler = new Compiler(options.context); // 4: 加载外部插件 if (options.plugins && Array.isArray(options.plugins)) { for (const plugin of options.plugins) { plugin.apply(compiler); } } // 5: 加载内部插件 compiler.options = new WebpackOptionsApply().process(options, compiler); // 6: 返回 compiler 对象 return compiler; } // /webpack/lib/WebpackOptionsApply.js class WebpackOptionsApply extends OptionsApply { process(options, compiler) { ... // 其他内部插件 new EntryOptionPlugin().apply(compiler); // EntryOptionPlugin 插件,监听了 entryOption 钩子 compiler.hooks.entryOption.call(options.context, options.entry); // 触发 entryOption new HarmonyModulesPlugin(options.module).apply(compiler); // EntryOptionPlugin 插件,监听了 compilation 钩子 } }
Webpack 定义了很多内部插件,其中,跟构建流程关联比较大的是 EntryOptionPlugin
,它通过解析 options.entry 属性创建不同的插件,例如: SingleEntryPlugin, MultiEntryPlugin, DynamicEntryPlugin
。由于我们是单入口,所以会创建 SingleEntryPlugin
,它内部监听了 compiler 对象的 compilation & make
钩子。
// /webpack/lib/EntryOptionPlugin.js const itemToPlugin = (context, item, name) => { if (Array.isArray(item)) { return new MultiEntryPlugin(context, item, name); } return new SingleEntryPlugin(context, item, name); }; class EntryOptionPlugin { apply(compiler) { compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => { if (typeof entry === "string" || Array.isArray(entry)) { itemToPlugin(context, entry, "main").apply(compiler); } else if (typeof entry === "object") { for (const name of Object.keys(entry)) { itemToPlugin(context, entry[name], name).apply(compiler); } } else if (typeof entry === "function") { new DynamicEntryPlugin(context, entry).apply(compiler); } return true; }); } };
创建 & 构建模块对象
Webpack 的核心原理是:一切皆模块,js / css / 图片都是模块。实际构建过程中,Webpack 并没有直接生成一个模块,而是先生成了一个依赖,依赖会对应一个模块工厂函数,再通过这个工厂函数去创建模块对象。以单文件入口为例,先生成一个 SingleEntryDependency
,它对应的模块工厂函数是 NormalModuleFactory
,再通过它生成一个 NormalModule
实例。Webpack 中还有很多其他类型的 Module。同时依赖还会对应一个模块模版函数
Webpack 默认只能解析 js, json, wasm
类型的文件,解析 js 的是 Parser
,解析 json 的是 jsonParser
,其他类型的文件(css / jpg 等)需要通过 loader 转换成 js
来兼容。创建 Module 实例之后,开始构建这个 Module,先加载 loaders 生成内容,再根据文件类型使用对应的解析器生成 AST。
注意:Webpack 中大量异步的写法使用的是 callback,而不是我们现在习惯的 promise & await,习惯这种写法能更好的看懂源码,例如:
// 使用 await 写异步 const { err, module } = await moduleFactory.create(params); // Webpack 源码 moduleFactory.create(params, (err, module) => { ... })
接下来我们来看是如何构建 Module 的:
在 compiler.run()
中,调用了 this.compile()
,在 compile()
中,
- 调用
newCompilation()
,创建了一个 compilation 对象,并且触发了 compilation 钩子; - 接着触发 make 钩子。
// /webpack/lib/Compiler.js class Compiler extends Tapable { constructor(context) { this.hooks = { ... beforeRun, run, make, } } run(callback) { ... this.hooks.beforeRun.callAsync(this, err => { this.hooks.run.callAsync(this, err => { ... this.compile(onCompiled); }); }); }, compile(callback) { const params = this.newCompilationParams(); const compilation = this.newCompilation(params); this.hooks.make.callAsync(compilation, err => {}) } } // /webpack/lib/SingleEntryPlugin.js 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); }); }
由于 SingleEntryPlugin
监听了 compiler 对象的 compilation & make
钩子,它在 compilation
阶段定义了SingleEntryDependency
对应的模块工厂函数 NormalModuleFactory
,在 make
阶段调用 addEntry 创建并构建了一个 NormalModule 对象。我们来看看主要代码:
addEntry -> _addModuleChain -> buildModule -> processModuleDependencies
// /webpack/lib/Compilation.js addEntry(context, entry, name, callback) { this._addModuleChain(context, entry) } _addModuleChain(context, dependency) { const Dep = dependency.constructor; const moduleFactory = this.dependencyFactories.get(Dep); const dependentModule = moduleFactory.create() // 创建 NormalModule 对象 const dependentModuleWithDeps = this.buildModule(dependentModule) // 构建 NormalModule 对象 this.processModuleDependencies(dependentModuleWithDeps, callback); // 处理 NormalModule 对象的依赖 } buildModule(module) { module.build() } processModuleDependencies(module) { const dependencies = new Map(); module.dependencies.map(dep => { // 有 resourceIdent 的才是我们通常理解的依赖,比如:require & import 引入的模块,需要生成新的模块 const resourceIdent = dep.getResourceIdentifier(); if (resourceIdent) { const factory = this.dependencyFactories.get(dep.constructor); const innerMap = new Map([[resourceIdent, []]]); innerMap.get(resourceIdent).push(dep); dependencies.set(factory, innerMap); } }) this.addModuleDependencies(module, dependencies); } // 递归解析模块依赖 addModuleDependencies(module, dependencies) { dependencies.map(dep => { // 创建模块 -> 构建模块 ... const factory = dep.factory const dependentModule = factory.create() this.buildModule(dependentModule) }) } // NormalModule.js build() { const result = runLoaders(this.resource, this.loaders) // 加载 loaders,this.resource 是文件路径 this.source = result this.parser.parse(this.source) // 将 loaders 处理后的内容转换成 AST;遍历 AST,收集 NormalModule 的依赖 } // NormalModuleFactory.js class NormalModuleFactory { create(data) { // 资源路径,如果是入口依赖则这个就是入口文件路径 const request = data.request // 资源类型 const type = this.getType(request) return new NormalModule({ // 创建解析该模块需要的loader loaders: this.createLoaders(request) // 获取模块解析器 parser: this.getParser(type) // 获取模版生成器 generator: this.getGenerator(type) }); } }
-
创建 NormalModule 对象
根据文件类型
type
定义模块的解析器以及模版生成器,以及需要使用哪些 loaders 处理。 -
构建 NormalModule 对象
模块创建完后,此时只是获取了模块的基本信息,如相对路径,文件类型,需要用哪些 loader 处理这个文件等。接下来需要调用 build 来构建 NormalModule。
-
加载 loaders
使用不同 loader 来转换资源文件,js 文件内容可以直接传给下一步,非 js 文件经过 loader 后一般会输出一段 js 字符串内容。本例中的 index.js 没有使用 loaders,所以经过
runLoaders
处理后得到的内容就是原文件内容:import add from './b.js' add(1, 2) import('./c.js').then(del => del(1, 2))
-
parse:https://github.com/acornjs/acorn / astexplorer.net/
将
runLoaders
处理后的内容转换成 AST,并解析出文件所有的依赖,index.js 的依赖是:NormalModule['./src/make/index.js']: { "dependencies": [ "HarmonyCompatibilityDependency", // 对应模板 `HarmonyExportDependencyTemplate` 会在 index.js 的最前面添加如:`__webpack_require__.r(__webpack_exports__);` 的代码,用于定义 exports:__esModule "HarmonyInitDependency", // 对应模板 `HarmonyInitDependencyTemplate`, 下文单独说明其作用 "ConstDependency", // 对应模板 `ConstDependencyTemplate`,会将 index.js 中的同步 import 语句删掉 "HarmonyImportSideEffectDependency", // 对应模板 `HarmonyImportSideEffectDependencyTemplate`,执行 apply 调用父类 HarmonyImportDependencyTemplate 的 apply,即为空。 "HarmonyImportSpecifierDependency" // 对应模板 `HarmonyImportSpecifierDependencyTemplate`,会在 index.js 中将引入的变量替换为 webpack 对应的包装变量 ], "blocks": ["ImportDependenciesBlock"] // 对应模板 `ImportDependencyTemplate`, 会将 index.js 中的 `import('./c.js')`替换为 `Promise.resolve(/*! import() */).then(__webpack_require__.bind(null, /*! ./c.js */ "./src/make/c.js"))` }
-
处理依赖
-
筛选:调用
processModuleDependencies
处理上一步产生的的依赖,它会筛选出因为 require or import 引入的依赖,比如上面的HarmonyImportSideEffectDependency, HarmonyImportSpecifierDependency, ImportDependenciesBlock
,筛选出来的依赖如下。sortedDependencies: [ { factory: NormalModuleFactory, dependencies: [HarmonyImportSideEffectDependency, HarmonyImportSpecifierDependency] }, { factory: NormalModuleFactory, dependencies: [ImportDependency] } ]
-
处理筛选出来的依赖:调用
addModuleDependencies
为依赖文件生成模块对象。
-
-
最终的 modules
Compilation: { modules: [ NM('./src/make/index.js'): { blocks: [ImportDependenciesBlock('./c.js')], dependencies: [ HarmonyCompatibilityDependency, HarmonyInitDependency, ConstDependency, HarmonyImportSideEffectDependency: { module: NM('./b.js') } HarmonyImportSpecifierDependency: { module: NM('./b.js') } ] }, NM('./b.js'): { blocks: [], dependencies: [ HarmonyCompatibilityDependency, HarmonyInitDependency, HarmonyExportSpecifierDependency, HarmonyExportHeaderDependency ] }, NM('./c.js'): { blocks: [], dependencies: [ HarmonyCompatibilityDependency, HarmonyInitDependency, HarmonyExportSpecifierDependency, HarmonyExportHeaderDependency ] }, ] }
-
总结
- 创建模块对象,将模块对象添加到 Compilation.modules 中。
- 构建模块对象:将模块经 loaders 处理之后的内容转成 AST,遍历 AST 找到所有的依赖,筛选出因 require or import 引入的依赖,继续第1步,直至所有文件处理完毕。
参考
深入源码理解webpack是如何保证plugins的执行顺序的