前端高手进阶 - 前中兴软创主任工程师 - 拉勾教育

Node.js 的出现,越来越多前端自动化工具涌现出来,包括早期的 Grunt、Gulp 以及现在流行的 webpack。随着这些工具的功能愈发强大,其重要性也在不断提升,成熟的框架都已经将这些工具封装成专用的命令行工具,比如 angular-cli 和 vue-cli。这一课时我们将继续承接前面课程的 “硬核” 风格,通过分析 webpack(5.0.0-beta.23) 的源码来深入理解其原理。

webpack 有两个执行入口,分别是通过命令行调用的 bin/webpack.js,以及直接在代码中引用的 lib/webpack.js。我们避开命令参数解析以及进程调用的过程来分析 lib/webpack.js,下面是部分源码(省去了数组型配置及 watch 功能)。

  1. const webpack = (( options, callback ) => {
  2. validateSchema(webpackOptionsSchema, options);
  3. let compiler;
  4. compiler = createCompiler(options);
  5. if (callback) {
  6. compiler.run((err, stats) => {
  7. compiler.close(err2 => {
  8. callback(err || err2, stats);
  9. });
  10. });
  11. }
  12. return compiler;
  13. });

从源码中可以看到,webpack() 函数内部有 3 个重要的操作:校验配置项创建编译器执行编译

校验配置项

校验配置项是通过调用 validateSchema() 函数来实现的,这个函数的内部其实是调用的 schema-utils 模块的 validate () 函数 ,validate() 函数支持通过 JSONSchema 规则来校验 json 对象。这些 JSONSchema 规则保存在 schemas/WebpackOptions.json 文件中,对应代码中的 webpackOptionsSchema 变量。

这里简单介绍一下 JSONSchema,它是通过 JSON 文件来描述 JSON 文件 ,可以用来校验 JSON 对象、生成 mock 数据及描述 JSON 对象结构。下面是一个对 output 参数的部分校验规则。

  1. "Output": {
  2. "description": "Options affecting the output of the compilation. `output` options tell webpack how to write the compiled files to disk.",
  3. "type": "object",
  4. "properties": {
  5. ...
  6. "path": {
  7. "$ref": "#/definitions/Path"
  8. }
  9. }
  10. }
  11. ...
  12. "definitions": {
  13. "Path": {
  14. "description": "The output directory as **absolute path** (required).",
  15. "type": "string"
  16. }
  17. }

从 “type”: “object” 可以看到 Output 是一个对象,它拥有属性 Path,而这个 P ath 类型定义在 definitions 对象的 Path 属性中,通过 “type”: “string” 可以看到,它是一个字符串类型。WebpackOptions.json 文件内容比较多,有 3000 多行,这里就不多介绍了,有兴趣的同学可以仔细研究。

一句话概括,validateSchema() 函数通过 JSONSchema 对 options 进行校验,如果不符合配置规则,则退出并在控制台输出格式化的错误信息。这样就能避免因为选项参数不正确而导致程序运行出错。

创建编译器

创建编译器操作是在 compiler.compile() 函数中调用 createCompiler() 函数来实现的,该函数会返回一个 Compiler 实例。createCompiler() 函数源码如下:

  1. const createCompiler = rawOptions => {
  2. const options = getNormalizedWebpackOptions(rawOptions);
  3. applyWebpackOptionsBaseDefaults(options);
  4. const compiler = new Compiler(options.context);
  5. compiler.options = options;
  6. new NodeEnvironmentPlugin({
  7. infrastructureLogging: options.infrastructureLogging
  8. }).apply(compiler);
  9. if (Array.isArray(options.plugins)) {
  10. for (const plugin of options.plugins) {
  11. if (typeof plugin === "function") {
  12. plugin.call(compiler, compiler);
  13. } else {
  14. plugin.apply(compiler);
  15. }
  16. }
  17. }
  18. applyWebpackOptionsDefaults(options);
  19. compiler.hooks.environment.call();
  20. compiler.hooks.afterEnvironment.call();
  21. new WebpackOptionsApply().process(options, compiler);
  22. compiler.hooks.initialize.call();
  23. return compiler;
  24. }

在 createCompiler() 函数内部可以看到,首先会通过 getNormalizedWebpackOptions() 函数将默认的配置参数与自定义的配置参数 rawOptions 进行合成,赋值给变量 options。applyWebpackOptionsBaseDefaults() 函数则将程序当前执行路径赋值给 options.context 属性。

