想知道如何高效读懂 Webpack 源码, 然后吊打面试官? 那就进来吧.

3,134 阅读10分钟

前言

我在之前写过的文章中提到过程序的本质是基于数据结构来设计算法, 也提到了程序的两面性, 但是对于大多数前端程序员来说未免太过抽象, 正好我一直试图用一种更好的方式去分析代码, 加上对 Webpack 兴趣浓厚, 本着授人以鱼不如授人以渔的思想, 写再多的源码解析, 不如告诉你如何读懂源码

我看过很多同学喜欢干读, 直接从生成代码硬读逻辑, 干的崩牙就不提了, 我觉得这实在不是太聪明的做法, 并且读完之后你大概率是记不住的. 而且你觉得自己读懂了, 其实你什么也没读懂, 因为你没读懂程序背后的设计.

首先澄清下我并不是标题党, 但是考虑到想做些知识科普, 又无奈现在你们只想看面试相关的东西, 因此作为面试官只好被自己吊打下了 😭

正文

怎么理解 "读懂源码"

在一些书籍中有提到过好的代码应该是自文档的, 什么是自文档 ? 所谓自文档就是代码本身就是设计文档, 通过阅读代码你能够理解程序背后的设计思路. 要做到这点就必须从程序设计入手来构思代码的结构和逻辑, 那什么是一个程序的结构呢?

程序的结构

任何一个程序都有自己的结构, 这种结构一定程度上受到编程范式的影响, 比如面向对象和面向过程是两种结构, 面向过程和函数式又是两种结构, 这里我们分析的对象主要是基于 JavaScript 开发的程序, 遵循 JavaScript 提供的结构定义, 虽然 JavaScript 具有多范式的特性, 不过大多数情况下我们认为他是面向对象的, 这和最初的基于原型的继承方式可以说关联很大.

因此 JavaScript 开发的程序在结构上具有面向对象的特征, 那么在 ES6 面世之后, 这种结构基本上就是由模块来定义的, 或者说 JavaScript 程序的结构就是由模块构成的

因此读懂源码的第一步就是要分析源码即程序的结构(模块), 那如何分析模块呢?

模块是数据结构和算法的载体

在 JavaScript 程序里, 一个模块通常是指一个文件, 但是这样定义模块对于分析源码来说难度很大, 所以我这里将模块的概念进一步抽象, 你可以理解模块是指一个或多个文件, 那如何确认一个模块应该包含哪些文件呢 ? 主要的依据是, 模块内的数据结构是否有关联性, 算法是否围绕关联数据结构来设计. 简单的讲, 你可以从文件命名来猜测这几个文件是否属于相同的模块

并不是所有程序的源码都是可读的, 不好的程序设计和糟糕的命名就像一本盗版或者乱写的书, 你根本无法从中理清脉络, 对于这种程序分析需要一些工具辅助, 后续我会看一篇讲讲如何去读这种程序的源码, 一般面向业务的程序大多数呈现这种特征.

比如在 Webpack 中代码主要集中在 lib 目录下

在这里我们必须先假设 Webpack 的文件命名是符合他自身设计的, 后面会提到如何去验证这种假设, 可以看到在 lib 目录下其实包含很多的文件夹, 这些文件夹本身就是一个模块, 比如 runtime, 不用看就知道里面肯定是负责运行时的一些代码, 为此我们可以来验证下. 比如我们打开 runtime/CompatGetDefaultExportRuntimeModule.js 这个文件, 看到里面有这样一段代码

/**
	 * @returns {string} runtime code
	 */
	generate() {
		const { runtimeTemplate } = this.compilation;
		const fn = RuntimeGlobals.compatGetDefaultExport;
		return Template.asString([
			"// getDefaultExport function for compatibility with non-harmony modules",
			`${fn} = ${runtimeTemplate.basicFunction("module", [
				"var getter = module && module.__esModule ?",
				Template.indent([
					`${runtimeTemplate.returningFunction("module['default']")} :`,
					`${runtimeTemplate.returningFunction("module")};`
				]),
				`${RuntimeGlobals.definePropertyGetters}(getter, { a: getter });`,
				"return getter;"
			])};`
		]);
	}

