理性在纠结

互联网搬砖小码农,欢迎一起讨论前端技术


  • 首页

  • 归档

  • 标签

玩转webpack(二):webpack的核心对象

发表于 2017-11-08

前言

webpack 是一个强大的模块打包工具,之所以强大的一个原因在于它拥有灵活、丰富的插件机制。但是 webpack 的文档不太友好,就我自己的学习经历来说,官方的文档并不详细,网上的学习资料又少有完整的概述和例子。所以,在研究了一段时间的 webpack 源码之后,自己希望写个系列文章,结合自己的实践一起来谈谈 webpack 插件这个主题,也希望能够帮助其他人更全面地了解 webpack。

这篇文章是系列文章的第二篇,将会从对象的角度来讲解 webpack。如果你想从整体角度了解 webpack,可以先阅读系列文章的第一篇:玩转webpack(一)

P.S. 以下的分析都基于 webpack 3.6.0
P.S. 本文将继续沿用第一篇文章的名词,任务点表示通过 plugin 方法注册的名称

webpack中的核心对象

跟第一篇文章类似,我们不会讲所有 webpack 中的对象都拿出来讲解,而是整理了一些比较核心的概念。我们可以先看看下面的类图:

webpack核心类图

下面的论述将会逐一讲述类图中的对象,首先我们先来看一下最顶层的类 Tapable。

Tapable

Tapable 提供了 webpack 中基于任务点的架构基础,它将提供任务点注册的方法以及触发的方法。

一个简单的例子,使用 plugin 方法来注册一个任务点,然后使用 applyPlugins 方法触发:

1
2
3
4
5
6
7
8
let obj = new Tapable()
obj.plugin("name", (params1, params2) => {
console.log(params1) // 1
console.log(params2) // params
})
obj.applyPlugins("name", 1, "params")

Tapable 里面注册任务点只有 plugin 方法,但是触发任务点的方法是提供了很多,可以分为同步和异步执行两类:

同步执行:

  • applyPlugins(name, ...params)
  • applyPlugins0(name)
  • applyPlugins1(name, param)
  • applyPlugins2(name, param1, param2)
  • applyPluginsWaterfall(name, init, ...params)
  • applyPluginsWaterfall0(name, init)
  • applyPluginsWaterfall1(name, init, param)
  • applyPluginsWaterfall2(name, init, param1, param2)
  • applyPluginsBailResult(name, ...params)
  • applyPluginsBailResult0(name)
  • applyPluginsBailResult1(name, param)
  • applyPluginsBailResult2(name, param1, param2 )
  • applyPluginsBailResult3(name, param1, param2, param3)
  • applyPluginsBailResult4(name, param1, param2, param3, param4)
  • applyPluginsBailResult5(name, param1, param2, param3, param4, param5)

异步执行:

  • applyPluginsAsync(name, ...params, callback)
  • applyPluginsAsyncSeries(name, ...params, callback)
  • applyPluginsAsyncSeries1(name, param, callback)
  • applyPluginsAsyncSeriesBailResult(name, ...params, callback)
  • applyPluginsAsyncSeriesBailResult1(name, param, callback)
  • applyPluginsAsyncWaterfall(name, init, ...params, callback)
  • applyPluginsParallel(name, ...params, callback)
  • applyPluginsParallelBailResult(name, ...params, callback)
  • applyPluginsParallelBailResult1(name, param, callback)

虽然上面的方法看起来很多,但从函数名就联想到函数的实际功能:

  • *Waterfall 的方法会将上一个监听器的执行结果传给下一个
  • *BailResult 的方法只会执行到第一个返回结果不为undefined的监听器
  • *Series 的方法会严格线性来执行异步监听器,只有上一个结束下一个才会开始
  • *Parallel 的方法会并行执行异步监听器
  • 函数名称最后如果带有数字,那么会按照实际的参数传给监听器。如果有数字,则严格按照数字来传递参数个数。

最后 Tapable 类还提供了一个方法 apply,它的作用是提供了外部插件注册任务点的统一接口,要求都在 apply 方法内部进行任务点注册逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let t = new Tapable()
let somePlugins = [{
apply(t) {
t.plugin("name", () => {
console.log("1")
return true
})
}
}, {
apply(t) {
t.plugin("name", () => {
console.log("2")
})
}
}]
t.apply(somePlugins)
t.applyPlugins("name") // 1 2
t.applyPluginsBailResult("name") // 1

webpack 中自定义插件就是调用 Compiler 实例对象(继承于 Tapable)的 apply 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack.config.js
module.exports = {
plugins: [{
apply(compiler) {
compiler.plugin("done", (stat) => {
console.log("it works!")
})
}
}]
}
// https://github.com/webpack/webpack/blob/master/lib/webpack.js
if(options.plugins && Array.isArray(options.plugins)) {
compiler.apply.apply(compiler, options.plugins);
}

webpack 源码中随处可以见 Tapable 的身影,在了解其工作原理对理解源码很有帮助。Compiler 继承了 Tapable,同时也作为构建的入口对象,下面我们来看一下。

Compiler

Compiler 是一个编译器实例,在 webpack 的每个进程中只会创建一个对象,它用来创建构建对象 Compilation,本身需要注意的属性和方法并不是很多。下面我们找几个主要的属性来说一下。

options属性

当 webpack 开始运行时,第一件事就是解析我们传入的配置,然后将配置赋值给 Compiler 实例:

1
2
3
4
5
// https://github.com/webpack/webpack/blob/master/lib/webpack.js#L37
compiler = new Compiler();
// 其他代码..
compiler.options = new WebpackOptionsApply().process(options, compiler);

因此,我们可以直接通过这个属性来获取到解析后的 webpack 配置:

1
2
3
4
5
6
7
8
class CustomPlugin {
constructor() {}
apply(compiler) {
compiler.plugin("run", (compiler) => {
console.log(compiler.options)
})
}
}

如果你不满足于官网给出的配置文档,想要了解更多配置解析,可以看看 WebpackOptionsDefaulter.js 这个文件,这里不再赘述。

输入输出

Compiler 实例在一开始也会初始化输入输出,分别是 inputFileSystem 和 outputFileSystem 属性,一般情况下这两个属性都是对应的 nodejs 中拓展后的 fs 对象。但是有一点要注意,当 Compiler 实例以 watch 模式运行时, outputFileSystem 会被重写成内存输出对象。也就是说,实际上在 watch 模式下,webpack 构建后的文件并不会生成真正的文件,而是保存在内存中。

我们可以使用 inputFileSystem 和 outputFileSystem 属性来帮助我们实现一些文件操作,如果你希望自定义插件的一些输入输出行为能够跟 webpack 尽量同步,那么最好使用 Compiler 提供的这两个变量:

1
2
3
4
5
6
7
8
9
class CustomPlugin {
constructor() {}
apply(compiler) {
compiler.outputFileSystem.mkdirp("/path/to/dir", (error) => {
compiler.outputFileSystem.writeFile("/path/to/file", "utf-8", (error) => {
})
})
}
}

