Webpack5 原理学习

初看源码

  1. 当在运行 webpack 打包命令的时候,会去执行 webpack-cli 中的 runWebpack() 然后 => 执行 createCompiler(), 内部会获取 options 和 config => 然后去调用 webpack。

  2. webpack 调用自身 createCompiler()createCompiler() 处理 options 后 new Compiler 生成 compiler 对象。然后注入 plugins 插件,后挂载一些 hooks(hooks 概念下面再讲,这里先提一下)

    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

    // lib/webpack.js

    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);

    }

    }

    }

    // hooks

    compiler.hooks.environment.call();

    compiler.hooks.afterEnvironment.call();

  3. 生成的 compiler 执行自身 run() 方法
    这里又会挂载一些 hooks,然后执行自身 compile()

1
2
3
4
5
6
7
8
9
10
11
12
13
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);
});
});
});
  1. compile() 中会生成 compilation 对象,其次同样是挂载 hooks,这里其实对应了单次执行编译的生命周期:beforeCompile -> make -> finishMake -> finish -> seal -> afterCompile
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
/**
* @param {Callback<Compilation>} callback signals when the compilation finishes
* @returns {void}
*/
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);

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);
});
});
});
});
});
});
});
}

到这里 webpack 整体上的执行流程和生命周期就结束了,看上去似乎很简单,并没有做什么编译上的事情,只是不停的在挂载 hooks 。

这也就是 webpack 的设计,将所有单一的职责明确的事情都交给了 plugins,webpack 通过加载 plugins 挂载 hooks ,在特定的执行阶段去执行对应的 hooks 来完成所有的任务。

plugins & Tapable

一个内置 plugin 的示例:EntryPlugin

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
class EntryPlugin {
/**
* An entry plugin which will handle
* creation of the EntryDependency
*
* @param {string} context context path
* @param {string} entry entry path
* @param {EntryOptions | string=} options entry options (passing a string is deprecated)
*/
constructor(context, entry, options) {
this.context = context;
this.entry = entry;
this.options = options || "";
}

/**
* Apply the plugin
* @param {Compiler} compiler the compiler instance
* @returns {void}
*/
apply(compiler) {
compiler.hooks.compilation.tap(
"EntryPlugin",
(compilation, { normalModuleFactory }) => {
compilation.dependencyFactories.set(
EntryDependency,
normalModuleFactory
);
}
);

const { entry, options, context } = this;
const dep = EntryPlugin.createDependency(entry, options);

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

/**
* @param {string} entry entry request
* @param {EntryOptions | string} options entry options (passing string is deprecated)
* @returns {EntryDependency} the dependency
*/
static createDependency(entry, options) {
const dep = new EntryDependency(entry);
// TODO webpack 6 remove string option
dep.loc = { name: typeof options === "object" ? options.name : options };
return dep;
}
}

从形态上看,插件通常是一个带有 apply 函数的类。

从逻辑上看,插件获取到全局的编译器 compiler 对象,compiler 提供的生命周期 hooks 上添加执行逻辑来对 webpack 处理流程进行干预。

plugin 也能获取到单次编译的 compilation 对象,通过调用对象上的方法,对单次编译进行干预。

从 plugin 的源码里可以看出 webpack plugin 是基于 hooks 去实现的。

查看 Compiler 类的实现,可以看到 hooks 的定义:

1
2
3
4
5
6
7
8
9
10
this.hooks = Object.freeze({
/** @type {SyncHook<[]>} */
initialize: new SyncHook([]),
/** @type {SyncBailHook<[Compilation], boolean | undefined>} */
shouldEmit: new SyncBailHook(["compilation"]),
/** @type {AsyncSeriesHook<[Stats]>} */
done: new AsyncSeriesHook(["stats"]),
// 以下省略...
})

而其中的 SyncHook 等,都是来自 Tapable 这个 npm 包。

Tapable ,这个包的主要逻辑就是实现一个订阅/发布模式:用 tap 方法注册回调,用 call 调用回调。

Tapable 定义许多 Hook ,从分类上来主要分为异步、同步;异步中又分为并行、串行;

具体的 Hook 不进行展开,一个是记不住,二是不影响对 webpack 整体的理解。可以参考 文章

构建流程

至此,webpack 的大致轮廓已经有了:提供一套具有生命周期的流水线,用 Tapable 实现钩子,plugins 注册钩子函数, webpack 在执行到对应的生命周期时调用 plugin 注册的逻辑。

hooks 对应了整个 webpack 执行的生命周期,可以通过 hooks 观察 webpack 的生命周期,这部分在 webpack 官网上有列举:compiler-hookscompilation-hooks

总体的构建流程:

