我们为什么要阅读webpack源码
相信很多人都有这个疑问,为什么要阅读源码,仅仅只是一个打包工具,会用不就行了,一些配置项在官网,或者谷歌查一查不就好了吗,诚然在大部分的时候是这样的,但这样在深入时也会遇到以下几种问题。
-
webpack 配置繁琐,具有 100 多个内置插件,200 多个钩子函数,在保持灵活配置的同时,也把问题抛给了开发者。如不同的配置项会不会对同一个功能产生影响,引用 Plugin 的先后顺序会不会影响打包结果?这些问题,不看源码是无法真正清晰的。
-
plugin 也就是插件,是 webpack 的支柱功能。开发者可以自己使用钩子函数写出插件,来丰富 webpack 的生态,也可以在自己或公司的项目中引用自己开发的插件,来去解决实际的工程问题,不去探究源码,无法理解 webpack 插件的运行,也无法写出高质量的插件。
-
从前端整体来看,现代前端的生态与打包工具高度相关,webpack 作为其中的佼佼者,了解源码,也就是在了解前端的生态圈。
Tapable浅析
首先我们要先明白什么是 Tapable,这个小型库是 webpack 的一个核心工具。在 webpack 的编译过程中,本质上通过 Tapable 实现了在编译过程中的一种发布订阅者模式的插件机制。它提供了一系列事件的发布订阅 API ,通过 Tapable 可以注册事件,从而在不同时机去触发注册的事件进行执行。
下面将会有一个模拟 webpack 注册插件的例子来尝试帮助理解。
compiler.js
const { SyncHook, AsyncParallelHook } = require('tapable');
class Compiler {
constructor(options) {
this.hooks = {
testSyncHook: new SyncHook(['name', 'age']),
testAsyncHook: new AsyncParallelHook(['name', 'age'])
}
let plugins = options.plugins;
plugins.forEach(plugin => {
plugin.apply(this);
});
}
run() {
this.testSyncHook('ggg', 25);
this.testAsyncHook('hhh', 24);
}
testSyncHook(name, age) {
this.hooks.testSyncHook.call(name, age);
}
testAsyncHook(name, age) {
this.hooks.testAsyncHook.callAsync(name, age);
}
}
module.exports = Compiler;
index.js
const Compiler = require('./complier');
const MockWebpackPlugin = require('./mock-webpack-plugin');
const complier = new Compiler({
plugins: [
new MockWebpackPlugin(),
]
});
complier.run();
mock-webpack-plugin.js
class MockWebpackPlugin {
apply(compiler) {
compiler.hooks.testSyncHook.tap('MockWebpackPlugin', (name, age) => {
console.log('同步事件', name, age);
})
compiler.hooks.testAsyncHook.tapAsync('MockWebpackPlugin', (name, age) => {
setTimeout(() => {
console.log('异步事件', name, age)
}, 3000)
})
}
}
module.exports = MockWebpackPlugin;
我相信有些小伙伴看到上述代码,就已经明白了大概的逻辑,我们只需要抓住发布和订阅这两个词,在代码中呈现的就是 tap 和 call,如果是异步钩子,使用 tapAsync, tapPromise 注册(发布),就要用 callAsync, promise(注意这里的 promise 是 Tapable 钩子实例方法,不要跟 Promise API 搞混) 触发(订阅)。
发布
compiler.hooks.testSyncHook.tap('MockWebpackPlugin', (name, age) => {
console.log('同步事件', name, age);
})
compiler.hooks.testAsyncHook.tapAsync('MockWebpackPlugin', (name, age) => {
setTimeout(() => {
console.log('异步事件', name, age)
}, 3000)
})
这里可以看到使用 tab 和 tabAsync 进行注册,在什么时机注册的呢,在 Compiler 类的初始化时期,也就是在通过 new 命令生成对象实例的时候,下面的代码已经在 constructor 中被调用并执行了,当然这个时候并没有像函数一样被调用,打印出来姓名和年龄,这时我们只需要先知道,它们已经被注册了。
订阅
run() {
this.testSyncHook('ggg', 25);
this.testAsyncHook('hhh', 24);
}
testSyncHook(name, age) {
this.hooks.testSyncHook.call(name, age);
}
testAsyncHook(name, age) {
this.hooks.testAsyncHook.callAsync(name, age);
}
通过 compiler.run() 命令将会执行下面两个函数,使用 call 和 callAsync 订阅。这个时候就会执行 console.log 来打印姓名和年龄了,所以说此时我们就能明白 webpack 中 compiler 和 compilation 中的钩子函数是以触发的时期进行区分,归根结底,是注册的钩子在 webpack 不同的编译时期被触发。
注意事项
这里要注意在初始化 Tapable Hook 的同时,要加上参数,传入参数的数量需要与实例化时传递给钩子类构造函数的数组长度保持一致。
this.hooks = {
testSyncHook: new SyncHook(['name', 'age']),
testAsyncHook: new AsyncParallelHook(['name', 'age'])
}
这里并非要严格的传入 ['name', 'age'],你也可以取其它的名字,如 ['fff', 'ggg],但是为了语义化,还是要进行规范,如下方代码,截取自源码中的 lib/Compiler.js 片段,它们在初始化中也是严格按照了这个规范。
/** @type {AsyncSeriesHook<[Compiler]>} */
beforeRun: new AsyncSeriesHook(["compiler"]),
/** @type {AsyncSeriesHook<[Compiler]>} */
run: new AsyncSeriesHook(["compiler"]),
/** @type {AsyncSeriesHook<[Compilation]>} */
emit: new AsyncSeriesHook(["compilation"]),
更具体的可以查看这篇文章 走进 Tapable - 掘金 (juejin.cn)
如何调试
想调试 webpack 源码,一般有两种方式,一种是 clone 调试,一种是 npm 包调试,笔者这里选择通过 clone 调试,运行 webpack 也有两种方式,一是通过 webpack-cli 输入命令启动,另外一种如下,引入 webapck,使用 webpack.run() 启动。
准备工作
首先可以用 https 从 github 上克隆 webpack 源码。
git clone https://github.com/webpack/webpack
npm install
之后可以在根目录创建一个名为 source 的文件夹,source 文件夹目录如下
-- webpack
-- source
-- src
-- foo.js
-- main.js
-- index.html
-- index.js
-- webpack.config.js
index.js
const webpack = require('../lib/index.js');
const config = require('./webpack.config.js');
const complier = webpack(config);
complier.run((err, stats) => {
if (err) {
console.error(err);
} else {
console.log(stats);
}
})
webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
devtool: 'source-map',
entry: './src/main.js',
output: {
path: path.join(__dirname, './dist'),
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
exclude: /node_modules/,
}
]
},
plugins: [
new HtmlWebpackPlugin({
title: 'Test Webpack',
template: './index.html',
filename: 'template.html'
})
]
}
引用 html-webpack-plugin 和 babel-loader 主要是想更清晰看到在构建过程中 webpack 会如何处理引入的 plugin 和 loader。
main.js
import foo from './foo.js';
import { isEmpty } from 'lodash';
foo();
const obj = {};
console.log(isEmpty(obj));
console.log('main.js');
foo.js
export default function foo() {
console.log('foo');
}
文件创建好了,这里使用 Vscode 进行调试, 打开 JavaScript 调试终端。
源码阅读
按照下面命令,启动 webpack
cd source
node index.js
这里为了更加清晰, 可以打上一个断点。如在 lib/webpack.js 中,将断点打在 158 行,查看是如何生成的 compiler 实例。
这里需要点击单步调试,这样才能进入 create 函数中,一步步调试可以看到,首先会对传入的 options 进行校验, 如果不符合规范,将会抛出错误,由于这里的 options 是一个对象,将会进入到 createCompiler 函数内。
在这个函数内将会创造 Compiler 实例,以及注册引入的插件和内置插件。
笔者将会一步步的讲解这个函数都做了什么事,如
applyWebpackOptionsBaseDefaults:给没设置的基本配置加上默认值。
new Compiler:生成 compiler 实例,初始化一些钩子和参数。
NodeEnvironmentPlugin:主要是对文件模块进行了封装和优化,感兴趣的读者可以打断点,详细去查看。
接下来要做的事情就是注册钩子,如上文中引入了 html-webpack-plugin, 这里将会调用 HtmlWebpackplugin 实例的 apply 函数,这样就能明白为什么以 class 类的方式,写插件,为什么里面一定要加上 apply。紧接着创建完 compiler 实例后,正如官网上描述的,关于 compiler.hooks.environment 的订阅时期,在编译器准备环境时调用,时机就在配置文件中初始化插件之后。我们就能知其然,也能知所以然了。
再往下,
new WebpackOptionsApply().process(options, compiler):注册了内部插件,如 DllPlugin, HotModuleReplacementPlugin 等。
小技巧分享
这里简单分享了笔者看源码的步骤,然后还有两个技巧分享。
一是由于 webpack 运用了大量回调函数,一步步打断点是很难看的清楚的,可直接在 Vscode 中全局搜索 compiler.hooks.xxx 和 compilation.hooks.xxx, 去看 tap 中回调函数的执行。
二是可在 Vscode 调试中的 watch 模块,添加上 compiler 和 compilation,这样也是更方便观察回调函数的执行。如
总结
webpack 中的细节很是繁多,里面有大量的异常处理,在看的时候要有重点的看,有选择的看,如果你要看 make 阶段所做的事情, 可以重点去看如何生成模块,模块分为几种,如何递归处理依赖,如何使用 loader 解析文件等。笔者认为看源码还有一个好处,那就是让你对这些知名开源库没有畏惧心理,它们也是用 js 一行行写的,里面会有一些代码片段,可能写的也没有那么优美,我们在阅读代码的同时,说不定也能成为代码贡献者,能够在简历上留下浓墨重彩的一笔。
作者:百宝门-前端组-闫磊刚