webpack 的 inputFileSystem 会相对更复杂一点,它内部实现了一些缓存的机制,使得性能效率更高。如果对这部分有兴趣,可以从这个 NodeEnvironmentPlugin 插件开始看起,它是内部初始化了 inputFileSystem 和 outputFileSystem:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// https://github.com/webpack/webpack/blob/master/lib/node/NodeEnvironmentPlugin.js
class NodeEnvironmentPlugin {
apply(compiler) {
compiler.inputFileSystem = new CachedInputFileSystem(new NodeJsInputFileSystem(), 60000);
const inputFileSystem = compiler.inputFileSystem;
compiler.outputFileSystem = new NodeOutputFileSystem();
compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem);
compiler.plugin("before-run", (compiler, callback) => {
if(compiler.inputFileSystem === inputFileSystem)
inputFileSystem.purge();
callback();
});
}
}

创建子编译器

在第一篇文章讲解 Compilation 实例化的时候,有略微提及到创建子编译器的内容:

这里为什么会有 compilation 和 this-compilation 两个任务点?其实是跟子编译器有关,Compiler 实例通过 createChildCompiler 方法可以创建子编译器实例 childCompiler,创建时 childCompiler 会复制 compiler 实例的任务点监听器。任务点 compilation 的监听器会被复制,而任务点 this-compilation 的监听器不会被复制。 更多关于子编译器的内容,将在其他文章中讨论。

这里我们来仔细看一下子编译器是如何创建的,Compiler 实例通过 createChildCompiler 的方法来创建:

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
// https://github.com/webpack/webpack/blob/master/lib/Compiler.js
class Compiler extends Tapable {
// 其他代码..
createChildCompiler(compilation, compilerName, compilerIndex, outputOptions, plugins) {
const childCompiler = new Compiler();
if(Array.isArray(plugins)) {
plugins.forEach(plugin => childCompiler.apply(plugin));
}
for(const name in this._plugins) {
if(["make", "compile", "emit", "after-emit", "invalid", "done", "this-compilation"].indexOf(name) < 0)
childCompiler._plugins[name] = this._plugins[name].slice();
}
childCompiler.name = compilerName;
childCompiler.outputPath = this.outputPath;
childCompiler.inputFileSystem = this.inputFileSystem;
childCompiler.outputFileSystem = null;
childCompiler.resolvers = this.resolvers;
childCompiler.fileTimestamps = this.fileTimestamps;
childCompiler.contextTimestamps = this.contextTimestamps;
const relativeCompilerName = makePathsRelative(this.context, compilerName);
if(!this.records[relativeCompilerName]) this.records[relativeCompilerName] = [];
if(this.records[relativeCompilerName][compilerIndex])
childCompiler.records = this.records[relativeCompilerName][compilerIndex];
else
this.records[relativeCompilerName].push(childCompiler.records = {});
childCompiler.options = Object.create(this.options);
childCompiler.options.output = Object.create(childCompiler.options.output);
for(const name in outputOptions) {
childCompiler.options.output[name] = outputOptions[name];
}
childCompiler.parentCompilation = compilation;
compilation.applyPlugins("child-compiler", childCompiler, compilerName, compilerIndex);
return childCompiler;
}
}

上面的代码看起来很多,但其实主要逻辑基本都是在拷贝父编译器的属性到子编译器上面。值得注意的一点是第10 - 13行,子编译器在拷贝父编译器的任务点时,会过滤掉make, compile, emit, after-emit, invalid, done, this-compilation这些任务点。

如果你阅读过第一篇文章(如果没有,推荐先看一下),应该会知道上面任务点在整个构建流程中的位置。从这里我们也可以看出来,子编译器跟父编译器的一个差别在于,子编译器并没有完整的构建流程。 比如子编译器没有文件生成阶段(emit任务点),它的文件生成必须挂靠在父编译器下面来实现。

另外需要注意的是,子编译器的运行入口并非 run 方法 ,而是有单独的 runAsChild 方法来运行,从代码上面也能够直接看出来,它马上调用了 compile 方法,跳过了 run, make等任务点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// https://github.com/webpack/webpack/blob/master/lib/Compiler.js
class Compiler extends Tapable {
// 其他代码..
runAsChild(callback) {
this.compile((err, compilation) => {
if(err) return callback(err);
this.parentCompilation.children.push(compilation);
Object.keys(compilation.assets).forEach(name => {
this.parentCompilation.assets[name] = compilation.assets[name];
});
const entries = Object.keys(compilation.entrypoints).map(name => {
return compilation.entrypoints[name].chunks;
}).reduce((array, chunks) => {
return array.concat(chunks);
}, []);
return callback(null, entries, compilation);
});
}
}

那么子编译器有什么作用呢?从上面功能和流程来看,子编译器仍然拥有完整的模块解析和chunk生成阶段。 也就是说我们可以利用子编译器来独立(于父编译器)跑完一个核心构建流程,额外生成一些需要的模块或者chunk。

事实上一些外部的 webpack 插件就是这么做的,比如常用的插件 html-webpack-plugin 中,就是利用子编译器来独立完成 html 文件的构建,为什么不能直接读取 html 文件?因为 html 文件中可能依赖其他外部资源(比如 img 的src属性),所以加载 html 文件时仍然需要一个额外的完整的构建流程来完成这个任务,子编译器的作用在这里就体现出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// https://github.com/jantimon/html-webpack-plugin/blob/master/lib/compiler.js#L46
var compilerName = getCompilerName(context, outputFilename);
var childCompiler = compilation.createChildCompiler(compilerName, outputOptions);
childCompiler.context = context;
childCompiler.apply(
new NodeTemplatePlugin(outputOptions),
new NodeTargetPlugin(),
new LibraryTemplatePlugin('HTML_WEBPACK_PLUGIN_RESULT', 'var'),
new SingleEntryPlugin(this.context, template), // html文件作为 entry
new LoaderTargetPlugin('node')
);
// 其他代码..
childCompiler.runAsChild(function (err, entries, childCompilation) {
// 其他代码..
// childCompilation.assets 属性可以拿到构建后的文件代码
})

在下一篇文章中我们将亲自实现一个插件,关于子编译器的具体实践到时再继续讨论。

Compilation

接下来我们来看看最重要的 Compilation 对象,在上一篇文章中,我们已经说明过部分属性了,比如我们简单回顾一下

  • modules 记录了所有解析后的模块
  • chunks 记录了所有chunk
  • assets记录了所有要生成的文件

上面这三个属性已经包含了 Compilation 对象中大部分的信息,但是我们也只是有个大致的概念,特别是 modules 中每个模块实例到底是什么东西,我们并不太清楚。所以下面的内容将会比较细地讲解。

但如果你对这部分内容不感兴趣也可以直接跳过,因为能真正使用的场景不会太多,但它能加深对 webpack 构建的理解。