经过以上处理之后,变量 options 才会作为参数传递给类 Compiler 来生成实例。在构造函数中,实例的很多属性进行了初始化操作,其中比较重要的是 hooks 属性。下面是截取的部分源码:

  1. constructor(context) {
  2. this.hooks = Object.freeze({
  3. initialize: new SyncHook([]),
  4. shouldEmit: new SyncBailHook(["compilation"]),
  5. done: new AsyncSeriesHook(["stats"]),
  6. afterDone: new SyncHook(["stats"]),
  7. additionalPass: new AsyncSeriesHook([]),
  8. beforeRun: new AsyncSeriesHook(["compiler"]),
  9. run: new AsyncSeriesHook(["compiler"]),
  10. emit: new AsyncSeriesHook(["compilation"]),
  11. assetEmitted: new AsyncSeriesHook(["file", "info"]),
  12. afterEmit: new AsyncSeriesHook(["compilation"]),
  13. thisCompilation: new SyncHook(["compilation", "params"]),
  14. compilation: new SyncHook(["compilation", "params"]),
  15. normalModuleFactory: new SyncHook(["normalModuleFactory"]),
  16. contextModuleFactory: new SyncHook(["contextModuleFactory"]),
  17. beforeCompile: new AsyncSeriesHook(["params"]),
  18. compile: new SyncHook(["params"]),
  19. make: new AsyncParallelHook(["compilation"]),
  20. finishMake: new AsyncSeriesHook(["compilation"]),
  21. afterCompile: new AsyncSeriesHook(["compilation"]),
  22. watchRun: new AsyncSeriesHook(["compiler"]),
  23. failed: new SyncHook(["error"]),
  24. invalid: new SyncHook(["filename", "changeTime"]),
  25. watchClose: new SyncHook([]),
  26. infrastructureLog: new SyncBailHook(["origin", "type", "args"]),
  27. environment: new SyncHook([]),
  28. afterEnvironment: new SyncHook([]),
  29. afterPlugins: new SyncHook(["compiler"]),
  30. afterResolvers: new SyncHook(["compiler"]),
  31. entryOption: new SyncBailHook(["context", "entry"])
  32. });
  33. }

为了防止 hooks 属性被修改,这里使用 Object.freeze() 函数来创建对象。简单介绍一下 object.freeze() 函数,它可以冻结一个对象。一个被冻结的对象再也不能被修改了;冻结了一个对象则不能向这个对象添加新的属性,不能删除已有属性,不能修改该对象已有属性的可枚举性、可配置性、可写性,以及不能修改已有属性的值。此外,冻结一个对象后该对象的原型也不能被修改。

这里一共创建了 4 种类型的钩子(hook),它们的名称和作用如下:

  • SyncHook(同步钩子),当钩子触发时,会依次调用钩子队列中的回调函数;
  • SyncBailHook(同步钩子),当钩子触发时,会依次调用钩子队列中的回调函数,如果遇到有返回值的函数则停止继续调用;
  • AsyncSeriesHook(异步串行钩子),如果钩子队列中有异步回调函数,则会等其执行完成后再执行剩余的回调函数;
  • AsyncParallelHook(异步并行钩子),可以异步执行钩子队列中的所有异步回调函数。

下面一段代码是钩子函数的简单用法。通过 new 关键字创建钩子实例,然后调用 tap() 函数来监听钩子,向 hook 的钩子队列中添加一个回调函数 。 当执行 hook.call() 函数时,会依次调用队列中的回调函数,并将参数传递给这些回调函数 。 需要注意的是, 参数的数量必须与实例化的数组长度一致。在下面的例子中,只能传递 1 个参数。

tapable 模块提供了十多种钩子,这里就不一一详细介绍了,我们只要知道它实现了一些特殊的订阅机制即可,对钩子有兴趣的同学可以参看其 文档

  1. const { SyncHook } = require('tapable');
  2. const hook = new SyncHook(['whatever']);
  3. hook.tap('1', function (arg1) {
  4. console.log(arg1);
  5. });
  6. hook.call('lagou');

接着继续往下看,会发现这样一行代码。

  1. new NodeEnvironmentPlugin({
  2. infrastructureLogging: options.infrastructureLogging
  3. }).apply(compiler);

这种调用插件(plugin)的 apply() 函数的写法在 webpack 中很常见,主要作用就是监听 compiler 钩子事件,或者说是向钩子队列中插入一个回调函数,当对应的钩子事件触发时调用。

钩子初始化完成后会调用 3 个钩子事件:

  1. compiler.hooks.environment.call();
  2. compiler.hooks.afterEnvironment.call();
  3. new WebpackOptionsApply().process(options, compiler);
  4. compiler.hooks.initialize.call();

其中,process() 函数会根据不同的执行环境引入一些默认的插件并调用它的 apply() 函数,比如 Node 环境下会引入下面的插件:

  1. const NodeTemplatePlugin = require("./node/NodeTemplatePlugin");
  2. const ReadFileCompileWasmPlugin = require("./node/ReadFileCompileWasmPlugin");
  3. const ReadFileCompileAsyncWasmPlugin = require("./node/ReadFileCompileAsyncWasmPlugin");
  4. const NodeTargetPlugin = require("./node/NodeTargetPlugin");
  5. new NodeTemplatePlugin({
  6. asyncChunkLoading: options.target === "async-node"
  7. }).apply(compiler);
  8. new ReadFileCompileWasmPlugin({
  9. mangleImports: options.optimization.mangleWasmImports
  10. }).apply(compiler);
  11. new ReadFileCompileAsyncWasmPlugin().apply(compiler);
  12. new NodeTargetPlugin().apply(compiler);
  13. new LoaderTargetPlugin("node").apply(compiler);

