浅析 webpack 打包流程(原理) 一 - 准备工作

webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。

自从前端模块化出现,我们可以把代码拆成一个个 js 文件,通过 import、require() 去关联依赖文件,最后再通过打包工具把这些模块化的 js 依赖关系打包成一个或多个 js 文件在 html 页面去引入。webpack 作为一个模块化解决方案,把项目中使用的每个文件都视为 模块(Modules)。除了 js,样式文件中 @import 的 css、stylesheet url(...)、HTML 中引入的图片在编译过程中都会被当作模块依赖来处理。因为 ES2015+ 、TypeScript 和一些前端框架(如 Vue、 React)的存在,webpack 又担负着将这些浏览器不支持的文件转化成可识别文件的工作。
除此之外,webpack 还能进行 tree-shaking (剔除无效代码) 和代码压缩,以及抽离出异步加载模块、第三方库来实现最终打包好的主文件只是进入首页所需要的资源。
webpack 还提供了一系列开发辅助工具,devserver,HMR 等,帮助我们高效地开发。

webpack 插件架构

插件是 webpack 的 支柱 功能,利用一些插件可以帮助我们提取公共依赖(拆包)、压缩资源(代码和图片)的体积,大大优化我们的构建输出。
webpack 从配置初始化到构建完成定义了一个生命周期。整个流程是一个事件驱动架构,利用插件系统 Tapable,通过发布-订阅事件来实现所有扩展功能。webpack 在运行过程中会在特定节点调用(广播)一些 hook,订阅了这些 hook 的插件在监听到后会执行绑定时定义好的逻辑。

webpack 核心模块

webpack 通过 Compiler (主要引擎) 控制构建流程,用 Compilation 对象存储过程中的解析编译信息。要厘清 webpack 打包原理,理解它们至关重要。关于这部分我仔细阅读源码写了这篇:webpack 之 Compiler 、Compilation 和 Tapable。还有负责生成模块的 ModuleFactory 生成模块,解析源码 的 Parser ,渲染代码 的 Template。

webpack 构建流程

当 webpack 处理应用程序时,它会从 入口 开始,递归地构建一个依赖关系图 (dependency graph),其中包含应用程序所需的每个模块 ( loader 负责将非JavaScript文件转换为依赖图能直接引用的有效模块),最后将所有这些模块打包成一个或多个 bundle。
先放上总的构建原理图,后面会详细去阐述。

webpack构建原理

再借一张别人画的简易版流程图:

webpack 构建流程简易版

几个关键阶段和结合资源形态流转的角度对过程的说明:


make后,compilation 会获知资源模块的内容与依赖关系,也就知道“输入”是什么;而经过seal阶段处理后, compilation 则获知资源输出的图谱,也就是知道怎么“输出”:哪些模块跟那些模块“绑定”在一起输出到哪里。

compiler.hooks.make 阶段:
entry 文件以 dependence 对象形式加入 compilation 的依赖列表,dependence 对象记录有 entry 的类型、路径等信息;
根据 dependence 调用对应的工厂函数创建 module 对象,之后读入 module 对应的文件内容,调用 loader-runner 对内容做转化,转化结果若有其它依赖则继续读入依赖资源,重复此过程直到所有依赖均被转化为 module。
compilation.seal 阶段:
遍历 module 集合,根据 entry 配置及引入资源的方式,将 module 分配到不同的 chunk;
遍历 chunk 集合,调用 compilation.emitAsset 方法标记 chunk 的输出规则,即转化为 assets 集合。
compiler.emitAssets 阶段:
将 assets 写入文件系统。

再放一张简单版主体框架流程

webpack 的构建从入口文件开始,会找出有哪些模块是入口起点依赖的。需要 loader 处理的就先转换编译,之后分析模块自身是否有依赖,有依赖就接着处理依赖,流程和刚刚一致。像这样递归获取并处理每个模块,同步为dependencies,异步为block,最终存储到一个 Map 表blockInfoMap中 (ModuleGraph)。然后遍历这些编译完成的模块,基于它们进行分组 (chunkGroup) 和封包 (chunk) ,生成ChunkGraph并优化。
跟着会根据插件配置对 chunk 进一步优化处理,比如代码分割、 treeshaking 或者 代码压缩,最后生成我们需要的 js。

webpack 的运行流程是一个串行的过程:
webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理环节的职责都是单一的,多个流程之间存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。而插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。webpack 通过 Compiler 来组织这条复杂的生产线。webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。

案例 demo

本系列的项目 demo,后面会以此为例分析过程和结果:【浅析 webpack 打包流程(原理) - 案例 demo】

一、初始化工作

把 webpack-cli 传的参数和项目配置做一个合并( cli 参数优先级更高),并处理部分参数 (验证:validateOptions(options) 处理:processOptions(options)) ,得到最终的配置 options,接着对配置中的统计信息(options.stats)进行处理。
创建 Compiler 实例compiler = new Compiler(options.context)(options.context 为项目绝对路径),把最终配置 options 挂载到 compiler 对象下。