所谓的模块

Compilation 在解析过程中,会将解析后的模块记录在 modules 属性中,那么每一个模块实例又是什么呢?

首先我们先回顾一下最开始的类图,我们会发现跟模块相关的类非常多,看起来类之间的关系也十分复杂,但其实只要记住下面的公式就很好理解:

依赖和模块的关系

这个公式的解读是: 一个依赖对象(Dependency)经过对应的工厂对象(Factory)创建之后,就能够生成对应的模块实例(Module)。

首先什么是 Dependency?
我个人的理解是,还未被解析成模块实例的依赖对象。比如我们运行 webpack 时传入的入口模块,或者一个模块依赖的其他模块,都会先生成一个 Dependency 对象。作为基类的 Dependency 十分简单,内部只有一个 module 属性来记录最终生成的模块实例。但是它的派生类非常多,webpack 中有单独的文件夹(webpack/lib/dependencies)来存放所有的派生类,这里的每一个派生类都对应着一种依赖的场景。比如从 CommonJS 中require一个模块,那么会先生成 CommonJSRequireDependency。

有了 Dependency 之后,如何找到对应的工厂对象呢?
Dependecy 的每一个派生类在使用前,都会先确定对应的工厂对象,比如 SingleEntryDependency 对应的工厂对象是 NormalModuleFactory。这些信息全部是记录在 Compilation 对象的 dependencyFactories 属性中,这个属性是 ES6 中的 Map 对象。直接看下面的代码可能更容易理解:

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
// https://github.com/webpack/webpack/blob/master/lib/SingleEntryPlugin.js
class SingleEntryPlugin {
apply(compiler) {
compiler.plugin("compilation", (compilation, params) => {
const normalModuleFactory = params.normalModuleFactory;
// 这里记录了 SingleEntryDependency 对应的工厂对象是 NormalModuleFactory
compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory);
});
compiler.plugin("make", (compilation, callback) => {
// 入口的模块会先变成一个 Dependency 对象
const dep = SingleEntryPlugin.createDependency(this.entry, this.name);
compilation.addEntry(this.context, dep, this.name, callback);
});
}
}
// https://github.com/webpack/webpack/blob/master/lib/Compilation.js
class Compilation extends Tapable {
_addModuleChain(context, dependency, onModule, callback) {
// 其他代码..
// 开始构建时,通过 Compilation 的 dependenciesFactories 属性找到对应的工厂对象
const moduleFactory = this.dependencyFactories.get(dependency.constructor);
if(!moduleFactory) {
throw new Error(`No dependency factory available for this dependency type: ${dependency.constructor.name}`);
}
this.semaphore.acquire(() => {
// 调用工厂对象的 create 方法,dependency作为参数传入,最终生成模块实例
moduleFactory.create({
contextInfo: {
issuer: "",
compiler: this.compiler.name
},
context: context,
dependencies: [dependency] // 作为参数传入
}, (err, module) => {
// module就是生成的模块实例
// 其他代码..
})
})
}
}

一种工厂对象只会生成一种模块,所以不同的模块实例都会有不同的工厂对象来生成。模块的生成过程我们在第一篇文章有讨论过,无非就是解析模块的 request,loaders等信息然后实例化。

模块对象有哪些特性呢?
同样在第一篇文章中,我们知道一个模块在实例化之后并不意味着构建就结束了,它还有一个内部构建的过程。所有的模块实例都有一个 build 方法,这个方法的作用是开始加载模块源码(并应用loaders),并且通过 js 解析器来完成依赖解析。这里要两个点要注意:

  1. 模块源码最终是保存在 _source 属性中,可以通过 _source.source() 来得到。注意在 build 之前 _source 是不存在的。
  2. js 解析器解析之后会记录所有的模块依赖,这些依赖其实会分为三种,分别记录在 variables,dependencies, blocks属性。模块构建之后的递归构建过程,其实就是读取这三个属性来重复上面的过程:依赖 => 工厂 => 模块

我们再来看看这些模块类,从前面的类图看,它们是继承于 Module 类。这个类实际上才是我们平常用来跟 chunk 打交道的类对象,它内部有 _chunks 属性来记录后续所在的 chunk 信息,并且提供了很多相关的方法来操作这个对象:addChunk,removeChunk,isInChunk,mapChunks等。后面我们也会看到,Chunk 类与之对应。

Module 类往上还会继承于 DependenciesBlock,这个是所有模块的基类,它包含了处理依赖所需要的属性和方法。上面所说的 variables,dependencies,blocks 也是这个基类拥有的三个属性。它们分别是:

  • variables 对应需要对应的外部变量,比如 __filename,__dirname,process 等node环境下特有的变量
  • dependencies 对应需要解析的其他普通模块,比如 require("./a") 中的 a 模块会先生成一个 CommonJSRequireDependency
  • blocks 对应需要解析的代码块(最终会对应成一个 chunk),比如 require.ensure("./b"),这里的 b 会生成一个 DependenciesBlock 对象

经过上面的讨论之后,我们基本将 webpack 中于模块相关的对象、概念都涉及到了,剩下还有模块渲染相关的模板,会在下面描述 Template 时继续讨论。

Chunk

讨论完 webpack 的模块之后,下面需要说明的是 Chunk 对象。关于 chunk 的生成,在第一篇文章中有涉及,这里不再赘述。
chunk 只有一个相关类,而且并不复杂。Chunk 类内部的主要属性是 _modules,用来记录包含的所有模块对象,并且提供了很多方法来操作:addModule,removeModule,mapModules 等。
另外有几个方法可能比较实用,这里也列出来:

  • integrate 用来合并其他chunk
  • split 用来生成新的子 chunk
  • hasRuntime 判断是否是入口 chunk
    其他关于 chunk 的内容,有兴趣的同学可以直接查看源码。

Template

Compilation 实例在生成最终文件时,需要将所有的 chunk 渲染(生成代码)出来,这个时候需要用到下面几个属性:

  • mainTemplate 对应 MainTemplate 类,用来渲染入口 chunk
  • chunkTemplate 对应 ChunkTemplate 类,用来传染非入口 chunk
  • moduleTemplate 对应 ModuleTemplate,用来渲染 chunk 中的模块
  • dependencyTemplates 记录每一个依赖类对应的模板

在第一篇文章时,有略微描述过 chunk 渲染的过程,这里再仔细地过一遍,看看这几个属性是如何应用在渲染过程中的:

首先 chunk 的渲染入口是 mainTemplate 和 chunkTemplate 的 render 方法。根据 chunk 是否是入口 chunk 来区分使用哪一个:

1
2
3
4
5
6
7
// https://github.com/webpack/webpack/blob/master/lib/Compilation.js
if(chunk.hasRuntime()) { // 入口chunk
source = this.mainTemplate.render(this.hash, chunk, this.moduleTemplate, this.dependencyTemplates);
} else {
source = this.chunkTemplate.render(chunk, this.moduleTemplate, this.dependencyTemplates);
}

