webpack 编译原理(How webpack compiles)

webpack 编译原理(How webpack compiles)

研发 @ 字节跳动

作者:大力智能技术团队-前端 chengcyber

​大力智能前端基础架构团队在为业务项目优化编译速度的过程中,对基于 webpack 的编译过程进行了研究。本文就以 webpack@5.11.0 版本展开在源码层面讲解 webpack 的编译原理。

理解 Tapable

webpack 的源码是非常抽象的,基本所有的操作都是通过 Tapable 动态注册回调形成插件机制。所以我们先看下 Tapable 是什么。

一句话来说,Tapable 就是支持下列纬度的 EventEmitter,提供了注册事件回调,并能让事件产生方来灵活选择如何执行这些回调。

  • 执行方式:同步、异步串行、异步并行

  • 过程控制:基本(依次执行)、流式(依次执行但传入上次的结果,类似 reduce)、提前结束(依次执行,但是允许提前结束)、循环(loop,循环执行直到返回的是 undefined)

举个简单的🌰:

class Person {
  constructor(name) {
    this.name = name;
    this.hooks = {
      eat: new SyncHook(),
    }
  }
  
  eat() {
    this.hooks.eat.call();
  }
}

const zhangsan = new Person('zhangsan');
复制代码

现在,我们有了一个 Person 类,开放了一个eat 的 hook。接着,我们实例化了一个 zhangsan 作为 Person 实例。然后,我构建了一个减肥插件(FitnessPlugin),目的是在 zhangsan 吃东西的时候就大喊:自觉点!!!

// FitnessPlugin
zhangsan.hooks.eat.tap("FitnessPlugin", () => console.log('自觉点!!!'));
复制代码

只需要通过 hooks.eat.tap 方法就可以注册一个事件回调

zhangsan.eat(); // 张三吃东西了!
/// 自觉点!!! by FitnessPlugin
复制代码

之后调用 eat 时,就会执行刚才注册的事件回调,console 输出自觉点。理解了 Tapable,你已经具备了为 webpack 撰写一个 FitnessPlugin 的能力了 😉

Compiler 编译器实例化

Compiler 是 webpack 根据传入 webpack.config.js 和 cli 参数组合生成的编译器实例,源码传送门:github.com/webpack/web…

Compiler 的主要功能

  1. 根据是否 watch 选择 watchRun 还是 run

  2. 执行 compile,新建一个 Compilation

  3. 使用 Compilation 执行编译过程

  4. emit 产物

生命周期

在 Compiler 运行的过程中,webpack 定义了许多生命周期。先放一张总览图(排除 watch 和错误处理):

正是由于有如此多的生命周期开放出来,所以 webpack 的插件机制很强大,可以灵活的介入编译的各个环节;同时也对源码阅读带来了难度,因为全是动态注册的。接下来,我们看几个主要的生命周期和其作用帮助了解 Compiler。

compiler.hooks.compilation

Compilation 被创建出来后,同样拥有众多生命周期通过插件介入管控此次的编译过程。例如:

  • JavascriptModulePlugin 会注册 js 文件的 parser 和 generator

  • EntryPlugin 会设置 EntryDependency 的 factory,告诉编译器如何处理 entry 模块

compiler.hooks.make

当 Compilation 被创建完成后(下文讲解 Compilation),就会调用 make 生命周期,webpack 内置了 EntryPlugin 注册了 make 的处理逻辑,用来找到入口模块,即 webpack.config 中的 entry。

// webpack.config.js
module.exports = {
  entry: {
    main: './index.js',
  },
}
复制代码

处理名为 main 的入口文件 ./index.js 最终会生成 EntryDependency 实例,通过 compilation.addEntry 添加进编译过程。

Compilation

Compiler 是经由配置生成的编译器,而一个 Compilation 实例代表一次完整的编译过程。包括加载入口模块,解析依赖,解析 AST,创建 Chunk,生成产物等一系列工作。

Compilation 主要过程