二、编译前准备

此阶段概述:在 compiler 的各种 hook 上注册项目配置的 plugins、注册 webpack 默认插件 ➡️ 注册resolverFactory.hooks为 Factory.createResolver 方法提供参数对象。
webpack 的事件机制是基于 tapable 库做的事件流控制,在整个编译过程中暴露出各种hook,而 plugin 注册监听了某个/某些 hook,在这个 hook 触发时,会执行 plugin 里绑定的方法。

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

NodeEnvironmentPlugin 类主要对文件系统做一些封装,包括输入,输出,缓存,监听等等,这些扩展后的方法全部挂载在 compiler 对象下。

plugin.apply(compiler); 通过调用每个插件实例的 apply 方法,并把 complier 实例作为参数传进去,在 compiler 生命周期的各种钩子事件上注册配置中的所有 plugins。即插件 apply 方法中订阅了 compiler 的一些 hook,后续 compiler 会根据运行时各种事件钩子的触发,去执行插件注册/绑定的函数。
关于 Compiler 和 插件机制我这篇有比较详细的说明 ➡️ webpack 之 Compiler 、Compilation 和 Tapable

// /lib/Webpack.js
compiler.options = new WebpackOptionsApply().process(options, compiler);

WebpackOptionsApply 类的 process 方法把配置里的一些属性添加到 compiler 上,更主要的是注册激活一些默认自带的插件和 resolverFactory.hooks。大部分插件的作用是往 compiler 的两个 hook: compilation, thisCompilation 里注册一些事件(此时这两个钩子已经获取到 normalModuleFactory 等参数),举例:

// /lib/WebpackOptionsApply.js
new JavascriptModulesPlugin().apply(compiler); // 给 normalModuleFactory 的 js 模块提供 Parser、JavascriptGenerator 对象 ,并给 seal 阶段的 template 提供 renderManifest 数组(包含 render 方法)
new EntryOptionPlugin().apply(compiler); // 将插件注册在compiler.hooks.entryOption 上
compiler.hooks.entryOption.call(options.context, options.entry); // 激活 entryOption 钩子事件,EntryOptionPlugin 实例里绑定的方法随即被触发

EntryOptionPlugin 插件会根据入口配置是单入口或多入口实例化SingleEntryPlugin / MultiEntryPlugin 插件,两者均会在 apply 方法里注册 compiler.hooks: compilation, make

插件处理完毕,触发compiler.hooks.afterPlugins钩子。

// /lib/WebpackOptionsApply.js
compiler.resolverFactory.hooks.resolveOptions
  .for("context")
  .tap("WebpackOptionsApply", resolveOptions => {
    return Object.assign(
      {
        fileSystem: compiler.inputFileSystem,
        esolveToContext: true
      },
      cachedCleverMerge(options.resolve, resolveOptions)
    );
  });

然后依次注册 compiler.resolverFactory.hooks: resolveOptions.for (normal/context/loader),目的是为 Factory.createResolver 提供默认参数对象 (包含相关的项目 resolve 配置项)。触发 compiler.hooks.afterResolvers 钩子,至此 compiler 初始化完毕。

三、开始编译

此阶段概述:compiler.run ➡️ compiler.compile 开启编译 ➡️ 实例化 NormalModuleFactory 类ContextModuleFactory 类 ➡️ 创建Compilation实例 ➡️ 触发compiler.hooks.make钩子执行 compilation.addEntry (处理入口),执行 moduleFactory.create 开始构建 module
compile 是真正进行编译的过程,最终会把所有原始资源编译为目标资源。

继续回到/lib/Webpack.js,判断 options 里是否有 watch,有走 compiler.watch,无则 compiler.run,我们执行 compiler 的 run 方法,正式启动编译。

首先调用compiler.hooks: beforeRun钩子,做一些判断 inputFileSystem 是否配置、读取之前的 records 等处理,再在回调里执行 Compiler 类的compile原型方法

// /lib/Compiler.js
compile(callback) {
  const params = {
    normalModuleFactory: this.createNormalModuleFactory(),
    contextModuleFactory: this.createContextModuleFactory(),
    compilationDependencies: new Set()
  };
}

先分别实例化 NormalModuleFactory 类和 ContextModuleFactory 类 (均扩展于 tapable),和触发 compiler.hooks: normalModuleFactory ,contextModuleFactory钩子。

// /lib/NormalModuleFactory.js
this.hooks.factory.tap("NormalModuleFactory", () => (result, callback) => {
  let resolver = this.hooks.resolver.call(null);
  resolver(result, (err, data) => {
    // ...
  });
});
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
  // ...
});