两个类的 render 方法将生成不同的”包装代码”,MainTemplate 对应的入口 chunk 需要带有 webpack 的启动代码,所以会有一些函数的声明和启动。
这两个类都只负责这些”包装代码”的生成,包装代码中间的每个模块代码,是通过调用 renderChunkModules 方法来生成的。这里的 renderChunkModules 是由他们的基类 Template 类提供,方法会遍历 chunk 中的模块,然后使用 ModuleTemplate 来渲染。

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
// https://github.com/webpack/webpack/blob/master/lib/MainTemplate.js
// MainTemplate的部分render方法:
const source = new ConcatSource();
source.add("/******/ (function(modules) { // webpackBootstrap\n");
source.add(new PrefixSource("/******/", bootstrapSource));
source.add("/******/ })\n");
source.add("/************************************************************************/\n");
source.add("/******/ (");
// 调用 renderChunkModules 方法,遍历这个 chunk 的所有模块生成对应的代码
const modules = this.renderChunkModules(chunk, moduleTemplate, dependencyTemplates, "/******/ ");
source.add(this.applyPluginsWaterfall("modules", modules, chunk, hash, moduleTemplate, dependencyTemplates));
source.add(")");
// https://github.com/webpack/webpack/blob/master/lib/Template.js
module.exports = class Template extends Tapable {
// 其他代码..
renderChunkModules(chunk, moduleTemplate, dependencyTemplates, prefix) {
// 其他代码..
var allModules = chunk.mapModules(function(module) {
return {
id: module.id,
// 调用 moduleTemplate.render 方法
source: moduleTemplate.render(module, dependencyTemplates, chunk)
};
});
// 其他代码..
}
}

ModuleTemplate 做的事情跟 MainTemplate 类似,它同样只是生成”包装代码”来封装真正的模块代码,而真正的模块代码,是通过模块实例的 source 方法来提供。该方法会先读取 _source 属性,即模块内部构建时应用loaders之后生成的代码,然后使用 dependencyTemplates 来更新模块源码。

dependencyTemplates 是 Compilation 对象的一个属性,它跟 dependencyFactories 同样是个 Map 对象,记录了所有的依赖类对应的模板类。

上面用文字来描述这个过程可能十分难懂,所以我们直接看实际的例子。比如下面这个文件:

1
let a = require("./a")

我们来看看使用 webpack 构建后最终的文件:

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
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
var a = __webpack_require__(1);
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
console.log("a");
/***/ })
/******/ ]);

其中,从 1-66 行都是 MainTemplate 生成的启动代码,剩余的代码生成如下图所示:
代码生成示例

总结

通过这篇文章,我们将 webpack 中的一些核心概念和对象都进行了不同程度的讨论,对于 webpack 中有哪些对象可以操作有了一定的认识。下一篇文章将手动写一个 webpack 插件,来验证这两篇文章的讨论。
最后再次说明,本文内容是由个人理解和整理,如果有不正确的地方欢迎大家指正。如果需要转载,请注明出处。

玩转webpack(一):webpack的基本架构和构建流程

发表于 2017-11-02

前言

webpack 是一个强大的模块打包工具,之所以强大的一个原因在于它拥有灵活、丰富的插件机制。但是 webpack 的文档不太友好,就个人的学习经历来说,官方的文档并不详细,网上的学习资料又少有完整的概述和例子。所以,在研究了一段时间的 webpack 源码之后,自己希望写个系列文章,结合自己的实践一起来谈谈 webpack 插件这个主题,也希望能够帮助其他人更全面地了解 webpack。

这篇文章是系列文章的第一篇,将会讲述 webpack 的基本架构以及构建流程。如果有描述不正确的地方,欢迎大家指正。

如果你对其他主题感兴趣,也可以直接阅读:玩转webpack(二):webpack的核心对象

P.S. 以下的分析都基于 webpack 3.6.0
P.S. 本文首发在部门的公众号,点击查看公众号文章,欢迎关注:小时光茶社。

webpack的基本架构

webpack 的基本架构,是基于一种类似事件的方式。下面的代码中,对象可以使用 plugin 函数来注册一个事件,暂时可以理解为我们熟悉的 addEventListener。但为了区分概念,后续的讨论中会将事件名称为 任务点,比如下面有四个任务点 compilation, optimize, compile, before-resolve:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
compiler.plugin("compilation", (compilation, callback) => {
// 当Compilation实例生成时
compilation.plugin("optimize", () => {
// 当所有modules和chunks已生成,开始优化时
})
})
compiler.plugin("compile", (params) => {
// 当编译器开始编译模块时
let nmf = params.normalModuleFactory
nmf.plugin("before-resolve", (data) => {
// 在factory开始解析模块前
})
})

webpack 内部的大部分功能,都是通过这种注册任务点的形式来实现的,这在后面中我们很容易发现这一点。所以这里直接抛出结论:webpack 的核心功能,是抽离成很多个内部插件来实现的。
那这些内部插件是如何对 webpack 产生作用的呢?在我们开始运行 webpack 的时候,它会先创建一个 Compiler 实例,然后调用 WebpackOptionsApply 这个模块给 Compiler 实例添加内部插件:

1
2
3
4
5
// https://github.com/webpack/webpack/blob/master/lib/webpack.js#L37
compiler = new Compiler();
// 其他代码..
compiler.options = new WebpackOptionsApply().process(options, compiler);

在 WebpackOptionsApply 这个插件内部会根据我们传入的 webpack 配置来初始化需要的内部插件:

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
// https://github.com/webpack/webpack/blob/master/lib/WebpackOptionsApply.js
JsonpTemplatePlugin = require("./JsonpTemplatePlugin");
NodeSourcePlugin = require("./node/NodeSourcePlugin");
compiler.apply(
new JsonpTemplatePlugin(options.output),
new FunctionModulePlugin(options.output),
new NodeSourcePlugin(options.node),
new LoaderTargetPlugin(options.target)
);
// 其他代码..
compiler.apply(new EntryOptionPlugin());
compiler.applyPluginsBailResult("entry-option", options.context, options.entry);
compiler.apply(
new CompatibilityPlugin(),
new HarmonyModulesPlugin(options.module),
new AMDPlugin(options.module, options.amd || {}),
new CommonJsPlugin(options.module),
new LoaderPlugin(),
new NodeStuffPlugin(options.node),
new RequireJsStuffPlugin(),
new APIPlugin(),
new ConstPlugin(),
new UseStrictPlugin(),
new RequireIncludePlugin(),
new RequireEnsurePlugin(),
new RequireContextPlugin(options.resolve.modules, options.resolve.extensions, options.resolve.mainFiles),
new ImportPlugin(options.module),
new SystemPlugin(options.module)
);