一个 Module 对应了一份源码文件,或者由源码文件解析过程中产生出的虚拟模块。虚拟模块:能够被编译器认为是一个模块,但不能正常对应到文件系统中的源码文件,一般编译器借此处理某些特殊模块类型。例如 require.context('./a')。

handleModuleCreation

前面说到 EntryPlugin 会把 entry 加入进编译过程。经过这个函数进行处理,最终会通过 NormalModuleFactory 进行实例化,继续获得 dependencies 然后递归这个过程。

buildModule

使用 Parser 对源码进行 parse 这里会遍历 ast 中的所有节点,然后 call 相应的 Tapable 事件,插件通过注册对应的语法回调,干预 parse 的结果。这里有点像是用 Tapable 模拟了 visitor 的逻辑,并且开放语法处理的逻辑给外部。

举一个 🌰:webpack 有一个内置的语法 require.context(webpack.js.org/guides/depe…

const allFiles = require.context('./a', false, /\.js$/);
复制代码

对 ast 进行遍历,直到遇到 require.context,此时就调用 RequireContextDependencyParserPlugin 注册的回调函数,根据参数创建一个 RequireContextDependency,对当前的 NormalModule 调用 addDependency 添加该依赖。最终在生成代码的时候会被替换成:

const allFiles = __webpack_require__('./a sync \\\\.js$');
复制代码

而这个 ./ sync \\\\.js$ 是根据 require.context 的参数生产的 ContextModule 的 name,其文件内容如下:

var map = {
  "./index.js": "./a/index.js"
};
function webpackContext(req) {
  var id = webpackContextResolve(req);
  return webpack_require(id);
}
function webpackContextResolve(req) {
  if (!webpack_require.o(map, req)) {
    var e = new Error("Cannot find module '" + req + "'");
    e.code = 'MODULE_NOT_FOUND';
    throw e;
  }
  return map[req];
}
webpackContext.keys = function webpackContextKeys() {
  return Object.keys(map);
};
webpackContext.resolve = webpackContextResolve;
module.exports = webpackContext;
webpackContext.id = "./a sync \.js$";
复制代码

NormalModuleFactory 模块工厂👷‍♂️

做一个称职的模块工厂,主要含有下列的行为

  • create 创建模块

  • resolve 解析模块依赖

  • parse 将模块代码生成 AST

create 创建模块

执行 resolve,用其结果实例化 NormalModule,记录依赖,文件,源码等信息。还会处理 module.rules 的配置(见下文 RuleSetCompiler)。

resolve 解析模块依赖

通过 getResolver 拿到 normalResolver (所以叫 NormalModule 😁),去解析 ./index.js。获得一系列信息,例如:resource 文件系统的绝对地址,descriptionFilePath 当前项目的 package.json 绝对地址。然后执行 RuleSet 拿到处理规则,再找到对应的 loaders,生成对应的 parser,generator。

parse 将模块代码生成 AST

通过 getParser 根据文件类型拿到对应的 parser,这里的 parser 映射关系是通过插件注册进来(下文讲述)。假设是 js 文件,对应的是 JavascriptParser,会在 NormalModule 的 build 过程中调用 parse 函数,通过 acorn 生成 AST,然后在内部处理 AST(处理过程见下文 Parser)。

NormalResolver

代码中其实是没有 NormalResolver 的,所有的 Resolver 都是通过 ResolverFactory 创建,再通过 type 进行区分。即 NormalResolver === ResolverFactory 一个实例,其 type === 'normal'。

为什么这么设计?

webpack 中的 resolve 都是用的 enhanced-resolve 通过不同的配置来进行。解析不同的目标对象时需要有不同的策略。比如 NormalResolver 的职责是对应源码的模块依赖解析;解析Loader 需要有 LoaderResolver 专门解析类似 ts-loader 的代码位置。在创建不同 Resolver 的时候,可以通过插件注入不同的 resolverOptions,来达到控制 resolver 的目的。

举个🌰:我现在需要解析某个 esm 类型的 NormalModule,对应的是 NormalResolver 也就是ResolverFactory with type 'normal'。在创建时,通过 WebpackOptionsApply 注入参数逻辑。最终得到 enhanced-resolve 对 esm 模块需要的参数。

{
  conditionNames: ['import', 'module', 'webpack', 'development', 'browser'],
  aliasFields: ['browser'],
  mainFields: ['browser', 'module', 'main'],
  modules: ['node_modules'],
  mainFiles: ['index'],
  extensions: ['.ts', '.js', '.json'],
  exportsFields: ['exports'],
}
复制代码

大白话就是依次看 package.json 中的 module 和 main 字段,然后根据 browser 字段来做别名,过程中可以自动尝试添加后缀 .ts,.js,.json。

RuleSetCompiler

RuleSetCompiler 会把用户设置的 module.rules 和 webpack 内置的默认 rules 结合起来,形成一个方法,在后续处理中直接调用来确定模块处理用哪些 loaders 还有 parse 代码时需要的参数。

举一个🌰:

// webpack.config.js
module: {
 rules: [
  {
   test: /\.tsx?$/,
   loader: "ts-loader",
   options: {
    transpileOnly: true
   }
  }
 ]
},
复制代码

经过了 compile 后就变成了:

{
  conditions: [
    {
      property: 'resource',
      matchWhenEmpty: false,
      fn: (v) => /\.tsx?$/.test(v),
    },
  ],
  effects: [
    {
      loader: 'ts-loader',
      options: {
        transpileOnly: true,
      },
      ident: 'ruleSet[1].rules[0]',
    },
  ],
}
复制代码

下面来看看这个结果是怎么产生的。

CompileRule 过程

首先,BasicMatcherRulePlugin 注入了处理 test 的逻辑,调用 ruleSetCompiler.compileCondition 把这个正则转换成了 (v) => /\.tsx?$/.test 的匹配函数,针对的属性是 resource 属性,即上述结果中的 conditions[0]。接着,UseEffectRulePlugin 注入处理 loader 的逻辑,结果很好理解,就是使用 ts-loader 并且 loaderOptions 是 { transpileOnly: true }。如果对插件注入的位置有兴趣,可以看注入的逻辑地址。

这个结果怎么使用?

compiledRule 里面含有两个 array: conditions 和 effects。当一个 Module resolve 出结果后,会通过所有的 compiledRule 运行一遍,其中对于某条 compiledRule,一旦 conditions 全部符合,就会累积 effects 给后续处理(下文讲解得出的 effects 怎么使用)。

CompiledRule.exec

直接举一个🌰:假如一个 src/index.tsx 文件,经过了处理后得到的结果是:

effects: [
  {
    type: 'type',
    value: 'javascript/auto',
  },
  {
    type: 'use',
    value: {
      loader: 'ts-loader',
      options: {
        transpileOnly: true,
      },
    },
  },
]
复制代码

type: use 会把 ts-loader 作为这个 Module 的 loaders。type: type 会设置这个 Module 的参数,这里就是 type: 'javascript/auto' 指导 parser 如何解析。

Parser

webpack 有一些默认的 Parser

  • JavascriptParser

  • javascript/auto

  • javascript/esm

  • javascript/dynamic

  • JsonParser

  • json

  • WebAssemblyParser

  • webassembly/sync

  • webassembly/async

  • AssetParser

  • asset

  • asset/inline

  • asset/source

  • asset/resource

这里主要讲一下 JavascriptParser

JavascriptParser

通过 JavascriptModulePlugin 插件对 js 类型文件注入 parser 和 generator。

这里的 js 有三种类型:

  • javascript/auto

  • webpack@3 包括之前版本对 js 的默认类型,可以是 CommonJS,AMD,ESM

  • javascript/esm

  • webpack@4 为了 treeshaking 引入的 js 类型,只能处理 ESM

  • 有更严格的规则,动态引用必须是 default,不能是 namespace

  • javascript/dynamic

  • 只能处理 CommonJS 类型

说点大白话,对 acorn 来说,只有两种 sourceType:module 和 script

javascript/dynamic 就是 sourceType: script

javascript/esm 就是 sourceType: module

javascript/auto 就是先用 module parse 一下,如果失败了就再用 script parse 😉

AssetParser

这里提一下 asset,因为有四种类型,做一下说明:

  • asset

  • webpack 自动判断属于 asset/inline (文件大小 < 8kb),还是 asset/resource

  • asset/inline

  • 使用 Data URI 内联在代码中

  • asset/resource

  • 产出对应资源的文件,代码中链接形式引用

  • asset/source

  • 类似 asset/inline 但是内联的是文件源码,多用于 .txt 类型

更详细的可以查看官方文档的 Asset Modules 章节。

Generator

与 Parser 的概念想对应,自然而然可以想到有

  • JavascriptGenerator

  • JsonGenerator

  • WebAssemblyGenerator

JavascriptGenerator

这里大概讲一下对 js 文件的 generate 行为。

1. 对引用的地方,会进行修改,举个🌰:

require("./index");会替换成require(/*! ./index */"./index.ts");
复制代码

2. 替换require -> __webpack_require__

require("./index");

__webpack_require__("./index");
复制代码

3. 绑定的runtime 函数信息

由于 2 中有用到 __webpack_require__,所以需要注入这个 runtime 函数,将 Module 需要 __webpack__require__ 的信息记录下来,在最后 renderManifest 的时候在添加对应的 runtime 代码,源码位置请移步这里。

Tree Shaking

Tree shaking 是在产物中去掉没有用到代码的过程。他的命名和概念来源于另一个 bundle 工具:rollup。

举🌰:

// index.js
import { cube } from './math';

console.log(cube(2)); // 计算 2 的三次方

// math.js
export function square(x) {
  return x * x;
}

export function cube(x) {
  return x * x * x;
}
复制代码

通过我们对代码的阅读,很快就能知道 math#square 这个函数没有被使用到,可以在产物中去除来减小代码体积。现在来看一下开发环境中的产物(设置 optimization: { usedExports: true }):

/***/ "./math.js":
/*!*****************!*\
  !*** ./math.js ***!
  \*****************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "cube": () => /* binding */ cube
/* harmony export */ });
/* unused harmony export square */
function square(x) {
 return x * x;
}