NormalModuleFactory 负责生成各类模块:从入口点开始,分解每个请求,解析文件内容以查找进一步的请求,然后通过分解所有请求以及解析新的文件来爬取全部文件。在最后阶段,每个依赖项都会成为一个模块实例。
在实例化 NormalModuleFactory 执行 constructor 的过程中,注册了 normalModuleFactory.hooks: factory,触发 factory 钩子时会先触发 normalModuleFactory.hooks: resolver,再执行注册的回调函数。

ContextModuleFactory 从 webpack 独特的 require.context API 生成依赖关系。它会解析请求的目录,为每个文件生成请求,并依据传递来的 regExp 进行过滤。最后匹配成功的依赖关系将被传入 NormalModuleFactory。

之后触发compiler.hooks: beforeCompile、compile,然后执行:const compilation = this.newCompilation(params)来实例化一个 Compilation 类。
newCompilation 方法里还触发了 compiler.hooks: thisCompilation、compilation,在编译前注册plugins阶段WebpackOptionsApply.js里注册了大量这俩 hooks 的事件,此时拿到 compilation 对象,开始执行这一系列事件。

  • compiler.hooks.thisCompilation 会在 compilation 对象的 hooks 上注册一些新事件;
  • compiler.hooks.compilation 会在 compilation、normalModuleFactory 对象的 hooks 上注册一些新事件,同时还会往 compilation.dependencyFactories (工厂类)、compilation.dependencyTemplates (模板类) 增加依赖模块。

为什么这里需要 thisCompilation、compilation 两个钩子?
Compiler 的 createChildCompiler 方法可以创建子编译器,过程中会复制 compilation 钩子(上注入的插件方法),但不会复制thisCompilationmakecompile等。子编译器拥有完整的 module 和 chunk 生成,通过它可以独立于父编译器执行一个核心构建流程,额外生成一些需要的 module 和 chunk。

触发compiler.hooks : make,执行之前在SingleEntryPlugin | MultiEntryPlugin注册的订阅事件,执行:

// /lib/SingleEntryPlugin.js 或 /lib/MultiEntryPlugin.js
compiler.hooks.make.tapAsync(
  "SingleEntryPlugin",
  (compilation, callback) => {
    const { entry, name, context } = this;
    const dep = SingleEntryPlugin.createDependency(entry, name);
    compilation.addEntry(context, dep, name, callback);
  }
);

再看 compilation 的 addEntry 方法:

// /lib/Compilation.js
_addModuleChain(context, dependency, onModule, callback) {
  // ...
  const Dep = /** @type {DepConstructor} */ (dependency.constructor);
  const moduleFactory = this.dependencyFactories.get(Dep); // moduleFactory 为 normalModuleFactory
  this.semaphore.acquire(() => { // 编译队列控制
    // 默认并发数为 100,超过后存入 semaphore.waiters,
    // 根据情况再调用 semaphore.release 去执行存入的事件 semaphore.waiters。
    moduleFactory.create({...}, (err, module) => {
      //...
    });
  });
} 

addEntry(context, entry, name, callback) {
  this.hooks.addEntry.call(entry, name); // 触发 addEntry 钩子
  // ...
  this._addModuleChain( // 调用上面的_addModuleChain
    context,
    entry,
    module => this.entries.push(module), // 把 module 添加 compilation.entries
    (err, module) => {} // _addModuleChain 执行完的回调
  ) 
}

进一步分析,dependency = SingleEntryPlugin.createDependency(entry, name),即new SingleEntryDependency(entry),则 Dep 为 SingleEntryDependency 类,而之前compiler.hooks: compilation的注册事件中添加了依赖:

// /lib/SingleEntryPlugin.js 或 /lib/MultiEntryPlugin.js
compilation.dependencyFactories.set(
  SingleEntryDependency,
  normalModuleFactory
);

所以 moduleFactory 即为 normalModuleFactory

this.semaphore是一个编译队列控制,对执行进行了并发控制。moduleFactory.create开始构建 module, 递归解析依赖的重复从此处开始

下文:浅析 webpack 打包流程(原理) 二 - 递归构建 module

webpack 打包流程系列(未完):
浅析 webpack 打包流程(原理) - 案例 demo
浅析 webpack 打包流程(原理) 一 - 准备工作
浅析 webpack 打包流程(原理) 二 - 递归构建 module
浅析 webpack 打包流程(原理) 三 - 生成 chunk
浅析 webpack 打包流程(原理) 四 - chunk 优化
浅析 webpack 打包流程(原理) 五 - 构建资源
浅析 webpack 打包流程(原理) 六 - 生成文件

参考鸣谢:
webpack打包原理 ? 看完这篇你就懂了 !
webpack 透视——提高工程化(原理篇)
webpack 透视——提高工程化(实践篇)
webpack 4 源码主流程分析
[万字总结] 一文吃透 Webpack 核心原理

你可能感兴趣的:(浅析 webpack 打包流程(原理) 一 - 准备工作)