每一个内部插件,都是通过监听任务点的方式,来实现自定义的逻辑。比如 JsonpTemplatePlugin 这个插件,是通过监听 mainTemplate 对象的 require-ensure 任务点,来生成 jsonp 风格的代码:

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
// https://github.com/webpack/webpack/blob/master/lib/JsonpTemplatePlugin.js
mainTemplate.plugin("require-ensure", function(_, chunk, hash) {
return this.asString([
"var installedChunkData = installedChunks[chunkId];",
"if(installedChunkData === 0) {",
this.indent([
"return new Promise(function(resolve) { resolve(); });"
]),
"}",
"",
"// a Promise means \"currently loading\".",
"if(installedChunkData) {",
this.indent([
"return installedChunkData[2];"
]),
"}",
"",
"// setup Promise in chunk cache",
"var promise = new Promise(function(resolve, reject) {",
this.indent([
"installedChunkData = installedChunks[chunkId] = [resolve, reject];"
]),
"});",
"installedChunkData[2] = promise;",
"",
"// start chunk loading",
"var head = document.getElementsByTagName('head')[0];",
this.applyPluginsWaterfall("jsonp-script", "", chunk, hash),
"head.appendChild(script);",
"",
"return promise;"
]);
});

现在我们理解了 webpack 的基本架构之后,可能会产生疑问,每个插件应该监听哪个对象的哪个任务点,又如何对实现特定功能呢?

要完全解答这个问题很难,原因在于 webpack 中构建过程中,会涉及到非常多的对象和任务点,要对每个对象和任务点都进行讨论是很困难的。但是,我们仍然可以挑选完整构建流程中涉及到的几个核心对象和任务点,把 webpack 的构建流程讲清楚,当我们需要实现某个特定内容的时候,再去找对应的模块源码查阅任务点。

那么下面我们就来聊一聊 webpack 的构建流程。

webpack的构建流程

为了更清楚和方便地讨论构建流程,这里按照个人理解整理了 webpack 构建流程中比较重要的几个对象以及对应的任务点,并且按照构建顺序画出了流程图:

  • 图中每一列顶部名称表示该列中任务点所属的对象
  • 图中每一行表示一个阶段
  • 图中每个节点表示任务点名称
  • 图中每个节点括号表示任务点的参数,参数带有callback是异步任务点
  • 图中的箭头表示任务点的执行顺序
  • 图中虚线表示存在循环流程

webpack流程图

上面展示的只是 webpack 构建的一部分,比如与 Module 相关的对象只画出了 NormalModuleFactory,与 Template 相关的对象也只画出了 MainTemplate等。原因在于上面的流程图已经足以说明主要的构建步骤,另外有没画出来的对象和任务点跟上述的类似,比如 ContextModuleFactory 跟 NormalModuleFactory 是十分相似的对象,也有相似的任务点。有兴趣的同学可以自行拓展探索流程图。

流程图中已经展示了一些核心任务点对应的对象以及触发顺序,但是我们仍然不明白这些任务点有什么含义。所以剩下的内容会详细讲解 webpack 一些任务点详细的动作,按照个人理解将流程图分成了水平的三行,表示三个阶段,分别是:

  1. webpack的准备阶段
  2. modules和chunks的生成阶段
  3. 文件生成阶段

webpack的准备阶段

这个阶段的主要工作,是创建 Compiler 和 Compilation 实例。

首先我们从 webpack 的运行开始讲起,在前面我们大概地讲过,当我们开始运行 webpack 的时候,就会创建 Compiler 实例并且加载内部插件。这里跟构建流程相关性比较大的内部插件是 EntryOptionPlugin,我们来看看它到底做了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// https://github.com/webpack/webpack/blob/master/lib/WebpackOptionsApply.js
compiler.apply(new EntryOptionPlugin());
compiler.applyPluginsBailResult("entry-option", options.context, options.entry); // 马上触发任务点运行 EntryOptionPlugin 内部逻辑
// https://github.com/webpack/webpack/blob/master/lib/EntryOptionPlugin.js
module.exports = class EntryOptionPlugin {
apply(compiler) {
compiler.plugin("entry-option", (context, entry) => {
if(typeof entry === "string" || Array.isArray(entry)) {
compiler.apply(itemToPlugin(context, entry, "main"));
} else if(typeof entry === "object") {
Object.keys(entry).forEach(name => compiler.apply(itemToPlugin(context, entry[name], name)));
} else if(typeof entry === "function") {
compiler.apply(new DynamicEntryPlugin(context, entry));
}
return true;
});
}
};

EntryOptionPlugin 的代码只有寥寥数行但是非常重要,它会解析传给 webpack 的配置中的 entry 属性,然后生成不同的插件应用到 Compiler 实例上。这些插件可能是 SingleEntryPlugin, MultiEntryPlugin 或者 DynamicEntryPlugin。但不管是哪个插件,内部都会监听 Compiler 实例对象的 make 任务点,以 SingleEntryPlugin 为例:

1
2
3
4
5
6
7
8
9
10
11
12
// https://github.com/webpack/webpack/blob/master/lib/SingleEntryPlugin.js
class SingleEntryPlugin {
// 其他代码..
apply(compiler) {
// 其他代码..
compiler.plugin("make", (compilation, callback) => {
const dep = SingleEntryPlugin.createDependency(this.entry, this.name);
compilation.addEntry(this.context, dep, this.name, callback);
});
}
}

这里的 make 任务点将成为后面解析 modules 和 chunks 的起点。

除了 EntryOptionPlugin,其他的内部插件也会监听特定的任务点来完成特定的逻辑,但我们这里不再仔细讨论。当 Compiler 实例加载完内部插件之后,下一步就会直接调用 compiler.run 方法来启动构建,任务点 run 也是在此时触发,值得注意的是此时基本只有 options 属性是解析完成的:

1
2
3
4
5
// 监听任务点 run
compiler.plugin("run", (compiler, callback) => {
console.log(compiler.options) // 可以看到解析后的配置
callback()
})

另外要注意的一点是,任务点 run 只有在 webpack 以正常模式运行的情况下会触发,如果我们以监听(watch)的模式运行 webpack,那么任务点 run 是不会触发的,但是会触发任务点 watch-run。

接下来, Compiler 对象会开始实例化两个核心的工厂对象,分别是 NormalModuleFactory 和 ContextModuleFactory。工厂对象顾名思义就是用来创建实例的,它们后续用来创建 NormalModule 以及 ContextModule 实例,这两个工厂对象会在任务点 compile 触发时传递过去,所以任务点 compile 是间接监听这两个对象的任务点的一个入口:

1
2
3
4
5
6
7
// 监听任务点 compile
compiler.plugin("compile", (params) => {
let nmf = params.normalModuleFactory
nmf.plugin("before-resolve", (data, callback) => {
// ...
})
})