看上面的注释了么 runtime code, 然后看内容一个代码生成的函数, 所以你基本可以笃定 runtime 文件夹下的文件主要都是负责构造运行时包裹的代码的, 这就像你看一本书, 如果书的目录上写着 "xxx - xxxxx" 然后你发现这部分内容你暂时并不关心, 你就可以跳过不看. 其实这一段已经表达了我的第二个技巧, 如何看懂程序的逻辑

程序的逻辑

当你能看懂一个程序的结构, 可以说基本把握住了程序的总体设计, 即便你不知道其中的细节, 但是你会了解这个程序大概包括哪些模块, 这些模块的职责分工是什么, 然后这个程序的处理流程, 能解决哪些问题, 在此基础上你可以通过了解这些模块的内部细节来读懂程序的逻辑, 而程序的逻辑相当于一个程序的详细设计. 比如上面提到的 runtime 模块下的 CompatGetDefaultExportRuntimeModule.js 这个文件, 我们试着来读懂这个文件的详细设计

首先你要记住一点, 即 JavaScript 文件一般使用两种代码抽象方式, 函数, 看这个文件的第一行

class CompatGetDefaultExportRuntimeModule extends HelperRuntimeModule

你就知道这是一个类, 并且继承自 HelperRuntimModule, 其次是包含一个实例方法

generate(){}

这个方法的入参是空的, 返回的是 {string} runtime code.

在此我们不看 generate 函数实现的代码, 单纯从这个详细设计来看我们能获得哪些信息

这是一个继承自 HelperRuntimeModule 的子类, 从类名上看, 这个类主要负责兼容默认导出的运行时模块代码, 从 generate 的内部代码来看也确实如此

"var getter = module && module.__esModule ?",
				Template.indent([
					`${runtimeTemplate.returningFunction("module['default']")} :`,
					`${runtimeTemplate.returningFunction("module")};`
				]),

这里其实判断了是否 esModule 然后输出了不同的 default module 代码.

因此读懂代码的逻辑并不是从内部代码开始读, 而是先从这个文件内类或者函数的详细设计开始读, 如果我要给 Webpack 源码写本书的话, 那上面这些内容就差不多是这样一个章节

第 N 章 runtime 模块
Webpack 如何处理默认的 Default 代码导出 ---- P102

Webpack 在 runtime 模块内定义了 CompatGetDefaultExportRuntimeModule 这个 HelperRuntimeModule 的子类, 通过子类的实例方法 generate 来生成具有判断能力的运行时代码.

总结下, 一个程序的结构约等于一本书的目录, 程序的逻辑约等于一本书的该目录下的详细内容.

对 runtime 不感兴趣, 想知道面试时候的如何回答那些原理性问题?

我知道你们其实对 runtime 兴趣不大, 一般面试很少会问到这个, 因此那就来一点有价值的例子, 让我们一步一步来分析下大概率会被问到的问题, 作为一个混迹多年的面试官, 我理解大多数面试官的套路在 Webpack 上可能会问你两样东西, Plugin 和 Loader, 让我们先来看看 Webpack 对于 Plugin 的设计是怎样的

首先这里可能有个前提, 就是读源码之前先得把依赖摸清楚, 我在之前的文章中提到过 Webpack 内部使用的 JavaScript 解析器是 acorn, 同理对于 Plugin Webpack 也有自己的核心依赖, 那就是 tapable, 在读 Plugin 相关的模块之前, 我们先对 tapable 有个了解, 翻开 tapable 的 GitHub 主页, 注意这句话

The tapable package expose many Hook classes, which can be used to create hooks for plugins.

现在你对 tapable 应该有个印象, 那就是这是一个钩子库, 为 plugin 提供各种钩子, 那什么是钩子, 别急让我们回到 Webpack 的源码, 现在我们知道 tapable 是 plugin 的核心依赖, 那 plugin 的模块内一定会有 require 的代码, 搜索下, 搜索结果不多, 不过我们需要做排除, 这里有个小技巧, 那就是看名字, 从若干搜索结果中你可以发现最符合插件相关的模块其实就两个文件

  • lib/Compilation.js
  • lib/Compiler.js

还记得 Webpack 官方提到的编写插件核心要知道的概念么, Compiler, 因此基本可以笃定负责 Plugin 机制的主要就是这两个文件了, 继续之前的思路, 总体设计明了了, 那我们需要看看这些文件内的详细设计, 先看 Compiler 的详细设计…. 很长, 但是核心的代码其实在这里

