前言

最近通过对webpack的学习,较为深刻的学习了webpack的基础配置以及项目优化的方法。今天主要内容是记录一下自己对webpack的源码的学习。

探究

webpack周期

首先我们要了解一下webpack的编译构建流程所用到的钩子函数调用顺序:

  • entry-option,初始化option
  • run,开始编译
  • make,从entry开始递归的分析依赖,对每个依赖模块进行build
  • before-resolve,对模块位置进行解析
  • build-module,开始构建某个模块
  • normal-module-loader,将loader加载完成的module进行编译,生成ast树
  • program,遍历AST,当遇到require等一些调用表达式时,收集依赖
  • seal,所有依赖build完成,开始优化
  • emit,输出到dist目录

webpack入口

webpack本身也只是一个构造函数。我们可以从webpack/lib/webpack.js看到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
const webpack = (options, callback) => {

// 校验webpack.config.js传入的配置
const webpackOptionsValidationErrors = validateSchema(
webpackOptionsSchema,
options
);
if (webpackOptionsValidationErrors.length) {
throw new WebpackOptionsValidationError(webpackOptionsValidationErrors);
}

//
let compiler;
if (Array.isArray(options)) {
compiler = new MultiCompiler(
Array.from(options).map(options => webpack(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);
}
}
}
// 触发environment,afterEnvironment两个钩子函数,
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
compiler.options = new WebpackOptionsApply().process(options, compiler);
} else {
throw new Error("Invalid argument: options");
}
if (callback) {
if (typeof callback !== "function") {
throw new Error("Invalid argument: callback");
}
// 判断是否启用监视模式
if (
options.watch === true ||
(Array.isArray(options) && options.some(o => o.watch))
) {
const watchOptions = Array.isArray(options)
? options.map(o => o.watchOptions || {})
: options.watchOptions || {};
return compiler.watch(watchOptions, callback);
}

// 这里才开始构建应用
compiler.run(callback);
}
return compiler;
};

从上面可以看出,webpack其实就是一个方法。它声明并返回了一个Compiler的实例对象。那么,Compiler是个什么东西呢? 我们看一下

1
2
3
class Compiler extends Tapable {
// 里面内容暂时先不看
}

可以看出,Compiler是继承了Tapable。那么接下来,我们来说一下Tapable是什么。

Tapable

Tapable,我个人觉得它其实是一个事件注册机😂。

它的作用是为插件提供钩子类函数。主要是以下几种类型:

  • 普通型(钩子类名没有bail,waterfall,loop):这种钩子会按照tap注册的顺序依次执行
  • 瀑布型(waterfall): 这种钩子和普通钩子不同的地方在于:如果上一个tap有返回值。它会把这个返回值当成参数传入下一个tap函数。
  • 熔断型(bail):如果tap返回除null之外的值,会提前退出且停止执行其他的函数。
  • 循环型(loop):如果tap返回一个未定义的值时,tap会从第一个插件重新启动且循环该过程直到所有插件返回未定义的值才跳出循环。

栗子

tapable提供了许多不同的钩子类,我们可以为插件创建不同类型的钩子。

1
2
3
4
5
6
7
8
9
10
11
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");

所有的钩子构建函数都接受一个可选的参数(最好是接收一个字符串类型的参数名数组,例如['a', 'b', 'c']

1
const hook = new SyncHook(["arg1", "arg2", "arg3"]);

最好的练习是把所有的钩子暴露在一个类中的hooks属性当中:

1
2
3
4
5
6
7
8
9
10
11
class Car {
constructor() {
this.hooks = {
accelerate: new SyncHook(["newSpeed"]),
brake: new SyncHook(),
calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
};
}

/* ... */
}

现在其他人可以这样使用这些钩子

1
2
3
4
const myCar = new Car();

// 使用tap方法添加一个消费者
myCar.hooks.brake.tap("WarningLampPlugin", () => warningLamp.on());

这里你必须传一个name去标记这个插件。

你可以接收参数:

1
2

myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));

对于同步钩子而言,tap方法是添加插件时唯一合法的方法,异步钩子通常支持异步插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 绑定promise钩子
myCar.hooks.calculateRoutes.tapPromise("GoogleMapsPlugin", (source, target, routesList) => {
// return a promise
return google.maps.findRoute(source, target).then(route => {
routesList.add(route);
});
});
// 绑定异步钩子
myCar.hooks.calculateRoutes.tapAsync("BingMapsPlugin", (source, target, routesList, callback) => {
bing.findRoute(source, target, (err, route) => {
if(err) return callback(err);
routesList.add(route);
// call the callback
callback();
});
});

