加载本地 Loader
在开发 Loader 的过程中,为了测试编写的 Loader 是否能正常工作,需要把它配置到 Webpack 中后,才可能会调用该 Loader。 在前面的章节中,使用的 Loader 都是通过 Npm 安装的,要使用 Loader 时会直接使用 Loader 的名称,代码如下:
module.exports = { module: { rules: [ { test: /\.css/, use: ['style-loader'], }, ] }, };
如果还采取以上的方法去使用本地开发的 Loader 将会很麻烦,因为你需要确保编写的 Loader 的源码是在 node_modules
目录下。 为此你需要先把编写的 Loader 发布到 Npm 仓库后再安装到本地项目使用。
解决以上问题的便捷方法有两种,分别如下:
Npmlink
Npm link 专门用于开发和调试本地 Npm 模块,能做到在不发布模块的情况下,把本地的一个正在开发的模块的源码链接到项目的 node_modules
目录下,让项目可以直接使用本地的 Npm 模块。 由于是通过软链接的方式实现的,编辑了本地的 Npm 模块代码,在项目中也能使用到编辑后的代码。
完成 Npm link 的步骤如下:
- 确保正在开发的本地 Npm 模块(也就是正在开发的 Loader)的
package.json
已经正确配置好; - 在本地 Npm 模块根目录下执行
npm link
,把本地模块注册到全局; - 在项目根目录下执行
npm link loader-name
,把第2步注册到全局的本地 Npm 模块链接到项目的node_moduels
下,其中的loader-name
是指在第1步中的package.json
文件中配置的模块名称。
链接好 Loader 到项目后你就可以像使用一个真正的 Npm 模块一样使用本地的 Loader 了。
ResolveLoader
ResolveLoader 用于配置 Webpack 如何寻找 Loader。 默认情况下只会去 node_modules
目录下寻找,为了让 Webpack 加载放在本地项目中的 Loader 需要修改 resolveLoader.modules
。
假如本地的 Loader 在项目目录中的 ./loaders/loader-name
中,则需要如下配置:
module.exports = { resolveLoader:{ // 去哪些目录下寻找 Loader,有先后顺序之分 modules: ['node_modules','./loaders/'], } }
加上以上配置后, Webpack 会先去 node_modules
项目下寻找 Loader,如果找不到,会再去 ./loaders/
目录下寻找。
实战
上面讲了许多理论,接下来从实际出发,来编写一个解决实际问题的 Loader。
该 Loader 名叫 comment-require-loader
,作用是把 JavaScript 代码中的注释语法:
// @require '../style/index.css'
转换成:
require('../style/index.css');
该 Loader 的使用场景是去正确加载针对 Fis3 编写的 JavaScript,这些 JavaScript 中存在通过注释的方式加载依赖的 CSS 文件。
该 Loader 的使用方法如下:
module.exports = { module: { rules: [ { test: /\.js$/, use: ['comment-require-loader'], // 针对采用了 fis3 CSS 导入语法的 JavaScript 文件通过 comment-require-loader 去转换 include: [path.resolve(__dirname, 'node_modules/imui')] } ] } };
该 Loader 的实现非常简单,完整代码如下:
1. function replace(source) { 2. // 使用正则把 // @require '../style/index.css' 转换成 require('../style/index.css'); 3. return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);'); 4. } 5. 6. module.exports = function (content) { 7. return replace(content); 8. };
编写 Plugin
Webpack 通过 Plugin 机制让其更加灵活,以适应各种应用场景。 在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
一个最基础的 Plugin 的代码是这样的:
class BasicPlugin{ // 在构造函数中获取用户给该插件传入的配置 constructor(options){ } // Webpack 会调用 BasicPlugin 实例的 apply 方法给插件实例传入 compiler 对象 apply(compiler){ compiler.plugin('compilation',function(compilation) { }) } } // 导出 Plugin module.exports = BasicPlugin;
在使用这个 Plugin 时,相关配置代码如下:
const BasicPlugin = require('./BasicPlugin.js'); module.export = { plugins:[ new BasicPlugin(options), ] }
Webpack 启动后,在读取配置的过程中会先执行 newBasicPlugin(options)
初始化一个 BasicPlugin
获得其实例。 在初始化 compiler
对象后,再调用 basicPlugin.apply(compiler)
给插件实例传入 compiler
对象。 插件实例在获取到 compiler
对象后,就可以通过 compiler.plugin(事件名称,回调函数)
监听到 Webpack 广播出来的事件。 并且可以通过 compiler
对象去操作 Webpack。
通过以上最简单的 Plugin 相信你大概明白了 Plugin 的工作原理,但实际开发中还有很多细节需要注意,下面来详细介绍。
Compiler
和 Compilation
在开发 Plugin 时最常用的两个对象就是 Compiler 和 Compilation,它们是 Plugin 和 Webpack 之间的桥梁。 Compiler 和 Compilation 的含义如下:
- Compiler 对象包含了 Webpack 环境所有的的配置信息,包含
options
,loaders
,plugins
这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例; - Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。
Compiler 和 Compilation 的区别在于:Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。
事件流
Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。
Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。
Webpack 的事件流机制应用了观察者模式,和 Node.js 中的 EventEmitter 非常相似。Compiler 和 Compilation 都继承自 Tapable,可以直接在 Compiler 和 Compilation 对象上广播和监听事件,方法如下:
/** * 广播出事件 * event-name 为事件名称,注意不要和现有的事件重名 * params 为附带的参数 */ compiler.apply('event-name',params); /** * 监听名称为 event-name 的事件,当 event-name 事件发生时,函数就会被执行。 * 同时函数中的 params 参数为广播事件时附带的参数。 */ compiler.plugin('event-name',function(params) { });
同理, compilation.apply
和 compilation.plugin
使用方法和上面一致。
在开发插件时,你可能会不知道该如何下手,因为你不知道该监听哪个事件才能完成任务。
在开发插件时,还需要注意以下两点:
- 只要能拿到 Compiler 或 Compilation 对象,就能广播出新的事件,所以在新开发的插件中也能广播出事件,给其它插件监听使用。
- 传给每个插件的 Compiler 和 Compilation 对象都是同一个引用。也就是说在一个插件中修改了 Compiler 或 Compilation 对象上的属性,会影响到后面的插件。
- 有些事件是异步的,这些异步的事件会附带两个参数,第二个参数为回调函数,在插件处理完任务时需要调用回调函数通知 Webpack,才会进入下一处理流程。例如:
compiler.plugin('emit',function(compilation, callback) { // 支持处理逻辑 // 处理完毕后执行 callback 以通知 Webpack // 如果不执行 callback,运行流程将会一直卡在这不往下执行 callback(); });
常用 API
插件可以用来修改输出文件、增加输出文件、甚至可以提升 Webpack 性能、等等,总之插件通过调用 Webpack 提供的 API 能完成很多事情。 由于 Webpack 提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面来介绍一些常用的 API。
读取输出资源、代码块、模块及其依赖
有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下一步处理。
在 emit
事件发生时,代表源文件的转换和组装已经完成,在这里可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。
监听文件变化
Webpack 会从配置的入口模块出发,依次找出所有的依赖模块,当入口模块或者其依赖的模块发生变化时, 就会触发一次新的 Compilation。
在开发插件时经常需要知道是哪个文件发生变化导致了新的 Compilation
默认情况下 Webpack 只会监视入口和其依赖的模块是否发生变化,在有些情况下项目可能需要引入新的文件,例如引入一个 HTML 文件。 由于 JavaScript 文件不会去导入 HTML 文件,Webpack 就不会监听 HTML 文件的变化,编辑 HTML 文件时就不会重新触发新的 Compilation。 为了监听 HTML 文件的变化,我们需要把 HTML 文件加入到依赖列表中,为此可以使用如下代码:
compiler.plugin('after-compile', (compilation, callback) => { // 把 HTML 文件添加到文件依赖列表,好让 Webpack 去监听 HTML 模块文件,在 HTML 模版文件发生变化时重新启动一次编译 compilation.fileDependencies.push(filePath); callback(); });
修改输出资源
有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听 emit
事件,因为发生 emit
事件时所有模块的转换和代码块对应的文件已经生成好, 需要输出的资源即将输出,因此 emit
事件是修改 Webpack 输出资源的最后时机。
所有需要输出的资源会存放在 compilation.assets
中, compilation.assets
是一个键值对,键为需要输出的文件名称,值为文件对应的内容。
设置 compilation.assets
的代码如下:
compiler.plugin('emit', (compilation, callback) => { // 设置名称为 fileName 的输出资源 compilation.assets[fileName] = { // 返回文件内容 source: () => { // fileContent 既可以是代表文本文件的字符串,也可以是代表二进制文件的 Buffer return fileContent; }, // 返回文件大小 size: () => { return Buffer.byteLength(fileContent, 'utf8'); } }; callback(); });
读取 compilation.assets
的代码如下:
compiler.plugin('emit', (compilation, callback) => { // 读取名称为 fileName 的输出资源 const asset = compilation.assets[fileName]; // 获取输出资源的内容 asset.source(); // 获取输出资源的文件大小 asset.size(); callback(); });
判断 Webpack 使用了哪些插件
在开发一个插件时可能需要根据当前配置是否使用了其它某个插件而做下一步决定,因此需要读取 Webpack 当前的插件配置情况。 以判断当前是否使用了 ExtractTextPlugin 为例,可以使用如下代码:
// 判断当前配置使用使用了 ExtractTextPlugin, // compiler 参数即为 Webpack 在 apply(compiler) 中传入的参数 function hasExtractTextPlugin(compiler) { // 当前配置所有使用的插件列表 const plugins = compiler.options.plugins; // 去 plugins 中寻找有没有 ExtractTextPlugin 的实例 return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null; }
实战
下面我们举一个实际的例子,带你一步步去实现一个插件。
该插件的名称取名叫 EndWebpackPlugin,作用是在 Webpack 即将退出时再附加一些额外的操作,例如在 Webpack 成功编译和输出了文件后执行发布操作把输出的文件上传到服务器。 同时该插件还能区分 Webpack 构建是否执行成功。使用该插件时方法如下:
module.exports = { plugins:[ // 在初始化 EndWebpackPlugin 时传入了两个参数,分别是在成功时的回调函数和失败时的回调函数; new EndWebpackPlugin(() => { // Webpack 构建成功,并且文件输出了后会执行到这里,在这里可以做发布文件操作 }, (err) => { // Webpack 构建失败,err 是导致错误的原因 console.error(err); }) ] }
要实现该插件,需要借助两个事件:
done
:在成功构建并且输出了文件后,Webpack 即将退出时发生;failed
:在构建出现异常导致构建失败,Webpack 即将退出时发生;
实现该插件非常简单,完整代码如下:
class EndWebpackPlugin { constructor(doneCallback, failCallback) { // 存下在构造函数中传入的回调函数 this.doneCallback = doneCallback; this.failCallback = failCallback; } apply(compiler) { compiler.plugin('done', (stats) => { // 在 done 事件中回调 doneCallback this.doneCallback(stats); }); compiler.plugin('failed', (err) => { // 在 failed 事件中回调 failCallback this.failCallback(err); }); } } // 导出插件 module.exports = EndWebpackPlugin;
从开发这个插件可以看出,找到合适的事件点去完成功能在开发插件时显得尤为重要。 在 工作原理概括 中详细介绍过 Webpack 在运行过程中广播出常用事件,你可以从中找到你需要的事件。
调试 Webpack
在编写 Webpack 的 Plugin 和 Loader 时,可能执行结果会和你预期的不一样,就和你平时写代码遇到了奇怪的 Bug 一样。 对于无法一眼看出问题的 Bug,通常需要调试程序源码才能找出问题所在。
虽然可以通过 console.log
的方式完成调试,但这种方法非常不方便也不优雅,本节将教你如何断点调试 工作原理概括 中的插件代码。 由于 Webpack 运行在 Node.js 之上,调试 Webpack 就相对于调试 Node.js 程序。
在 Webstorm 中调试
Webstorm 集成了 Node.js 的调试工具,因此使用 Webstorm 调试 Webpack 非常简单。
1. 设置断点
在你认为可能出现问题的地方设下断点,点击编辑区代码左侧出现红点表示设置了断点。
2. 配置执行入口
告诉 Webstorm 如何启动 Webpack,由于 Webpack 实际上就是一个 Node.js 应用,因此需要新建一个 Node.js 类型的执行入口。
以上配置中有三点需要注意:
Name
设置成了debug webpack
,就像设置了一个别名,方便记忆和区分;Workingdirectory
设置为需要调试的插件所在的项目的根目录;JavaScriptfile
即 Node.js 的执行入口文件,设置为 Webpack 的执行入口文件node_modules/webpack/bin/webpack.js
。
3. 启动调试
经过以上两步,准备工作已经完成,下面启动调试,启动时选中前面设置的 debug webpack
。
4. 执行到断点
启动后程序就会停在断点所在的位置,在这里你可以方便的查看变量当前的状态,找出问题。
原理总结
Webpack 是一个庞大的 Node.js 应用,如果你阅读过它的源码,你会发现实现一个完整的 Webpack 需要编写非常多的代码。 但你无需了解所有的细节,只需了解其整体架构和部分细节即可。
对 Webpack 的使用者来说,它是一个简单强大的工具; 对 Webpack 的开发者来说,它是一个扩展性的高系统。
Webpack 之所以能成功,在于它把复杂的实现隐藏了起来,给用户暴露出的只是一个简单的工具,让用户能快速达成目的。 同时整体架构设计合理,扩展性高,开发扩展难度不高,通过社区补足了大量缺失的功能,让 Webpack 几乎能胜任任何场景。