function cube(x) {
 return x * x * x;
}


/***/ })
复制代码

从产物中可以看到 math#square 这个函数还在,没有被 tree shaking 掉,这是由于开发模式是不会触发去除 dead code 这个功能的,需要在生产模式下才能生效。但是可以看到 export 都加上了 harmony 的注释,而且 square 有一行 unused harmony export 这些是怎么来的呢?

首先,需要多个插件配合 HarmonyExportDependencyParserPlugin ,HarmonyImportDependencyParserPlugin,FlagDependencyUsagePlugin

  • 解析到 import { cube } from './math' 时,会对当前模块添加 HarmonyImportSpecifierDependency,在这个 dependency 下会记录被使用的 export 变量名,在这里就是记录了 cube 被使用。

  • 在 optimize 阶段,由 FlagDependencyUsagePlugin 会从 HarmonyImportSpecifierDependency 中读取被引用的信息,来给所有 export 加上是否被使用的信息

  • 在代码生成的时候,获取未使用的 exports 中含有 square,然后添加这行注释 unused harmony export square

上面说了开发环境的处理,那生产环境到底最终是怎么去除的呢?

  • HarmonyExportDependencyPlugin 看到 export 语法的时候会给当前模块添加 HarmonyExportHeaderDependency

  • 这个 dependency 的功能就是在生成代码的时候去掉 export(同上面开发环境生成的代码)

  • 最终,代码会通过 uglify 的工具去掉 dead code 即 math#square 代码,在当前版本中就是 terser 来处理这块逻辑。

结语

恭喜你,看到了这里👏。对于webpack来说,可以讲解的点实在是太多了。

文章分类
前端
文章标签