下一步 Compiler 实例将会开始创建 Compilation 对象,这个对象是后续构建流程中最核心最重要的对象,它包含了一次构建过程中所有的数据。也就是说一次构建过程对应一个 Compilation 实例。在创建 Compilation 实例时会触发任务点 compilaiion 和 this-compilation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// https://github.com/webpack/webpack/blob/master/lib/Compiler.js
class Compiler extends Tapable {
// 其他代码..
newCompilation(params) {
const compilation = this.createCompilation();
compilation.fileTimestamps = this.fileTimestamps;
compilation.contextTimestamps = this.contextTimestamps;
compilation.name = this.name;
compilation.records = this.records;
compilation.compilationDependencies = params.compilationDependencies;
this.applyPlugins("this-compilation", compilation, params);
this.applyPlugins("compilation", compilation, params);
return compilation;
}
}

这里为什么会有 compilation 和 this-compilation 两个任务点?其实是跟子编译器有关,Compiler 实例通过 createChildCompiler 方法可以创建子编译器实例 childCompiler,创建时 childCompiler 会复制 compiler 实例的任务点监听器。任务点 compilation 的监听器会被复制,而任务点 this-compilation 的监听器不会被复制。 更多关于子编译器的内容,将在下一篇文章中讨论。

compilation 和 this-compilation 是最快能够获取到 Compilation 实例的任务点,如果你的插件功能需要尽早对 Compilation 实例进行一些操作,那么这两个任务点是首选:

1
2
3
4
5
6
// 监听 this-compilation 任务点
compiler.plugin("this-compilation", (compilation, params) => {
console.log(compilation.options === compiler.options) // true
console.log(compilation.compiler === compiler) // true
console.log(compilation)
})

当 Compilation 实例创建完成之后,webpack 的准备阶段已经完成,下一步将开始 modules 和 chunks 的生成阶段。

modules 和 chunks 的生成阶段

这个阶段的主要内容,是先解析项目依赖的所有 modules,再根据 modules 生成 chunks。
module 解析,包含了三个主要步骤:创建实例、loaders应用以及依赖收集。
chunks 生成,主要步骤是找到 chunk 所需要包含的 modules。

当上一个阶段完成之后,下一个任务点 make 将被触发,此时内部插件 SingleEntryPlugin, MultiEntryPlugin, DynamicEntryPlugin的监听器会开始执行。监听器都会调用 Compilation 实例的 addEntry 方法,该方法将会触发第一批 module 的解析,这些 module 就是 entry 中配置的模块。

我们先讲一个 module 解析完成之后的操作,它会递归调用它所依赖的 modules 进行解析,所以当解析停止时,我们就能够得到项目中所有依赖的 modules,它们将存储在 Compilation 实例的 modules 属性中,并触发任务点 finish-modules:

1
2
3
4
5
6
7
8
9
// 监听 finish-modules 任务点
compiler.plugin("this-compilation", (compilation) => {
compilation.plugin("finish-modules", (modules) => {
console.log(modules === compilation.modules) // true
modules.forEach(module => {
console.log(module._source.source()) // 处理后的源码
})
})
})

下面将以 NormalModule 为例讲解一下 module 的解析过程,ContextModule 等其他模块实例的处理是类似的。
第一个步骤是创建 NormalModule 实例。这里需要用到上一个阶段讲到的 NormalModuleFactory 实例, NormalModuleFactory 的 create 方法是创建 NormalModule 实例的入口,内部的主要过程是解析 module 需要用到的一些属性,比如需要用到的 loaders, 资源路径 resource 等等,最终将解析完毕的参数传给 NormalModule 构建函数直接实例化:

1
2
3
4
5
6
7
8
9
10
11
12
// https://github.com/webpack/webpack/blob/master/lib/NormalModuleFactory.js
// 以 require("raw-loader!./a") 为例
// 并且对 .js 后缀配置了 babel-loader
createdModule = new NormalModule(
result.request, // <raw-loader>!<babel-loader>!/path/to/a.js
result.userRequest, // <raw-loader>!/path/to/a.js
result.rawRequest, // raw-loader!./a.js
result.loaders, // [<raw-loader>, <babel-loader>]
result.resource, // /path/to/a.js
result.parser
);

这里在解析参数的过程中,有两个比较实用的任务点 before-resolve 和 after-resolve,分别对应了解析参数前和解析参数后的时间点。举个例子,在任务点 before-resolve 可以做到忽略某个 module 的解析,webpack 内部插件 IgnorePlugin 就是这么做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// https://github.com/webpack/webpack/blob/master/lib/IgnorePlugin.js
class IgnorePlugin {
checkIgnore(result, callback) {
// check if result is ignored
if(this.checkResult(result)) {
return callback(); // callback第二个参数为 undefined 时会终止module解析
}
return callback(null, result);
}
apply(compiler) {
compiler.plugin("normal-module-factory", (nmf) => {
nmf.plugin("before-resolve", this.checkIgnore);
});
compiler.plugin("context-module-factory", (cmf) => {
cmf.plugin("before-resolve", this.checkIgnore);
});
}
}

在创建完 NormalModule 实例之后会调用 build 方法继续进行内部的构建。我们熟悉的 loaders 将会在这里开始应用,NormalModule 实例中的 loaders 属性已经记录了该模块需要应用的 loaders。应用 loaders 的过程相对简单,直接调用loader-runner 这个模块即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// https://github.com/webpack/webpack/blob/master/lib/NormalModule.js
const runLoaders = require("loader-runner").runLoaders;
// 其他代码..
class NormalModule extends Module {
// 其他代码..
doBuild(options, compilation, resolver, fs, callback) {
this.cacheable = false;
const loaderContext = this.createLoaderContext(resolver, options, compilation, fs);
runLoaders({
resource: this.resource,
loaders: this.loaders,
context: loaderContext,
readResource: fs.readFile.bind(fs)
}, (err, result) => {
// 其他代码..
});
}
}

webpack 中要求 NormalModule 最终都是 js 模块,所以 loader 的作用之一是将不同的资源文件转化成 js 模块。比如 html-loader 是将 html 转化成一个 js 模块。在应用完 loaders 之后,NormalModule 实例的源码必然就是 js 代码,这对下一个步骤很重要。

下一步我们需要得到这个 module 所依赖的其他模块,所以就有一个依赖收集的过程。webpack 的依赖收集过程是将 js 源码传给 js parser(webpack 使用的 parser 是 acorn):

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
// https://github.com/webpack/webpack/blob/master/lib/NormalModule.js
class NormalModule extends Module {
// 其他代码..
build(options, compilation, resolver, fs, callback) {
// 其他代码..
return this.doBuild(options, compilation, resolver, fs, (err) => {
// 其他代码..
try {
this.parser.parse(this._source.source(), {
current: this,
module: this,
compilation: compilation,
options: options
});
} catch(e) {
const source = this._source.source();
const error = new ModuleParseError(this, source, e);
this.markModuleAsErrored(error);
return callback();
}
return callback();
});
}
}

