前端高手进阶第21讲:你的代码到底是怎么编译的?

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 功能)。

// lib/webpack.js 
const webpack = (( options, callback ) => { 
  validateSchema(webpackOptionsSchema, options); 
  let compiler; 
  compiler = createCompiler(options); 
  if (callback) { 
    compiler.run((err, stats) => { 
      compiler.close(err2 => { 
        callback(err || err2, stats); 
      }); 
    }); 
  } 
  return compiler; 
}); 

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

校验配置项

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

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

"Output": { 
  "description""Options affecting the output of the compilation. `output` options tell webpack how to write the compiled files to disk.", 
  "type""object", 
  "properties": { 
     ... 
    "path": { 
      "$ref""#/definitions/Path" 
    } 
  } 
} 
... 
"definitions": { 
 "Path": { 
    "description""The output directory as **absolute path** (required).", 
    "type""string" 
  } 
} 

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

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

创建编译器

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

// lib/webpack.js 
const createCompiler = rawOptions => { 
  const options = getNormalizedWebpackOptions(rawOptions); 
  applyWebpackOptionsBaseDefaults(options); 
  const compiler = new Compiler(options.context); 
  compiler.options = options; 
  new NodeEnvironmentPlugin({ 
    infrastructureLogging: options.infrastructureLogging 
  }).apply(compiler); 
  if (Array.isArray(options.plugins)) { 
    for (const plugin of options.plugins) { 
      if (typeof plugin === "function") { 
        plugin.call(compiler, compiler); 
      } else { 
        plugin.apply(compiler); 
      } 
    } 
  } 
  applyWebpackOptionsDefaults(options); 
  compiler.hooks.environment.call(); 
  compiler.hooks.afterEnvironment.call(); 
  new WebpackOptionsApply().process(options, compiler); 
  compiler.hooks.initialize.call(); 
  return compiler; 
} 

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

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

// lib/Compiler.js 
constructor(context) { 
    this.hooks = Object.freeze({ 
      initializenew SyncHook([]), 
      shouldEmitnew SyncBailHook(["compilation"]), 
      donenew AsyncSeriesHook(["stats"]), 
      afterDonenew SyncHook(["stats"]), 
      additionalPassnew AsyncSeriesHook([]), 
      beforeRunnew AsyncSeriesHook(["compiler"]), 
      runnew AsyncSeriesHook(["compiler"]), 
      emitnew AsyncSeriesHook(["compilation"]), 
      assetEmittednew AsyncSeriesHook(["file""info"]), 
      afterEmitnew AsyncSeriesHook(["compilation"]), 
      thisCompilationnew SyncHook(["compilation""params"]), 
      compilationnew SyncHook(["compilation""params"]), 
      normalModuleFactorynew SyncHook(["normalModuleFactory"]), 
      contextModuleFactorynew SyncHook(["contextModuleFactory"]), 
      beforeCompilenew AsyncSeriesHook(["params"]), 
      compilenew SyncHook(["params"]), 
      makenew AsyncParallelHook(["compilation"]), 
      finishMakenew AsyncSeriesHook(["compilation"]), 
      afterCompilenew AsyncSeriesHook(["compilation"]), 
      watchRunnew AsyncSeriesHook(["compiler"]), 
      failednew SyncHook(["error"]), 
      invalidnew SyncHook(["filename""changeTime"]), 
      watchClosenew SyncHook([]), 
      infrastructureLognew SyncBailHook(["origin""type""args"]), 
      environmentnew SyncHook([]), 
      afterEnvironmentnew SyncHook([]), 
      afterPluginsnew SyncHook(["compiler"]), 
      afterResolversnew SyncHook(["compiler"]), 
      entryOptionnew SyncBailHook(["context""entry"]) 
    }); 
} 

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

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

  • SyncHook(同步钩子),当钩子触发时,会依次调用钩子队列中的回调函数;

  • SyncBailHook(同步钩子),当钩子触发时,会依次调用钩子队列中的回调函数,如果遇到有返回值的函数则停止继续调用;

  • AsyncSeriesHook(异步串行钩子),如果钩子队列中有异步回调函数,则会等其执行完成后再执行剩余的回调函数;

  • AsyncParallelHook(异步并行钩子),可以异步执行钩子队列中的所有异步回调函数。

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

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

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

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

// lib/webpack.js 
new NodeEnvironmentPlugin({ 
  infrastructureLogging: options.infrastructureLogging 
}).apply(compiler); 

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

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

// lib/webpack.js 
compiler.hooks.environment.call(); 
compiler.hooks.afterEnvironment.call(); 
new WebpackOptionsApply().process(options, compiler); 
compiler.hooks.initialize.call(); 

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

// lib/WebpackOptionsApply.js 
const NodeTemplatePlugin = require("./node/NodeTemplatePlugin"); 
const ReadFileCompileWasmPlugin = require("./node/ReadFileCompileWasmPlugin"); 
const ReadFileCompileAsyncWasmPlugin = require("./node/ReadFileCompileAsyncWasmPlugin"); 
const NodeTargetPlugin = require("./node/NodeTargetPlugin"); 
new NodeTemplatePlugin({ 
  asyncChunkLoading: options.target === "async-node" 
}).apply(compiler); 
new ReadFileCompileWasmPlugin({ 
  mangleImports: options.optimization.mangleWasmImports 
}).apply(compiler); 
new ReadFileCompileAsyncWasmPlugin().apply(compiler); 
new NodeTargetPlugin().apply(compiler); 
new LoaderTargetPlugin("node").apply(compiler); 

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

执行编译

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

// lib/Compiler.js 
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); 
    this.hooks.make.callAsync(compilation, err => { 
    }) 
  }) 
} 

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

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

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

// lib/Compiler.j s 
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.hooks.thisCompilation.call(compilation, params); 
  this.hooks.compilation.call(compilation, params); 
  return compilation; 
} 

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

image (2).png

监听了 make 钩子的插件

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

// lib/EntryPlugin.js 
class EntryPlugin { 
  apply(compiler) { 
    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); 
    	}); 
    }); 
  } 
} 

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

_addEntryItem(context, entry, target, options, callback) { 
  this.addModuleChain(context, entry, (err, module) => { 
    if (err) { 
      this.hooks.failedEntry.call(entry, options, err); 
      return callback(err); 
    } 
    this.hooks.succeedEntry.call(entry, options, module); 
    return callback(null, module); 
  }); 
} 

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

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

总结

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

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

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

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


精选评论

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

源码头

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值