  1. 初始化参数:从配置文件、 配置对象、Shell 参数中读取,与默认配置结合得出最终的参数
    1. 创建编译器对象:用上一步得到的参数创建 Compiler 对象
    2. 初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化 RuleSet 集合、加载配置的插件等
    3. 开始编译:执行 compiler 对象的 run 方法
    4. 确定入口:根据配置中的 entry 找出所有的入口文件,调用 compilition.addEntry 将入口文件转换为 dependency 对象
    5. **编译模块 (make)**:根据 entry 对应的 dependency 创建 module 对象,调用 loader 将模块转译为标准 JS 内容,调用 JS 解释器将内容转换为 AST 对象,从中找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
    6. 完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的 依赖关系图
    7. **输出资源 (seal)**:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
    8. **写入文件系统 (emitAssets)**:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

可以看到 14 与开头第一章节是基本对应的,后面的 59 则是散落到各个 plugins 中。

entry & dependency

webpack 官网简单介绍了 Dependency Graph

任何时候一个文件依赖于另一个文件,webpack 都会将其视为 dependency 。这允许 webpack 获取非代码资源,例如图像或 Web 字体,并将它们作为应用程序的依赖项提供。

当 webpack 处理你的应用程序时,它从命令行或其配置文件中定义的模块列表开始。从这些 entry points 开始,webpack 递归地构建一个 Dependency Graph,其中包含应用程序所需的每个模块,然后将所有这些模块捆绑到少量的 bundles 中(通常只有一个)以供浏览器加载。

从源码上看,compilation 中的 addEntry -> _addEntryItem -> addModuleTree -> handleModuleCreation 是在负责这一职责。

handleModuleCreation 函数中, 会对 moduleGraph 对象进行操作,最终存储一个依赖关系的无环有向图。

make 编译

依赖图构建完成后,开始进行编译,这里每个需要参与构建的文件都被视作一个 module,webpack 中有个 Module 的抽象类对其进行了定义。Module 下的 build 方法则是进行编译的入口方法。

编译中,webpack 运行 loaders 将各种资源文件 module 转译成 js。

loader

loader 是一个带有副作用的内容转译器。应为 webpack 只接受符合 js 规范的文本,因此其他类型的 module 时,需要将此种 module 转换成 js module。

loader 通常是一个函数:

  • source:资源输入,对于第一个执行的 loader 为资源文件的内容;后续执行的 loader 则为前一个 loader 的执行结果
  • sourceMap: 可选参数,代码的 sourcemap 结构
  • data: 可选参数,其它需要在 Loader 链中传递的信息
1
2
3
4
5
6
function loader(source, sourceMap?, data?) {
// source 为 loader 的输入,可能是文件内容,也可能是上一个 loader 处理结果
return source;
};

module.exports = loader;

类型

loader 主要有以下几种类型:

  • 同步 loaderreturn 或调用 this.callback 都是同步返回值
  • 异步 loader :是用 this.async() 获取异步函数,是用 this.callback() 返回值
  • raw loader :默认情况下接受 utf-8 类型的字符串作为入参,若标记 raw 属性为 true ,则入参的类型为二进制数据
  • pitch loaderloader 总是从右到左被调用。有些情况下,loader 只关心 request 后面的 元数据 (metadata),并且忽略前一个 loader 的结果。在实际(从右到左)执行 loader 之前,会先从左到右调用 loader 上的 pitch 方法。

pitch loader

上面的示例代码是个Normal Loader,而 Pitching Loader 则是在 loader 函数上设置的 pitch 函数

1
2
3
loader.pitch = function (remainingRequest, precedingRequest, data) { 

};

Pitching Loader 的执行顺序是 从左到右,而 Normal Loader 的执行顺序是 从右到左。当 Loader.pitch 方法返回非 undefined 值时,跳过了剩下的 loader。

loader 实现示例

