彻底掌握 Webpack 中 Loader 和 Plugin 的机制
概述
因为忙好久没有更新文章了,正好在公司内部做了技术分享,私底下写了篇文章。希望给有需要的小伙伴带来帮助,一起成长~💪
通过本文你可以了解到一下知识点:
Webpack
配置中Loader
的用法Webpack
配置中Plugin
的用法Loader
和Plugin
的区别- 如何编写一个自定义的
Loader
- 如何编写一个自定义的
Plugin
众所周知,Webpack
只能处理 JavaScript
和 JSON
文件,其他类型的文件只能通过对应的 loader
进行转换成 JavaScript
代码供 Webpack
进行打包!

探索 Loader
Loader
就是一个代码转码器,对各种资源进行转换。接下来我们从它的特点
、分类
、用法
以及执行顺序
等方面来彻底了解 Loader
的本质,从而为我们实现自定义 Loader
以及了解底层原理做铺垫。
Loader
的特点:单一原则,每个Loader
只做对应的事情Loader
的分类:pre
、normal
(默认)、inline
、post
四种Loader
的用法:单个loader
、多个loader
、对象形式Loader
的顺序:从右到左,从下到上
总结:Loader
就是一个函数,接受原始资源作为参数,输出进行转换后的内容。
Loader 的基本用法
接下来,我们创建一个项目来探索 Loader
在 Webpack
中是如何使用的,有哪些需要注意的点以及如何自定义一个 Loader
。
mkdir webpack-loader-plugin
cd webpack-loader-plugin
npm init -y
npm install webpack webpack-cli -D
安装好依赖和创建好对应的目录后,创建 webpack
的配置文件 webpack.config.js
,来尝试一下 loader
的用法,并引入一个非 js 类型
的文件然后进行打包,很显然打包是报错的,并友好的提示你:You may need an appropriate loader to handle this file type
. 所以在 webpack 中加载非 js 类型
的文件都需要通过对应的 loader
来进行转换后再进行打包。我们以 .css
样式文件为例:
// webpack.config.js
const path = require('path')
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
}
以上是 loader
的特点和基本用法,下面通过自定义 loader
来掌握 loader
的分类以及它的顺序顺序。
自定义 Loader
我们在项目中创建一个 loader
的文件夹来存放我们自定义的 loader1.js
、loader2.js
、loader3.js
、loader4.js
,修改 webpack.config.js
配置:
const path = require('path')
module.exports = {
...
module: {
rules: [
{
test: /\.js$/,
use: [path.resolve(__dirname, 'loader/loader1.js'), path.resolve(__dirname, 'loader/loader2.js'), path.resolve(__dirname, 'loader/loader3.js'), path.resolve(__dirname, 'loader/loader4.js')]
}
]
}
}
loader
的引入方式:
-
- 通过
npm
包安装的loader
,直接使用名称即可
{ test: /\.css$/, use: 'css-loader' }
- 通过
-
- 自定义
loader
时,使用绝对路径
{ test: /\.css$/, use: path.resolve(__dirname, 'loader/loader1.js') }
- 自定义
-
- 配置别名方式
resolveLoader: { alias: { 'loader1': path.resolve(__dirname, 'loader/loader1.js') } }, module: { rules: [ { test: /\.css$/, use: 'loader1' } ] }
-
- 配置查找方式
resolveLoader: { modules: ['node_modules', path.resolve(__dirname, 'loader')] }, module: { rules: [ { test: /\.css$/, use: 'loader1' } ] }
loader
的执行顺序:
从上面的例子可知:普通 loader
的执行顺序:从右向左、从下到上
。
但是通过改变 loader
的四种类型的执行顺序:pre
、normal
、inline
、post
。这样我们就可以随意的改变 loader
的执行顺序。
{
test: /.js$/,
use: 'loader1',
enforce: 'pre'
},
{
test: /.js$/,
use: 'loader2'
},
{
test: /.js$/,
use: 'loader3',
enforce: 'post'
}
若:不加 enforce
属性时,加载顺序:从下到上,依次为:loader3
-> loader2
-> loader1
; 但加上以上 enforce
属性配置后,加载顺序改变了依次为:loader1
-> loader2
-> loader3
inline loader
的加载规则
!
: 忽略normal loader
-!
: 忽略normal loader
、pre loader
!!
: 忽略normal loader
、pre loader
、post loader
总结:通过前缀来设置禁用不同种类的 loader
Picth 方法
loader
的执行分为两个阶段:Pitch 阶段
和Noraml 阶段
。在定义一个 loader
函数时,可以导出一个 pitch
方法,这个方法会在 loader
函数执行前执行。
loader
会先执行 pitch
,然后获取资源再执行 normal loader
。如果 pitch
有返回值时,就不会走之后的 loader
,并将返回值返回给之前的 loader
。这就是为什么 pitch
有 熔断 的作用!
// loader/loader1.js
function loader1(source) {
console.log('loader1~~~~~~', source)
return source
}
module.exports = loader1
module.exports.pitch = function (res) {
console.log('pitch1')
}
// loader/loader2.js 同理
// loader/loader3.js 同理
// loader/loader4.js 同理
获取参数
想要获取用户传入的参数时,需要下载一个依赖:
npm install loader-utils -D // 注意loader-utils@2.0.0版本
loader-utils
依赖中有个 getOptions
方法,用来获取 loader
中 options
的配置
// webpack.config.js
{
test: /.js$/,
use: [
{
loader: 'loader1',
options: {
name: 'tmc'
}
}
]
}
// loader1.js
const loaderUtil = require('loader-utils')
function loader1 (source) {
const options = loaderUtil.getOptions(this) || {}
console.log(options)
return source
}
module.exports = loader1
// 输出:{ name: 'tmc' }
验证参数
想要验证用户传入的参数是否合法,需要下载一个依赖:
npm install schema-utils -D
schema-utils
依赖中有个 validate
方法,用来验证 loader
中 options
的配置是否合法
// loader/loader1.js
const { getOptions } = require('loader-utils')
const { validate } = require('schema-utils')
const schemaJson = require('./schema.json')
function loader1 (source) {
const options = getOptions(this) || {}
validate(schemaJson, options, { name: 'loader1' })
return source
}
module.exports = loader1
// schema.json
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "名称"
}
},
"additionalProperties": true
}
properties
中的键名就是我们需要检验的 options
中的字段名称,additionalProperties
代表了是否允许 options
中海油其他额外的属性。
注意:additionalProperties
属性默认值是 false
,当 loader
允许添加多个 options
属性时,将值改为 true
即可。
同步 & 异步
loader
分为 同步loader
和 异步loader
当使用同步方式转换内容时,可以使用
return
或this.callback()
两种形式返回结果
callback 的详细传参方法如下:
callback({
error: Error | Null, // 当无法转换原内容时,给webpack返回一个Error
content: String | Buffer, // 转换后的内容
sourceMap?: SourceMap, // 转换后的内容得出原内容的Source Map(可选)
abstrctSyntaxTree?: AST // 原内容生成 AST语法数(可选)
})
function loader3 (source, map, meta) {
console.log('loader3~~~~~~', source)
return source
}
// 或者
function loader3 (source, map, meta) {
console.log('loader3~~~~~~', source)
this.callback(null, source, map, meta)
return;
}
module.exports = loader3
// map, meta两个参数是可选参数
注意:当调用 callback()
时,始终返回 undefined
当使用异步方式转换内容时,使用
this.async()
形式获取异步操作的回调函数,并在回调函数中返回结果
function loader4 (source) {
console.log('loader4~~~~~~', source)
const callback = this.async()
setTimeout(() => {
callback(null, source) // 第一个参数错误对象,可设置为null
}, 1000)
}
module.exports = loader4
技巧
webpack
默认会缓存所有的loader
, 如果不想缓存就使用this.cacheable(false)
- 处理二进制数据
module.exports = function(source) {
source instanceof Buffer === true;
return source;
};
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据
module.exports.raw = true;
注意:最关键的代码是最后一行 module.exports.raw = true
;,没有该行 Loader
只能拿到字符串
实战
接下来,我们手写一个 Loader
来实战一下它的用法:
需求:模拟实现 babel-loader 的功能
// loader/babelLoader.js
const {
getOptions
} = require('loader-utils')
const {
validate
} = require('schema-utils')
const babel = require('@babel/core')
const schema = require('./babelSchema.json')
function babelLoader(source) {
// 获取用户传入的参数
const options = getOptions(this)
// 验证参数
validate(schema, options, {
name: 'babelLoader'
})
const callback = this.async()
// 转换代码
babel.transform(source, {
presets: options.presets,
sourceMap: true
}, (err, result) => {
callback(err, result.code, result.map)
})
}
module.exports = babelLoader
// loader/babelSchema.json
{
"type": "object",
"properties": {
"presets": {
"type": "array"
}
},
"additionalProperties": true
}
// 使用
module.exports = {
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'babelLoader',
options: {
presets: [
"@babel/preset-env"
]
}
}
}
]
},
// 解析loader的规则
resolveLoader: {
modules: ['node_modules', path.resolve(__dirname, 'loader')]
}
}