parser 将 js 源码解析后得到对应的AST(抽象语法树, Abstract Syntax Tree)。然后 webpack 会遍历 AST,按照一定规则触发任务点。 比如 js 源码中有一个表达式:a.b.c,那么 parser 对象就会触发任务点 expression a.b.c。更多相关的规则 webpack 在官网有罗列出来,大家可以对照着使用。

有了AST对应的任务点,依赖收集就相对简单了,比如遇到任务点 call require,说明在代码中是有调用了require函数,那么就应该给 module 添加新的依赖。webpack 关于这部分的处理是比较复杂的,因为 webpack 要兼容多种不同的依赖方式,比如 AMD 规范、CommonJS规范,然后还要区分动态引用的情况,比如使用了 require.ensure, require.context。但这些细节对于我们讨论构建流程并不是必须的,因为不展开细节讨论。

当 parser 解析完成之后,module 的解析过程就完成了。每个 module 解析完成之后,都会触发 Compilation 实例对象的任务点 succeed-module,我们可以在这个任务点获取到刚解析完的 module 对象。正如前面所说,module 接下来还要继续递归解析它的依赖模块,最终我们会得到项目所依赖的所有 modules。此时任务点 make 结束。

继续往下走,Compialtion 实例的 seal 方法会被调用并马上触发任务点 seal。在这个任务点,我们可以拿到所有解析完成的 module:

1
2
3
4
5
6
7
// 监听 seal 任务点
compiler.plugin("this-compilation", (compilation) => {
console.log(compilation.modules.length === 0) // true
compilation.plugin("seal", () => {
console.log(compilation.modules.length > 0) // true
})
})

有了所有的 modules 之后,webpack 会开始生成 chunks。webpack 中的 chunk 概念,要不就是配置在 entry 中的模块,要不就是动态引入(比如 require.ensure)的模块。这些 chunk 对象是 webpack 生成最终文件的一个重要依据。

每个 chunk 的生成就是找到需要包含的 modules。这里大致描述一下 chunk 的生成算法:

  1. webpack 先将 entry 中对应的 module 都生成一个新的 chunk
  2. 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中
  3. 如果一个依赖 module 是动态引入的模块,那么就会根据这个 module 创建一个新的 chunk,继续遍历依赖
  4. 重复上面的过程,直至得到所有的 chunks

在所有 chunks 生成之后,webpack 会对 chunks 和 modules 进行一些优化相关的操作,比如分配id、排序等,并且触发一系列相关的任务点:

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
// https://github.com/webpack/webpack/blob/master/lib/Compilation.js
class Compilation extends Tapable {
// 其他代码 ..
seal(callback) {
// 生成 chunks 代码..
self.applyPlugins0("optimize");
while(self.applyPluginsBailResult1("optimize-modules-basic", self.modules) ||
self.applyPluginsBailResult1("optimize-modules", self.modules) ||
self.applyPluginsBailResult1("optimize-modules-advanced", self.modules)) { /* empty */ }
self.applyPlugins1("after-optimize-modules", self.modules);
while(self.applyPluginsBailResult1("optimize-chunks-basic", self.chunks) ||
self.applyPluginsBailResult1("optimize-chunks", self.chunks) ||
self.applyPluginsBailResult1("optimize-chunks-advanced", self.chunks)) { /* empty */ }
self.applyPlugins1("after-optimize-chunks", self.chunks);
self.applyPluginsAsyncSeries("optimize-tree", self.chunks, self.modules, function sealPart2(err) {
if(err) {
return callback(err);
}
self.applyPlugins2("after-optimize-tree", self.chunks, self.modules);
while(self.applyPluginsBailResult("optimize-chunk-modules-basic", self.chunks, self.modules) ||
self.applyPluginsBailResult("optimize-chunk-modules", self.chunks, self.modules) ||
self.applyPluginsBailResult("optimize-chunk-modules-advanced", self.chunks, self.modules)) { /* empty */ }
self.applyPlugins2("after-optimize-chunk-modules", self.chunks, self.modules);
const shouldRecord = self.applyPluginsBailResult("should-record") !== false;
self.applyPlugins2("revive-modules", self.modules, self.records);
self.applyPlugins1("optimize-module-order", self.modules);
self.applyPlugins1("advanced-optimize-module-order", self.modules);
self.applyPlugins1("before-module-ids", self.modules);
self.applyPlugins1("module-ids", self.modules);
self.applyModuleIds();
self.applyPlugins1("optimize-module-ids", self.modules);
self.applyPlugins1("after-optimize-module-ids", self.modules);
self.sortItemsWithModuleIds();
self.applyPlugins2("revive-chunks", self.chunks, self.records);
self.applyPlugins1("optimize-chunk-order", self.chunks);
self.applyPlugins1("before-chunk-ids", self.chunks);
self.applyChunkIds();
self.applyPlugins1("optimize-chunk-ids", self.chunks);
self.applyPlugins1("after-optimize-chunk-ids", self.chunks);
// 其他代码..
})
}
}

这些任务点一般是 webpack.optimize 属性下的插件会使用到,比如 CommonsChunkPlugin 会使用到任务点 optimize-chunks,但这里我们不深入讨论。

至此,modules 和 chunks 的生成阶段结束。接下来是文件生成阶段。

文件生成阶段

这个阶段的主要内容,是根据 chunks 生成最终文件。主要有三个步骤:模板 hash 更新,模板渲染 chunk,生成文件

Compilation 在实例化的时候,就会同时实例化三个对象:MainTemplate, ChunkTemplate,ModuleTemplate。这三个对象是用来渲染 chunk 对象,得到最终代码的模板。第一个对应了在 entry 配置的入口 chunk 的渲染模板,第二个是动态引入的非入口 chunk 的渲染模板,最后是 chunk 中的 module 的渲染模板。

在开始渲染之前,Compilation 实例会调用 createHash 方法来生成这次构建的 hash。在 webpack 的配置中,我们可以在 output.filename 中配置 [hash] 占位符,最终就会替换成这个 hash。同样,createHash 也会为每一个 chunk 也创建一个 hash,对应 output.filename 的 [chunkhash] 占位符。

每个 hash 的影响因素比较多,首先三个模板对象会调用 updateHash 方法来更新 hash,在内部还会触发任务点 hash,传递 hash 到其他插件。 chunkhash 也是类似的原理:

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
// https://github.com/webpack/webpack/blob/master/lib/Compilation.js
class Compilation extends Tapable {
// 其他代码..
createHash() {
// 其他代码..
const hash = crypto.createHash(hashFunction);
if(outputOptions.hashSalt)
hash.update(outputOptions.hashSalt);
this.mainTemplate.updateHash(hash);
this.chunkTemplate.updateHash(hash);
this.moduleTemplate.updateHash(hash);
// 其他代码..
for(let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const chunkHash = crypto.createHash(hashFunction);
if(outputOptions.hashSalt)
chunkHash.update(outputOptions.hashSalt);
chunk.updateHash(chunkHash);
if(chunk.hasRuntime()) {
this.mainTemplate.updateHashForChunk(chunkHash, chunk);
} else {
this.chunkTemplate.updateHashForChunk(chunkHash, chunk);
}
this.applyPlugins2("chunk-hash", chunk, chunkHash);
chunk.hash = chunkHash.digest(hashDigest);
hash.update(chunk.hash);
chunk.renderedHash = chunk.hash.substr(0, hashDigestLength);
}
this.fullHash = hash.digest(hashDigest);
this.hash = this.fullHash.substr(0, hashDigestLength);
}
}