  • 删除源码中的 console.log 函数
1
2
3
4
5
6
7
module.exports = function (source) {
const reg = /console\.log\(["'].*?["']\)/
if (reg.test(source)) {
return source.replace(reg, '')
}
return source
}

模块加载原理

比如这样的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
// main.js
const hello = require('./modules/module1')
function main() {
console.log('main')
}
hello()
main()

// module1.js
module.exports = function hello() {
console.log('hello')
}

构建出如下的产物:

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
/******/ (() => { // webpackBootstrap
/******/ var __webpack_modules__ = ({

/***/ "./src/modules/module1.js":
/*!********************************!*\
!*** ./src/modules/module1.js ***!
\********************************/
/***/ ((module) => {

module.exports = function hello() {
console.log('hello')
}

/***/ })

/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
(() => {
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
const hello = __webpack_require__(/*! ./modules/module1 */ "./src/modules/module1.js")
function main() {
console.log('main')
}
hello()
main()
})();

/******/ })()
;
  1. 首先这整个是个 IIFE,防止全局作用域污染
  2. 定义 __webpack_modules__ ,形成模块 map,模块作为值保存
  3. 定义 __webpack_module_cache__ ,缓存已加载的模块
  4. 定义 __webpack_require__ ,用来加载模块并缓存,从 __webpack_modules__ 加载模块并保存到 __webpack_module_cache__
  5. 执行 entry 模块,其中的 引入模块 (import/require) 会被 __webpack_require__ 替代

懒加载

  1. 将异步模块分割成单独 chunk
  2. 异步引入模块的时机会去通过 jsonp 的方式加载模块,返回 promise
  3. webpack 新建一个全局队列 self["webpackChunk"],其 push 方法为 webpackJsonpCallback, 该方法会去合并模块到 __webpack_modules__,并去执行上一步中 promise 的 resolve
  4. 异步模块中会添加执行 self["webpackChunk"].push 的方法,执行该方法,即去将异步模块中的导出注册到 __webpack_modules__
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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
/******/ 	/* webpack/runtime/jsonp chunk loading */
/******/ (() => {
/******/ // no baseURI
/******/
/******/ // object to store loaded and loading chunks
/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
/******/ var installedChunks = {
/******/ "main": 0
/******/ };
/******/
/******/ __webpack_require__.f.j = (chunkId, promises) => {
/******/ // JSONP chunk loading for javascript
/******/ var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
/******/ if(installedChunkData !== 0) { // 0 means "already installed".
/******/
/******/ // a Promise means "currently loading".
/******/ if(installedChunkData) {
/******/ promises.push(installedChunkData[2]);
/******/ } else {
/******/ if(true) { // all chunks have JS
/******/ // setup Promise in chunk cache
/******/ var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));
/******/ promises.push(installedChunkData[2] = promise);
/******/
/******/ // start chunk loading
/******/ var url = __webpack_require__.p + __webpack_require__.u(chunkId);
/******/ // create error before stack unwound to get useful stacktrace later
/******/ var error = new Error();
/******/ var loadingEnded = (event) => {
/******/ if(__webpack_require__.o(installedChunks, chunkId)) {
/******/ installedChunkData = installedChunks[chunkId];
/******/ if(installedChunkData !== 0) installedChunks[chunkId] = undefined;
/******/ if(installedChunkData) {
/******/ var errorType = event && (event.type === 'load' ? 'missing' : event.type);
/******/ var realSrc = event && event.target && event.target.src;
/******/ error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
/******/ error.name = 'ChunkLoadError';
/******/ error.type = errorType;
/******/ error.request = realSrc;
/******/ installedChunkData[1](error);
/******/ }
/******/ }
/******/ };
/******/ __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
/******/ }
/******/ }
/******/ }
/******/ };
/******/
/******/ // no prefetching
/******/
/******/ // no preloaded
/******/
/******/ // no HMR
/******/
/******/ // no HMR manifest
/******/
/******/ // no on chunks loaded
/******/
/******/ // install a JSONP callback for chunk loading
/******/ var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
/******/ var [chunkIds, moreModules, runtime] = data;
/******/ // add "moreModules" to the modules object,
/******/ // then flag all "chunkIds" as loaded and fire callback
/******/ var moduleId, chunkId, i = 0;
/******/ if(chunkIds.some((id) => (installedChunks[id] !== 0))) {
/******/ for(moduleId in moreModules) {
/******/ if(__webpack_require__.o(moreModules, moduleId)) {
/******/ __webpack_require__.m[moduleId] = moreModules[moduleId];
/******/ }
/******/ }
/******/ if(runtime) var result = runtime(__webpack_require__);
/******/ }
/******/ if(parentChunkLoadingFunction) parentChunkLoadingFunction(data);
/******/ for(;i < chunkIds.length; i++) {
/******/ chunkId = chunkIds[i];
/******/ if(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
/******/ installedChunks[chunkId][0]();
/******/ }
/******/ installedChunks[chunkId] = 0;
/******/ }
/******/
/******/ }
/******/
/******/ var chunkLoadingGlobal = self["webpackChunk"] = self["webpackChunk"] || [];
/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
/******/ })();

HMR

  • 使用 webpack-dev-server 托管静态资源,同时以 Runtime 方式注入 HMR 客户端代码
  • 浏览器加载页面后,与 WDS 建立 WebSocket 连接
  • Webpack 监听到文件变化后,增量构建发生变更的模块,并通过 WebSocket 发送 hash 事件
  • 浏览器接收到 hash 事件后,请求 manifest 资源文件,确认增量变更范围
  • 浏览器加载发生变更的增量模块
  • Webpack 运行时触发变更模块的 module.hot.accept 回调,执行代码变更逻辑