class Compiler {
	/**
	 * @param {string} context the compilation path
	 */
	constructor(context) {
		this.hooks = Object.freeze({

如果你仔细看 tapable 的 README, 你会发现这里的代码和用例上的基本上是一样的, tapable 和一般的事件管道并不相同, 他更像是为 Webpack 插件量身定制的一种事件管道, 并不是我们所熟知的发布订阅模式

所这里面试就会有坑, 比如面试官问你 Webpack 的 Plugin 机制是咋回事, 如果你说是一种基于事件的, 那他会问具体的呢, 如果你知道 tapable 就可以讲讲这种 hook 模式和一般发布订阅模式的区别, 但是如果你不了解这块设计最好不要瞎答, 直接来个那就是个发布订阅...凉凉

对于如此冗长复杂的详细设计, 我是不会干啃的, 毕竟我没有 00 后这么好的牙口, 所以这里又会有一个小技巧, 那就是当详细设计太过细节, 太过复杂的时候, 我们要学会从用例入手, 什么是用例?

用例就是官方文档给开发者看的文档里关于如何使用的这部分, 比如关于 Plugin, 官网上会有这样一段描述

Basic plugin architecture

Plugins are instantiated objects with an apply method on their prototype. This apply method is called once by the webpack compiler while installing the plugin. The apply method is given a reference to the underlying webpack compiler, which grants access to compiler callbacks. A simple plugin is structured as follows:

class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('Hello World Plugin', (
      stats /* stats is passed as an argument when done hook is tapped.  */
    ) => {
      console.log('Hello World!');
    });
  }
}

module.exports = HelloWorldPlugin;

然后在看看 Webpack 的入口文件 lib/webpack.js, 里面有这样一段代码

if (Array.isArray(options.plugins)) {
		for (const plugin of options.plugins) {
			if (typeof plugin === "function") {
				plugin.call(compiler, compiler);
			} else {
				plugin.apply(compiler);
			}
		}
	}

传入的这个 compiler 就是 Compiler 的实例

const compiler = new Compiler(options.context);

结合这些分析内容, 关于 Plugin 部分可以做个问答总结

面试官: "了解 Webpack 插件的内部原理么?"

答: "Webpack 的 Plugin 机制的核心实现是 tapable, tapable 是为 Plugin 打造的一个钩子管道, 可以允许插件触发在 compiler 上定义的各种钩子 (这里可能对于实际写过插件的同学会问到一些具体的 api, 这里问的主要是核对是否有真实的插件开发经验, 但不涉及对原理的掌握)"

对于一般面试可能止步于此, 但是对于那些经验老道的面试官一定会继续深挖

面试官: "那你怎么理解 compiler"

答: "compiler 通过 tapable 本身提供的 hook 机制定义了各种钩子, 这些钩子涵盖了 Webpack 执行的构建的整个周期"

面试官: "官网的例子上还有关于 compilation 这个的描述, 能讲讲和 compiler 有什么不同么?"

这里还是需要用到上面的那套分析思路, 记得我说的 Plugin 模块有两个文件么, 除了 compiler.js 另一个就是 compilation.js, 如果你仔细阅读其中的详细设计, 你会发现 compilation 的钩子定义主要和 module 和 chunk 处理相关

答: "compilation 通过 compiler 来创建, 内部也是基于 tapable, 不过 compilation 的钩子主要针对是 chunk 和 module 的处理, 可以看成是两条不同的处理管道"

大部分面试官可能会止步于此, 但是对于那些混迹江湖多年的骨灰级面试官, 接下来的问题可能就要直击灵魂了

面试官: "哦, 那你能说说 tapable 的内部设计么?" , "你提到了 compilation 主要负责 chunk 和 module 的处理流程, 那让我们来聊聊 chunk 和 module 相关的内容吧… 又是一套灵魂三连"

想知道如何应对骨灰级面试官的拷问? 点个赞, 我们下期见!!!

后话

本文主要提供一种阅读源码的思路, 其实好的源代码本身就是一本书, 只是对于缺乏经验的大部分初学者可能会被密密麻麻的代码给吓退, 另外本文中提到的回答只是一个总结...切忌照搬照抄, 想要通过面试, 你还是应该静下心来按照我给你的思路, 仔细阅读源码, 你可以选择你感兴趣额部分, 尽量不要干那种从头到尾读, 干到崩牙的愚蠢方式, 那样会让你对阅读源码这件事彻底丧失兴趣...