//绑定同步钩子
myCar.hooks.calculateRoutes.tap("CachedRoutesPlugin", (source, target, routesList) => {
const cachedRoute = cache.get(source, target);
if(cachedRoute)
routesList.add(cachedRoute);
})

类需要调用被声明的那些钩子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Car {
/* ... */

setSpeed(newSpeed) {
// call(xx) 传参调用同步钩子的API
this.hooks.accelerate.call(newSpeed);
}

useNavigationSystemPromise(source, target) {
const routesList = new List();
// 调用promise钩子(钩子返回一个promise)的API
return this.hooks.calculateRoutes.promise(source, target, routesList).then(() => {
return routesList.getRoutes();
});
}

useNavigationSystemAsync(source, target, callback) {
const routesList = new List();
// 调用异步钩子API
this.hooks.calculateRoutes.callAsync(source, target, routesList, err => {
if(err) return callback(err);
callback(null, routesList.getRoutes());
});
}
}

其实这个例子是官方文档举的,我就是翻译了一下。通过这个栗子我们可以知道怎么使用tapable来写一个插件。还有一些tapable的内容,我就没继续写了。毕竟我们的主要目的还是搞清webpack的运行过程。

构建与编译

上面刚才说到了,构建应用的开始是这么一段代码:compiler.run(callback)。现在我们来看下run方法内部是什么样子的。 run方法位于lib/Compiler.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 内部很多代码对我们没有帮助,省略掉
run(callback) {
// ...

const run = () => {
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);
});
});
});
};

// ...
}

可以看出,run方法内就是触发了 beforeRunrun两个钩子,然后到this.compile(onCompiled)之后才开始真正编译整个项目。

接下来看一下compile方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
if (err) return callback(err);

this.hooks.compile.call(params);
// 创建compilation
const compilation = this.newCompilation(params);

const logger = compilation.getLogger("webpack.Compiler");

logger.time("make hook");
this.hooks.make.callAsync(compilation, err => {
logger.timeEnd("make hook");
if (err) return callback(err);

logger.time("finish make hook");
this.hooks.finishMake.callAsync(compilation, err => {
logger.timeEnd("finish make hook");
if (err) return callback(err);

process.nextTick(() => {
logger.time("finish compilation");
compilation.finish(err => {
logger.timeEnd("finish compilation");
if (err) return callback(err);

logger.time("seal compilation");
compilation.seal(err => {
logger.timeEnd("seal compilation");
if (err) return callback(err);

logger.time("afterCompile hook");
this.hooks.afterCompile.callAsync(compilation, err => {
logger.timeEnd("afterCompile hook");
if (err) return callback(err);

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

compile方法内部主要就是创建了一个 Compilation 对象,然后触发make的钩子。

对于Compilation而言,它的实例能够访问所有的模块和它们的依赖(大部分是循环依赖)。它会对应用程序的依赖图中所有模块进行字面上的编译(literal compilation)。在编译阶段,模块会被加载(loaded)、封存(sealed)、优化(optimized)、分块(chunked)、哈希(hashed)和重新创建(restored)。

而make阶段的话,主要是根据配置中的entry去找到入口文件,然后根据入口文件递归出所有依赖。形成一个依赖关系树。然后将递归到的所有模块交给对应的loader进行处理。

如果我们想看一下make阶段如何处理的。那么首先,我们得找到make这个钩子的注册的地方。这里,我们可以通过全局查找make.tap(绑定方法的地方),最终我们能在EntryPlugin.js找到它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apply(compiler) {
compiler.hooks.compilation.tap(
"EntryPlugin",
(compilation, { normalModuleFactory }) => {
compilation.dependencyFactories.set(
EntryDependency,
normalModuleFactory
);
}
);

compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
const { entry, options, context } = this;
const dep = EntryPlugin.createDependency(entry, options);
compilation.addEntry(context, dep, options, err => {
callback(err);
});
});
}

到这里,对于webpack的运行机制差不多就了解了。当然如果你对webpack内如何处理我们的项目文件的话也可以继续往下探究。但对于这篇文章而言,就没必要了。

总结

通过对webpack的源码解读,可以大致总结webpack工作的流程:

  • 调用webpack方法,处理传入的配置进行并创建Compiler对象。
  • 创建compilation对象,并调用compile方法开始对项目进行编译。
  • 调用addEntry方法以配置文件中的entry属性指向文件为起点,开始递归形成依赖关系树。
  • 递归依赖树,将依赖交给loader处理。
  • 合并loader处理过的结果,输出打包结果。