至此,编译器已经创建完成。小结一下创建编译器步骤的主要逻辑,首先会将配置参数进行修改,比如加入一些默认配置项;然后创建一个编译器实例 compiler,这个实例的构造函数会初始化一些钩子;最后就是调用插件的 apply() 函数来监听钩子,同时也会主动触发一些钩子事件。

执行编译

调用 compiler.compile() 函数标志着进入编译阶段,该阶段非常依赖钩子, 代码跳跃比较大,理解起来会有一定难度 。下面是 compile() 函数的部分代码:

  1. compile(callback) {
  2. const params = this.newCompilationParams();
  3. this.hooks.beforeCompile.callAsync(params, err => {
  4. if (err) return callback(err);
  5. this.hooks.compile.call(params);
  6. const compilation = this.newCompilation(params);
  7. this.hooks.make.callAsync(compilation, err => {
  8. })
  9. })
  10. }

首先是触发了 compiler.hooks.compile 钩子,触发后,一些插件将进行初始化操作,为编译做好准备,比如 LoaderTargetPlugin 插件将会加载需要的加载器。

调用 newCompilation() 函数则会创建了一个 Compilation 实例。注意,这里的 Compilation 和前面创建的 Compiler 是有区别的:Compiler 是全局唯一的,包含了配置参数、加载器、插件这些信息,它会一直存在 webpack 的生命周期中;而 Compilation 包含了当前模块的信息,只是代表一次编译过程。

在创建 compilation 完成后会触发 compiler.hooks.thisCompilation 钩子和 compiler.hooks.compilation,激活 JavaScriptModulesPlugin 插件的监听函数,从而加载 JavaScript 的解析模块 acorn 。

  1. newCompilation(params) {
  2. const compilation = this.createCompilation();
  3. compilation.fileTimestamps = this.fileTimestamps;
  4. compilation.contextTimestamps = this.contextTimestamps;
  5. compilation.name = this.name;
  6. compilation.records = this.records;
  7. compilation.compilationDependencies = params.compilationDependencies;
  8. this.hooks.thisCompilation.call(compilation, params);
  9. this.hooks.compilation.call(compilation, params);
  10. return compilation;
  11. }

在 compiler.compile() 函数中触发 compiler.hooks.make 钩子标志着编译操作正式开始。那么哪些函数监听了 make 钩子呢?通过搜索代码可以发现有 7 个插件监听了它。

第21讲:你的代码到底是怎么编译的? - 图1

监听了 make 钩子的插件

其中 EntryPlugin 插件负责分析入口文件,下面是截取的部分代码:

  1. class EntryPlugin {
  2. apply(compiler) {
  3. compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
  4. const { entry, options, context } = this;
  5. const dep = EntryPlugin.createDependency(entry, options);
  6. compilation.addEntry(context, dep, options, err => {
  7. callback(err);
  8. });
  9. });
  10. }
  11. }

EntryPlugin 插件中调用了 compilation 对象的 addEntry() 函数,该函数中又调用了 _addEntryItem() 函数将入口模块添加到模块依赖列表中,部分源码如下:

  1. _addEntryItem(context, entry, target, options, callback) {
  2. this.addModuleChain(context, entry, (err, module) => {
  3. if (err) {
  4. this.hooks.failedEntry.call(entry, options, err);
  5. return callback(err);
  6. }
  7. this.hooks.succeedEntry.call(entry, options, module);
  8. return callback(null, module);
  9. });
  10. }

在 addModuleChain() 函数中会调用 compilation 的 handleModuleCreation() 函数,该函数代码比较多,其中会调用 compilation 的 buildModule() 函数来构建模块。

模块构建完成过后,通过 acorn 生成模块代码的抽象语法树,根据抽象语法树分析这个模块是否还有依赖的模块,如果有则继续解析每个依赖的模块,直到所有依赖解析完成,最后合并生成输出文件。这个过程和前面几讲提到的编译器执行过程类似,就不再赘述了。

总结

这一课时从源码层面分析了 webpack 的工作原理,webpack 的执行过程大体上可以分为 3 个步骤,包括:检验配置项、创建编译器、执行编译。

在 检验 配置项时使用了 JSONSchema 来校验配置参数。在创建编译器时,用到了 tapable 模块提供的钩子机制,通过触发适当的钩子事件来让对应的插件进行初始化。

在执行编译阶段,以 compiler.hooks.make 钩子事件为起始点,触发入口文件的解析工作,并调用加载器对资源进行处理,然后构建成抽象语法树,将最终的抽象语法树转换成目标文件并输出到配置项指定的目录。

最后布置一道思考题:尝试一下 tapable 模块的各种钩子事件,分析比较一下它们的使用区别。