探索 Plugin
Plugin
就是一个扩展器,它比 Loader
更加灵活,因为它可以接触到 Webpack
编译器。在 Webpack
运行的生命周期中会广播出许多的事件,Plugin
可以监听这些事件,在合适的时机通过 Webpack
提供的 API
改变输出结果。这样 Plugin
就可以通过一些 hook 函数来拦截 Webpack
的执行,做一些 Webpack
打包之外的事情。像:打包优化
、资源管理
、注入环境变量
等等。
Plugin 的基本用法
使用一个插件三步:
-
npm
安装对应的插件
-
- 引入安装的插件
-
- 在
plugins
中使用
- 在
// 1. npm 安装对应的插件
npm install clean-webpack-plugin -D
// 2. 引入安装的插件
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
// 3. 在 plugins 中使用
export default {
plugins: [
new CleanWebpackPlugin()
]
}
自定义 Plugin
在编写插件之前,我们首先要熟悉 webpack
整体的运行流程。webpack
的运行本质上是一种事件流的机制,在 webpack
运行过程中,从初始化参数
、编译文件
、确认入口
、编译模块
、编译完成
、生成资源
到最后输出资源
等一系列过程。在这整个过程中,webpack
都会在对应的节点中向外广播一些事件,我们可以监听这些事件,在合适的时机通过 webpack
提供的 API
做一些合适的事情。
Webpack
插件由以下组成:
- 一个
JavaScript
命名函数 - 在插件函数的
propotype
上定义一个apply
方法 - 制定一个绑定到
webpack
自身的事件钩子 - 处理
webpack
内部实例的特定数据 - 功能完成后调用
webpack
提供的回调
Plugin
的核心在于,apply
方法执行时,可以操作webpack
本次打包的各个时间节点(hooks
), 在不同的时间节点做一些操作。其工作流程如下:
webpack
启动后,执行new myPlugin(options)
,初始化插件并获取实例- 初始化
compiler
对象,调用myPlugin.apply(compiler)
给插件传入compiler
对象 - 插件实例获取
compiler
, 它监听webpack
广播的事件,通过compiler
操作webpack
使用插件时往往都是 new XXXPlugin()
,那么换句话说一个插件就是一个类,使用该插件就是 new 一个该插件的实例,并且把插件所需要的配置参数传给该类的构造函数。在构造函数中获取用户传入的参数。
得出编写一个插件的第一步,如下:
class myPlugin {
constructor(options) {
this.options = options // 用户传入的参数
}
}
module.exports = myPlugin
由 webpack
源码得知,插件实例上都会有个 apply
方法,并将 compiler
作为其参数。
// 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);
}
}
}
扩展:webpack
中插件都会有一个 apply
方法,类似于 Vue
插件中都会有个 install
方法
得出编写一个插件的第二步,如下:
class myPlugin {
constructor(options) {
this.options = options // 用户传入的参数
}
apply(compiler) {
}
}
module.exports = myPlugin
在开发 Plugin
时最常用的两个对象 Compiler
和 Compilation
,它们都继承自Tapable
,是 Plugin
和 Webpack
之间的桥梁。类似于 react-redux
是连接 React
和 Redux
的桥梁。
核心对象
- 负责整体编译流程的
Compiler
对象 - 负责编译 Module 的
Compilation
对象
Compiler
Compiler
对象表示了完整的 Webpack
环境配置(可以理解为 webpack
一个实例)。该对象在启动 webpack
时被一次性建立,并配置好所有可操作的设置,包括 options
,loader
和 plugin
。当在 webpack
环境中应用一个插件时,插件将收到此 Compiler
对象的引用。可以使用它来访问 webpack
的主环境。
Compilation
Compilation
对象代表了一次资源版本构建。当运行 webpack
开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 Compilation
,从而生成一组新的编译资源。一个 Compilation
对象表现了当前的 模块资源
、编译生成资源
、变化的文件
、以及 被跟踪依赖的状态信息
。Compilation
对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。
注意:两者的区别在于:一个是代表了整个构建过程,一个是代表构建过程中的某个模块
compiler
代表了整个webpack
从启动到关闭的生命周期,而compilation
只是代表了一次性的编译过程。compiler
和compilation
两者都暴露出许多钩子,我们可以根据实际需求的场景进行自定义处理。
Tapable
Tapable
提供了一系列事件的发布订阅 API
,通过 Tapable
可以注册事件,从而在不同时机去触发注册的事件回调进行执行。Webpack
中的 Plugin
机制正是基于这种机制实现在不同编译阶段调用不同的插件从而影响编译结果。
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
同步钩子只有一种注册的方法
- 只能通过tap注册,由
call
来执行
异步钩子提供三种注册的方法:
tap
:以同步方式注册钩子,用call
来执行tapAsync
: 以异步方式注册钩子,用callAsync
来执行tapPromise
: 以异步方式注册钩子,返回一个Promise
注意:异步钩子可以分为:
- 异步串行钩子(
AsyncSeries
):可以被串联(连续按照顺序调用)执行的异步钩子函数 - 异步并行钩子(
AsyncParallel
):可以被并联(并发调用)执行的异步钩子函数
不同类型钩子的区别?
- 基本类型: 它不关心每个被调用的事件的返回值,仅仅执行注册的回调函数
- 瀑布类型: 会将上一个回调函数的返回值传递给下一个回调函数作为参数
- 保险类型: 如果回调函数中存在非
undefined
返回值时,后面的回调函数都不会被执行了 - 循环类型: 如果回调函数中存在非
undefined
返回值,就会重新开始执行所有的回调函数,直到所有的回调函数都返回undefined
当知道了 webpack
会广播哪些事件后,我们就可以在 apply
中监听事件并编写对应的逻辑,如下:
class myPlugin {
constructor(options) {
this.options = options // 用户传入的参数
}
apply(compiler) {
// 监听某个事件
compiler.hooks.'compiler事件名称'.tap('myPlugin', (compilation) => {
// 编写对应的逻辑
})
}
}
module.exports = myPlugin
注意:当监听 compiler
对象中的 compilation
事件时,此时也可以在回调函数中继续监听 compilation
对象里的事件,如下:
class myPlugin {
constructor(options) {
this.options = options // 用户传入的参数
}
apply(compiler) {
// 监听某个事件
compiler.hooks.'compiler事件名称'.tap('myPlugin', (compilation) => {
// 编写对应的逻辑
compilation.hooks.'compilation事件名称'.tap('myPlugin', () => {
// 编写对应的逻辑
})
})
}
}
module.exports = myPlugin
上面监听的事件都是同步的钩子,用 tap
进行注册。当监听异步的钩子时,我们就需要用 tapAsync 和 tapPromise
来进行注册了。并且还需要传入一个 cb
回调函数,在插件运行完后,必须调用这个这个 cb
回调函数,如下:
class myPlugin {
constructor(options) {
this.options = options // 用户传入的参数
}
apply(compiler) {
// 监听某个事件
compiler.hooks.emit.tapAsync('myPlugin', (compilation, cb) => {
setTimeout(() => {
// 编写对应的逻辑
cb()
}, 1000)
})
}
}
module.exports = myPlugin
获取 & 验证参数
我们知道在插件的钩子函数中可以获取到外部传入给插件的参数。在编写一个插件时,我们需要验证参数传入的是否合法。和 Loader
类似,验证参数是否合法,需下载:
npm install schema-utils -D
调试技巧:
"scripts": {
"start": "node --inspect-brk ./node_modules/webpack/bin/webpack.js"
},
webpack
插件本质上就是通过发布订阅的模式,通过 compiler
上监听事件。然后再打包编译过程中触发监听的事件从而添加一定的逻辑影响打包结果。
实战
接下来,我们通过手写 Plugin
来实战一下它的用法
需求:打包前删除js文件中的注释
class MyPlugin {
constructor(options) {
console.log('插件选项:', options)
this.userOptions = options || {}
}
// 必须带有apply方法
apply(compiler) {
compiler.hooks.emit.tap('插件名称', (compilation) => {
// compilation 此次打包的上下文
console.log('webpack 构建过程开始!', compilation)
for (const name in compilation.assets) {
// if(name.endsWith('.js'))
if (name.endsWith(this.userOptions.target)) {
// 获取处理之前的内容
const content = compilation.assets[name]
// 将原来的内容,通过正则表达式,删除注释
const noComments = content.replace(/\/\*[\s\S*?]\*\//g, '')
// 将处理后的结果,替换
compilation.assets[name] = {
source: () => noComments,
size: () => noComments.length,
}
}
}
})
}
}
module.exports = MyPlugin
// 使用
const MyPlugin = require('./plugin/my-plugin')
module.exports = {
// 插件配置
plugins: [
new MyPlugin({
// 插件选项
target: '.js',
}),
],
}
总结
通过以上我们大致掌握了 Webpack
中比较重要的两个概念:Loader
和 Plugin
。前端工程化在前端领域越来越的重要,不管是平时工作需要,还是面试提升自己的技术功底。掌握好 Webpack
的用法并了解其原理会给我们带来很大的好处!
转载自:https://juejin.cn/post/7068216285724672008