当 hash 都创建完成之后,下一步就会遍历 compilation.chunks 来渲染每一个 chunk。如果一个 chunk 是入口 chunk,那么就会调用 MainTemplate 实例的 render 方法,否则调用 ChunkTemplate 的 render 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// https://github.com/webpack/webpack/blob/master/lib/Compilation.js
class Compilation extends Tapable {
// 其他代码..
createChunkAssets() {
// 其他代码..
for(let i = 0; i < this.chunks.length; i++) {
const chunk = this.chunks[i];
// 其他代码..
if(chunk.hasRuntime()) {
source = this.mainTemplate.render(this.hash, chunk, this.moduleTemplate, this.dependencyTemplates);
} else {
source = this.chunkTemplate.render(chunk, this.moduleTemplate, this.dependencyTemplates);
}
file = this.getPath(filenameTemplate, {
noChunkHash: !useChunkHash,
chunk
});
this.assets[file] = source;
// 其他代码..
}
}
}

这里注意到 ModuleTemplate 实例会被传递下去,在实际渲染时将会用 ModuleTemplate 来渲染每一个 module,其实更多是往 module 前后添加一些”包装”代码,因为 module 的源码实际上是已经渲染完毕的(还记得前面的 loaders 应用吗?)。

MainTemplate 的渲染跟 ChunkTemplate 的不同点在于,入口 chunk 的源码中会带有启动 webpack 的代码,而非入口 chunk 的源码是不需要的。这个只要查看 webpack 构建后的文件就可以比较清楚地看到区别:

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
// 入口 chunk
/******/ (function(modules) { // webpackBootstrap
/******/ // install a JSONP callback for chunk loading
/******/ var parentJsonpFunction = window["webpackJsonp"];
/******/ window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
/******/ // add "moreModules" to the modules object,
/******/ // then flag all "chunkIds" as loaded and fire callback
/******/ var moduleId, chunkId, i = 0, resolves = [], result;
/******/ for(;i < chunkIds.length; i++) {
/******/ chunkId = chunkIds[i];
/******/ if(installedChunks[chunkId]) {
/******/ resolves.push(installedChunks[chunkId][0]);
/******/ }
/******/ installedChunks[chunkId] = 0;
/******/ }
/******/ for(moduleId in moreModules) {
/******/ if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
/******/ modules[moduleId] = moreModules[moduleId];
/******/ }
/******/ }
/******/ if(parentJsonpFunction) parentJsonpFunction(chunkIds, moreModules, executeModules);
/******/ while(resolves.length) {
/******/ resolves.shift()();
/******/ }
/******/
/******/ };
/******/ // 其他代码..
/******/ })(/* modules代码 */);
// 动态引入的 chunk
webpackJsonp([0],[
/* modules代码.. */
]);

当每个 chunk 的源码生成之后,就会添加在 Compilation 实例的 assets 属性中。

assets 对象的 key 是最终要生成的文件名称,因此这里要用到前面创建的 hash。调用 Compilation 实例内部的 getPath 方法会根据配置中的 output.filename 来生成文件名称。

assets 对象的 value 是一个对象,对象需要包含两个方法,source 和 size 分别返回文件内容和文件大小。

当所有的 chunk 都渲染完成之后,assets 就是最终更要生成的文件列表。此时 Compilation 实例还会触发几个任务点,例如 addtional-chunk-assets,addintial-assets等,在这些任务点可以修改 assets 属性来改变最终要生成的文件。

完成上面的操作之后,Compilation 实例的 seal 方法结束,进入到 Compiler 实例的 emitAssets 方法。Compilation 实例的所有工作到此也全部结束,意味着一次构建过程已经结束,接下来只有文件生成的步骤。

在 Compiler 实例开始生成文件前,最后一个修改最终文件生成的任务点 emit 会被触发:

1
2
3
4
5
6
7
8
9
10
11
12
// 监听 emit 任务点,修改最终文件的最后机会
compiler.plugin("emit", (compilation, callback) => {
let data = "abcd"
compilation.assets["newFile.js"] = {
source() {
return data
}
size() {
return data.length
}
}
})

当任务点 emit 被触发之后,接下来 webpack 会直接遍历 compilation.assets 生成所有文件,然后触发任务点 done,结束构建流程。

总结

经过全文的讨论,我们将 webpack 的基本架构以及核心的构建流程都过了一遍,希望在阅读完全文之后,对大家了解 webpack 原理有所帮助。
最后再次说明,本文内容是由个人理解和整理,如果有不正确的地方欢迎大家指正。如果需要转载,请注明出处。

下一篇文章将会讲解 webpack 核心的对象,敬请期待。

一次js并行串行的思考

发表于 2017-10-29

几天前,组里有同事抛出一个问题:假设给定一组url,要求尽可能快得加载,然后按照顺序打印出结果,用js如何实现?

这个问题其实很简单,大家很快就给定了简单的思路:

  1. 因为要求尽可能快,所以要并行加载
  2. 因为要求按顺序打印结果,那么就要串行输出

按照思路,要如何优雅地实现这个效果呢?

组里大家给出的实现方式差别很大,因为我最近看的函数式编程比较多,当时马上就想到通过 promise 和 reduce 来完成。

这里将问题简化一下,请求url的异步任务换成简单地输出数字,当时给出的代码是这样:

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
let makePromise = (value) => {
console.log("sync", value)
return new Promise(resolve => {
setTimeout(() => {
console.log("async", value)
resolve(value)
}, Math.random() * 1000)
})
}
let print = (value) => {
console.log("print", value)
return value
}
let values = [1, 2, 3, 4]
let promises = values.map(value => makePromise(value)) // 这里就已经开始并行加载
let parallelPromises = promises.reduce(
(current, next) => current.then(() => next.then(print)),
Promise.resolve()
)
parallelPromises
.then(() => console.log("done"))
.catch(() => console.log("failed"))

上面的代码输出结果如下:

输出结果

实际输出结果受到Math.random的随机影响,但是print的输出一定是按顺序的

上面的代码里面,当我们调用map将数字映射成promise数组时,实际上就实现了并行加载。然后我们使用reduce以及promise.then的特性,强制要求输出必须在前一个promise完成后再执行,就实现了串行输出。

理性在纠结

理性在纠结

互联网搬砖小码农,欢迎一起讨论前端技术

3 日志
4 标签
GitHub 知乎
© 2017 理性在纠结   |   网站访问量:
由 Hexo 强力驱动
主题 - NexT.Muse