【万字】webpack loader 概念,手写,原理一条龙唠明白
大家好我是来蹭饭,一个会点儿吉他和编曲,绞尽脑汁想傍个富婆的摸鱼大师。
webpack系列原理篇的内容因年末笔者诸多琐事所以鸽至现在,定睛一看距离上篇文章发表已过去4月有余,时间跨度之长,生产队的驴都不带这么歇息的。趁着最近有空,赶忙坐在电脑前码字,让我们书接上回。
回顾咱们webpack系列的历程,行程已经过半,前三篇文章是备受好评,今天总算来到了我们的原理篇——剖析loader从概念到手写到原理:
-
原理篇——剖析loader从概念到手写到原理
-
原理篇——剖析plugin从概念到手写到原理
-
原理篇——剖析webpack打包原理,手写小型webpack
本篇文章开始我们正式进入webapck原理相关内容讲解,如果有对webpack不熟悉的同学欢迎翻看基础篇,进阶篇和实战篇的内容。
一. 前言
本次讲解以 webapck 5.75.0 为基础进行演示,将从loader的 相关概念,分类,使用方式,手写练习,执行原理 五个章节为大家展开讲解。老规矩,相关案例代码已全部上传至 Git ,欢迎自取,不嫌麻烦的话欢迎点个star。接下来闲言少叙,大家坐稳扶好,我们发车!
二. 相关概念
本章节将通过 定义,基础结构 的讲解让大家对loader有基本的认知。
2.1 定义
我们之前用大白话聊过loader的作用:loader是一个翻译,把webpack不能直接处理的资源,翻译成能直接处理的。究其本质loader到底是什么?这里有一段 官方 的定义:
loader 本质上是导出为函数的 JavaScript 模块。loader runner 会调用此函数,然后将上一个 loader 产生的结果或者资源文件传入进去。函数中的 this
作为上下文会被 webpack 填充,并且 loader runner 中包含一些实用的方法,比如可以使 loader 调用方式变为异步,或者获取 query 参数。
到这里就非常清楚了,loader的本质就是函数模块,既然是函数,我们关注这个函数的入参,出参,功能,即可。接下来我们搭建webpack的基础环境,给大家展示最基本的loader结构。
2.2 基础结构
一个loader的基础结构如下所,其中 map 和 meta 是可选参。
/**
*
* @param {string|Buffer} content 源文件的内容
* @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 数据
* @param {any} [meta] meta 数据,可以是任何内容
*/
function webpackLoader(content, map, meta) {
// 你的 webpack loader 代码
}
module.exports = webpackLoader
为了更直观的演示,这里我们搭建好自己的webpack环境。
- 步骤1: 安装 webpack 与 webpack-cli
yarn add webpack webpack-cli -D
- 步骤2: 调整项目结构
├─ src
│ ├─ loaders
│ │ └─ loader1.js
│ └─ index.js
├─ webpack.config.js
├─ .gitignore
├─ package.json
└─ README.md
- 步骤3: 配置 webpack.config.js
const path = require('path')
// 相对路径转绝对路径
const resolvePath = _path => path.resolve(__dirname, _path)
module.exports = {
entry: resolvePath('./src/index.js'),
output: {
path: resolvePath('./dist'),
clean: true
},
module:{
rules:[{
test: /\.js$/,
loader: resolvePath('./src/loaders/loader1.js')
}]
},
mode: 'development'
}
- 步骤4: 初始化 loader1.js 中的内容
const loader1 = function (content, map, meta) {
console.log('loader1',content)
return content
}
module.exports = loader1
- 步骤5: 初始化 src/index.js 中的内容
console.log('Hello Cengfan!')
环境搭建完成,打开终端运行webpack看看控制台会输出什么内容。

可以看到 src/index.js 中的内容被原封不动地打印出来了,这个就是参数 content 的内容。它被处理后,会输出交给下一个loader继续调用。 如此,一个最基础的loader结构就展示出来了,接下来我们看看loader的分类。
三. 分类
本章节将按照 执行顺序,示例 跟大家讲解loader的分类。
3.1 执行顺序
loader在执行顺序上分为以下4类,仅需有印象即可,后续会进行大量的演示。
-
pre:前置loader
-
normal:普通loader
-
inline:内联loader
-
post:后置loader
loader的执行顺序遵循以下原则:
-
默认的执行优先级为 pre,normal,inline,post
-
相同优先级的loader执行顺序为 从右往左,从下往上
了解loader的基本分类后,我们来看看它的示例。
3.2 示例
接下来更改项目中的文件,用以演示loader的默认执行顺序。
3.2.1 默认执行顺序
- 步骤1: 调整项目结构,新增 loader2.js, loader3.js
├─ src
│ ├─ loaders
│ │ ├─ loader1.js
│ │ ├─ loader2.js
│ │ ├─ loader3.js
│ └─ index.js
├─ webpack.config.js
├─ .gitignore
├─ package.json
└─ README.md
- 步骤2: 初始化 loader2.js 的内容
const loader2 = function (content, map, meta) {
console.log('loader2')
return content
}
module.exports = loader2
- 步骤3: 初始化 loader3.js 的内容
const loader3 = function (content, map, meta) {
console.log('loader3')
return content
}
module.exports = loader3
- 步骤4: 更改 webpack.config.js 中module的内容
module.exports = {
// ...
module:{
rules:[{
test: /\.js$/,
loader: resolvePath('./src/loaders/loader1.js')
},{
test: /\.js$/,
loader: resolvePath('./src/loaders/loader2.js')
},{
test: /\.js$/,
loader: resolvePath('./src/loaders/loader3.js')
}]
},
}
完成后终端执行webpack,查看输出结果。
从打印的结果来看loader的执行顺序符合 从下往上,从右往左 的原则。接下来我们更改 rule 对象的 enforece 属性来更改loader的执行顺序。
3.2.2 更改执行顺序
更改 webpack.config.js 的内容
module.exports = {
// ...
module:{
rules:[{
test: /\.js$/,
loader: resolvePath('./src/loaders/loader1.js'),
enforce: 'pre'
},{
// 无enforce属性,默认为 normal loader
test: /\.js$/,
loader: resolvePath('./src/loaders/loader2.js'),
},{
test: /\.js$/,
loader: resolvePath('./src/loaders/loader3.js'),
enforce: 'post'
}]
},
}
运行webpack查看结果。loader的打印结果从原先的 loader3,loader2,loader1 变成了倒序输出。输出优先级与loader中 enforce 的属性值相关,优先级为 pre,normal,post loader。
内联的 inline loader 不在webpack的配置文件中配置,它仅在业务代码中配置且使用不多,所以不展开讲解。到这里我们已经清楚了loader的基础结构和执行顺序,接下来我们看看loader的使用方式。
四. 使用方式
loader在使用方式上分为 同步 loader,异步 loader,raw loader,pitch loader 这4类,在正式开始讲解前我们先对webpack的配置文件进行优化工作。
在当前配置文件中,所有自定义loader的使用都需要用 resolvePath 这个方法指定loader的使用路径。这里我们可以修改配置文件,新增 resolveLoader 属性,让webpack默认寻找自定义的loader文件夹,之后直接使用loader的名称即可。
module.exports = {
// ...
module:{
rules:[{
test: /\.js$/,
loader: 'loader1',
enforce: 'pre'
},{
// 无enforce属性,默认为 normal loader
test: /\.js$/,
loader: 'loader2',
},{
test: /\.js$/,
loader: 'loader3',
enforce: 'post'
}]
},
resolveLoader:{
modules:[
// 默认在 node_modules 与 src/loaders 的目录下寻找loader
'node_modules',
resolvePath('./src/loaders')
]
},
}
运行webpack查看结果,loader内容正常输出。
优化工作完毕,接下来我们正式进入loader使用方式的学习。
4.1 同步loader
同步loader顾名思义在整个loader的执行流程中为同步执行。它的使用非常简单,我们用 loader1 举例,修改它的内容。
const loader1 = function (content, map, meta) {
console.log('loader1')
/*
param1:error 是否有错误
param2:content 处理后的内容
param3:source-map 信息可继续传递 source-map
param4:meta 给下一个 loader 传递的参数
*/
this.callback(null, content, map, meta)
}
module.exports = loader1
使用 this.callback 方法替代原先的 return 语句,运行webpack查看结果。
同步loader的使用仅仅是替换了 return 语句,使用起来非常简单,接下来我们看看异步loader的使用。
4.2 异步loader
异步loader并不是让渡当前loader的执行权力,给下一个loader先执行。而是卡住当前的执行进程,方便你在异步的时间里去进行一些额外的操作。待这些操作完成后,任务进程交给下一个loader。 接下来我们演示异步loader,为了演示方便,先去除配置文件中的 enforce 配置。
module.exports = {
// ...
module:{
rules:[{
test: /\.js$/,
loader: 'loader1',
// enforce: 'pre'
},{
// 无enforce属性,默认为 normal loader
test: /\.js$/,
loader: 'loader2',
},{
test: /\.js$/,
loader: 'loader3',
// enforce: 'post'
}]
},
// ...
}
修改 loader2 的内容,之后运行webpack查看输出结果。
const loader2 = function (content, map, meta) {
console.log('loader2')
// this.async 告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。
const callback = this.async()
setTimeout(() => {
console.log('async loader2')
// 调用 callback 后,才会执行下一个 loader
callback(null, content, map, meta)
},500)
}
module.exports = loader2
可以看到这里先输出了 loader3,loader2 ,500ms后输出了 async loader2,loader1 。这说明在异步loader执行完成之前,是不会执行下一个loader的这里的异步loader执行机制可以用 async 中遇到 await 就暂停运行,等待 await 返回结果后才运行后续的代码去类比理解。
下面我们进行异步loader错误的使用演示,在异步中使用同步loader输出。
- 步骤1: 修改 loader3 的内容
const loader3 = function (content, map, meta) {
console.log('loader3')
setTimeout(() => {
console.log('async loader3')
this.callback(null, content, map, meta)
},500)
}
module.exports = loader3
- 步骤2: 在 loader2 的异步中打印content,运行webpack查看输出结果
setTimeout(() => {
console.log('async loader2', content)
// 调用 callback 后,才会执行下一个 loader
callback(null, content, map, meta)
},500)
可以看到 loader2 先于 async loader3 输出,由于执行过程未等待,content 也没有传入 loader2 中,所以打印值为 undefined。
这个就是异步loader的使用,接下来看看raw loader的使用。
4.3 raw loader
raw loader 一般用于处理 Buffer 数据流的文件。在处理图片,字体图标等经常会使用它,这里为了演示方便,我们用它处理js。
- 步骤1: src/loaders 下新增 raw-loader.js 初始化文件
const rawLoader = function(content) {
console.log(content)
return content
}
rawLoader.raw = true
module.exports = rawLoader
- 步骤2: 修改webpack配置文件,仅用于测试raw loader
module.exports = {
// ...
module:{
/* rules:[{
test: /\.js$/,
loader: 'loader1',
// enforce: 'pre'
},{
// 无enforce属性,默认为 normal loader
test: /\.js$/,
loader: 'loader2',
},{
test: /\.js$/,
loader: 'loader3',
// enforce: 'post'
}] */
rules:[{
test: /\.js$/,
loader: 'raw-loader',
}]
},
// ...
}
运行webpack查看结果。
这就是 raw-loader 接下来我们看看 pitch-loader。
4.4 pitch loader
loader模块中导出函数的 pitch 属性指向的函数就叫 pitch loader。它的使用场景是 当前loader依赖上个loader的输出结果,且该结果为js而非webpack处理后的资源。 此时loader的逻辑处理更适合放在pitch loader。记住它的使用场景,下一章节我们手写 style-loader 时会进行详细讲解。
本章节将通过 基础结构,执行顺序,熔断机制,函数入参 这些小节,跟大家详细讲解 pitch loader。
4.4.1 基础结构
它的基础结构如下,参数均为可选参。
/**
* @remainingRequest 剩余请求
* @precedingRequest 前置请求
* @data 数据对象
*/
function (remainingRequest, precedingRequest, data) {
// code
};
4.4.2 执行顺序
loader的执行顺序是 从右往左,从下往上 ,但pitch loader的执行顺序正好相反,它是 从左往右,从上往下 。接下来我们分步写好pitch loader来观察它的执行顺序。
- 步骤1: 修改 loader1 的内容
const loader1 = function (content, map, meta) {
console.log('loader1')
/*
param1:error 是否有错误
param2:content 处理后的内容
param3:source-map 信息可继续传递 source-map
param4:meta 给下一个 loader 传递的参数
*/
this.callback(null, content, map, meta)
}
const pitch1 = function() {
console.log('pitch loader1')
}
module.exports = loader1
module.exports.pitch = pitch1
- 步骤2: 修改 loader2 的内容
const loader2 = function (content, map, meta) {
console.log('loader2')
// this.async 告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。
const callback = this.async()
setTimeout(() => {
console.log('async loader2')
// 调用 callback 后,才会执行下一个 loader
callback(null, content, map, meta)
},500)
}
const pitch2 = function() {
console.log('pitch loader2')
}
module.exports = loader2
module.exports.pitch = pitch2
- 步骤3: 修改 loader3 的内容
const loader3 = function (content, map, meta) {
console.log('loader3')
this.callback(null, content, map, meta)
}
const pitch3 = function(remainingRequest, precedingRequest, data) {
console.log('pitch loader3')
}
module.exports = loader3
module.exports.pitch = pitch3
- 步骤4: 修改 webpack.config.js 的 rule 对象,将之前演示 raw-loader 的代码注释
module.exports = {
// ...
module:{
rules:[{
test: /\.js$/,
loader: 'loader1',
// enforce: 'pre'
},{
// 无enforce属性,默认为 normal loader
test: /\.js$/,
loader: 'loader2',
},{
test: /\.js$/,
loader: 'loader3',
// enforce: 'post'
}]
/* rules:[{
test: /\.js$/,
loader: 'raw-loader',
}] */
},
// ...
}
运行webpack,查看输出结果。
这下明白了吧,pitch loader 会先于 normal loader 执行,下图即为它们的执行顺序。
4.4.3 熔断机制
上述都是pitch loader无返回值时的执行顺序,如果在整个执行链中,某个pitch loader有返回值,执行顺序又会发生改变。我们修改 loader2 中的pitch loader之后运行webpack查看输出结果。
const pitch2 = function() {
console.log('pitch loader2')
return 'cengfan'
}
执行到 loader2 时由于 pitch loader2 有返回值,导致后面所有的loader都不再执行,转而回到上一个loader的 normal loader。执行顺序如下图,这就是loader执行的熔断机制。
演示完毕,接下来为了其他演示效果正常,这里我们去掉loader2中的return语句。
4.4.4 函数入参
接下来我们处理 loader1,loader2,loader3 加上对应的输出语句和pitch loader的入参,以此进一步了解pitch loader。
- 步骤1: 修改 loader1 的内容
const loader1 = function (content, map, meta) {
console.log('loader1',content, map, meta)
/*
param1:error 是否有错误
param2:content 处理后的内容
param3:source-map 信息可继续传递 source-map
param4:meta 给下一个 loader 传递的参数
*/
this.callback(null, content, map, meta)
}
const pitch1 = function(remainingRequest, precedingRequest, data) {
console.log('pitch loader1')
console.log('remainingRequest:',remainingRequest, 'precedingRequest:',precedingRequest, 'data:',data)
}
module.exports = loader1
module.exports.pitch = pitch1
- 步骤2: 修改 loader2 的内容
const loader2 = function (content, map, meta) {
console.log('loader2')
// this.async 告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。
const callback = this.async()
setTimeout(() => {
console.log('async loader2')
// 调用 callback 后,才会执行下一个 loader
callback(null, content, map, this.data.value)
},500)
}
const pitch2 = function(remainingRequest, precedingRequest, data) {
data.value = 999
console.log('pitch loader2')
console.log('remainingRequest:',remainingRequest, 'precedingRequest:',precedingRequest, 'data:',data)
}
module.exports = loader2
module.exports.pitch = pitch2
- 步骤3: 修改 loader3 的内容
const loader3 = function (content, map, meta) {
console.log('loader3')
this.callback(null, content, map, meta)
}
const pitch3 = function(remainingRequest, precedingRequest, data) {
console.log('pitch loader3')
console.log('remainingRequest:',remainingRequest, 'precedingRequest:',precedingRequest, 'data:',data)
}
module.exports = loader3
module.exports.pitch = pitch3
执行webpack查看输出结果。
这里的输出结果分两步分讲解,首先是 remainingRequest:剩余请求;precedingRequest:前置请求,然后是 data。
由于输出内容太长且不直观,我把每个loader remainingRequest,precedingRequest 的输出结果汇总成了下表。帮助大家理解这两个参数的含义。
remainingRequest | precedingRequest | |
---|---|---|
pitch loader1 | index.js;loader2.js;loader3.js | |
pitch loader2 | index.js;loader3.js | loader1.js |
pitch loader3 | index.js | loader1.js;loader2.js |
你会发现这哥俩的含义属实是顾名思义了,就是告诉你当前的pitch loader中有哪些未执行的请求,和已经执行的请求。
前两个参数聊完后我们看看data,它可用于捕获并共享前面的信息。注意图中标红的地方,我们在 loader2 的 pitch loader 中添加了 data.value 属性。并在其异步调用的 callback 中将data作为参数传入以供loader1后续使用。之后我们在 loader1 中进行了data的输出。
这就是它捕获共享的作用。到这里我们已经讲解完pitch loader的3个参数了。稍后我们在手写练习中通过 style loader 对 pitch loader 的使用场景进行详细的解析。
五. 手写练习
在对loader有个大概的了解后,我们来手写 clean-log-loader,banner-loader,babel-loader,style-loader 这几个loader。
5.1 clean-log-loader
本loader用于清除webpack打包时检测到的所有console语句。接下来我们分步实现它。
- 步骤1: 调整src结构
├─ src
│ ├─ loaders
│ │ ├─ clean-log-loader
│ │ │ └─ index.js
│ │ ├─ loader1.js
│ │ ├─ loader2.js
│ │ ├─ loader3.js
│ │ └─ raw-loader.js
│ └─ index.js
- 步骤2: 初始化 clean-log-loader 的内容
const cleanLogLoader = function(content) {
// 使用正则将 content 文件中所有的 console 语句替换成空
return content.replace(/console\.log\(.*\);?/g,'')
}
module.exports = cleanLogLoader
- 步骤3: 修改 webpack.config.js
module.exports = {
// ...
module: {
rules:[{
test:/\.js$/,
loader:'clean-log-loader'
}]
/* rules: [{
test: /\.js$/,
loader: 'loader1',
// enforce: 'pre'
}, {
// 无enforce属性,默认为 normal loader
test: /\.js$/,
loader: 'loader2',
}, {
test: /\.js$/,
loader: 'loader3',
// enforce: 'post'
}] */
/* rules:[{
test: /\.js$/,
loader: 'raw-loader',
}] */
},
// ...
}
这里我们注释之前使用的loader。执行webpack查看打包结果。
可以看到 src/index.js 中的 console.log('Hello Cengfan!') 语句在打包后被清除,如果我们不引用 clean-log-loader,它会被重新打包。
如此,一个基础的 clean-log-loader 便完成了,那这个loader还能整点活儿出来吗?是可以的,比如我们在开发中,希望保留一些关键的输出信息区别于一般的console。此时我们可以这样修改自己的loader。
- 步骤1: 修改 clean-log-loader 的内容
const cleanLogLoader = function(content) {
// 使用正则将 content 文件中所有不带 '@' 的 console 语句替换成空
return content.replace(/console\.log\([^@]*\);?/g,'')
}
module.exports = cleanLogLoader
- 步骤2: 修改 src/index.js 的内容
console.log('Hello Cengfan!')
console.log('@','Hello Cengfan!')
执行webpack,查看输出结果,可以看到只有带 '@' 符号的 console 语句被保留了下来。
这里想区别于其他console的符号你可以自定义,只要把 clean-log-loader 中 replace 的正则匹配修改成你想用的符号即可。
好了到这里我们的 clean-log-loader 就正式写完了,接下来我们开始写下一个loader。
5.2 banner-loader
本loader用于给代码添加注释信息,如作者姓名等。接下来我们分步实现它。
- 步骤1: 调整 src 结构
├─ src
│ ├─ loaders
│ │ ├─ banner-loader
│ │ │ ├─ index.js
│ │ │ └─ schema.json
│ │ ├─ clean-log-loader
│ │ │ └─ index.js
│ │ ├─ loader1.js
│ │ ├─ loader2.js
│ │ ├─ loader3.js
│ │ └─ raw-loader.js
│ └─ index.js
- 步骤2: 初始化 /banner-loader/schema.json 的内容,本 json 文件用于验证从 webpack.config.js 获取的 options 配置是否合法。 type,properties 用于定义接收的 options 参数类型。additionalProperties 定义是否能追加参数,如果为false,增加参数会报错。
{
"type":"object",
"properties":{
"author":{
"type": "string"
},
"age":{
"type": "number"
}
},
"additionalProperties" : false
}
- 步骤3: 初始化 /banner-loader/index.js 的内容
const schema = require('./schema.json')
const bannerLoader = function (content) {
// 使用引入的 schema 验证获取的 options,options 在 webpack.config.js 中使用本 loader 时传递
const options = this.getOptions(schema)
const prefix = `
/*
* Author: ${options.author}
* age: ${options.age}
*/
`;
return prefix + content
}
module.exports = bannerLoader
- 步骤4: 修改 webpack.config.js
module.exports = {
// ...
module: {
rules:[{
test:/\.js$/,
loader:'clean-log-loader'
},{
test:/\.js$/,
loader:'banner-loader',
options:{
author:'Cengfan',
age:18
}
}]
// ...
},
// ...
}
运行webpack查看结果。需要添加的自定义信息已被打包。
如果我们在配置文件中给 options 新增字段,由于 schema.json 中的 additionalProperties 属性为 false,在打包时会报错。
这就是我们的 banner-loader,接下来我们开始写下一个loader。
5.3 babel-loader
本loader用于将 ES next 转换为 ES5,babel-loader 之前的文章已经列举过它的详细作用,这里我们通过引入官方的预设,分步手写一个 babel-loader。
- 步骤1: 由于我们需要babel的核心库和智能预设,因此要安装对应依赖
yarn add @babel/core @babel/preset-env -D
- 步骤2: 调整 src 结构
├─ src
│ ├─ loaders
│ │ ├─ babel-loader
│ │ │ ├─ index.js
│ │ │ └─ schema.json
│ │ ├─ banner-loader
│ │ │ ├─ index.js
│ │ │ └─ schema.json
│ │ ├─ clean-log-loader
│ │ │ └─ index.js
│ │ ├─ loader1.js
│ │ ├─ loader2.js
│ │ ├─ loader3.js
│ │ └─ raw-loader.js
│ └─ index.js
- 步骤3: 初始化 /babel-loader/schema.json 的内容
{
"type":"object",
"properties":{
"presets":{
"type": "array"
}
},
"additionalProperties" : true
}
- 步骤4: 初始化 /babel-loader/index.js 的内容,对babel.transform()方法不了解的话,欢迎去 Babel官网 查询
const babel = require('@babel/core')
const schema = require('./schema.json')
const babelLoader = function (content) {
const callback = this.async()
const options = this.getOptions(schema)
/*
使用 babel 编译代码
param1: code 代码内容
param2: options 对应的预设
param3: callback 回调函数,其中 result 返回值为 { code, map, ast } 对象
*/
babel.transform(content, options, function (err, result) {
// result 的返回值为 { code, map, ast },这里直接获取解析后的 code 传入给异步 loader 执行即可
if (err) callback(err)
else callback(null, result.code)
})
}
module.exports = babelLoader
- 步骤5: 修改 src/index.js 的内容
module.exports = {
// ...
module: {
rules:[{
test:/\.js$/,
loader:'clean-log-loader'
},{
test:/\.js$/,
loader:'banner-loader',
options:{
author:'Cengfan',
age:18,
}
},{
test:/\.js$/,
loader:'babel-loader',
options:{
presets: ['@babel/preset-env']
}
}]
// ...
},
// ...
}
- 步骤6: 修改 src/index.js 的内容
const add = (...args) => args.reduce((prev,curr) => prev,curr,0)
console.log('Hello Cengfan!')
console.log('@','Hello Cengfan!')
全部操作完成,执行webpack查看打包结果。
可以看到这里用ES6声明的语法全部变成了ES5。如果不调用我们自己的babel loader打包后的结果会变成这样。
这就是我们的 babel-loader,接下来我们开始写下一个loader。
5.4 style-loader
本loader会动态创建 style 标签,将处理好的样式插入到 head 标签中。全体注意!本loader会充分体现 pitch loader 的使用场景。接下来我们分步实现它。
- 步骤1: 由于需要借助页面显示样式; style-loader 无法处理引入的其他资源(如图片等)的原因,我们需要安装对应的依赖
yarn add html-webpack-plugin css-loader -D
- 步骤2: 调整 src 结构,新增 assets,css,style-loader,index.html
├─ src
│ ├─ assets
│ │ ├─ img
│ │ │ ├─ mk3.jpg
│ │ │ └─ mk5.jpg
│ │ │ └─ mk6.jpg
│ ├─ css
│ │ └─ index.css
│ ├─ loaders
│ │ ├─ babel-loader
│ │ │ ├─ index.js
│ │ │ └─ schema.json
│ │ ├─ banner-loader
│ │ │ ├─ index.js
│ │ │ └─ schema.json
│ │ ├─ clean-log-loader
│ │ │ └─ index.js
│ │ ├─ style-loader
│ │ │ └─ index.js
│ │ ├─ loader1.js
│ │ ├─ loader2.js
│ │ ├─ loader3.js
│ │ └─ raw-loader.js
│ ├─ index.html
│ └─ index.js
- 步骤3: 初始化 index.html 的内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webpack-loader-plugin</title>
<script defer src="main.js"></script></head>
<body>
<h2>Webpack-loader-plugin</h2>
<div class="picture">
<div></div>
<div></div>
<div></div>
</div>
</body>
</html>
- 步骤4: 初始化 index.css 的内容
h2{
background: #4285f4;
color: #fff;
}
.picture div{
width: 300px;
height: 300px;
float: left;
margin: 0 5px;
}
.picture div:first-child {
background: url(../assets/img/mk3.jpg) no-repeat center /contain;
}
.picture div:nth-child(2) {
background: url(../assets/img/mk5.jpg) no-repeat center /contain;
}
.picture div:last-child {
background: url(../assets/img/mk6.jpg) no-repeat center /contain;
}
- 步骤5: 修改 index.js 的内容
import './css/index.css'
const add = (...args) => args.reduce((prev,curr) => prev,curr,0)
console.log('Hello Cengfan!')
console.log('@','Hello Cengfan!')
- 步骤6: 初始化 style-loader 的内容
const styleLoader = function(content) {
// 创建 style 标签,将 css-loader 处理后的内容插入到 html 中
const script = `
const styleEl = document.createElement('style')
styleEl.innerHTML = ${JSON.stringify(content)}
document.head.appendChild(styleEl)
`
return script
}
module.exports = styleLoader
- 步骤7: 修改 webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
// ...
module.exports = {
// ...
module: {
rules:[{
test:/\.js$/,
loader:'clean-log-loader'
},{
test:/\.js$/,
loader:'banner-loader',
options:{
author:'Cengfan',
age:18,
}
},{
test:/\.js$/,
loader:'babel-loader',
options:{
presets: ['@babel/preset-env']
}
},{
test:/\.css$/,
use: ['style-loader', 'css-loader'],
}]
// ...
},
// ...
plugins: [
new HtmlWebpackPlugin({
template: resolvePath('./src/index.html'),
})
],
// ...
}
配置完毕,接下来运行查看打包后的html样式是否生效。
全不生效,诶就是玩儿!这到底是怎么回事呢?F12查看生成的style标签。
可以发现插入到style标签的内容也就是 style loader 接收的 content 参数,并不是我们预想的直接被 css-loader 处理好的样式内容,而是一段js脚本。css-loader 会将css文件处理成commonJs模块放入js中,这样插入的内容当然不会生效。
如何解决遇到的问题?可以看到这段js脚本作为模块最后默认暴露了出来。我们需要引用这段脚本并执行它。现在摆在面前的有两种选择。
-
在style loader的 normal 阶段实现能执行js的逻辑,并获取 css loader 返回的样式内容;
-
将style loader的逻辑放在 pitch 阶段,通过 pitch loader 函数的 remainingRequest 参数,获取 css loader 的相对路径,把它作为模块引入style loader中,然后让webpack通过 import 语句递归执行引入模块的运算结果。最后输出样式内容。
显然,最大程度利用webpack自身特点帮我们处理事务是最优解。style loader官方 也是在 pitch 阶段处理所有逻辑。因此我们的 style loader 需要这样改进一下。
const styleLoader = function(content){}
const styleLoaderPitch = function(remainingRequest) {
/*
将绝对路径:
C:\Front End\projects\webpack-loader-plugin\node_modules\css-loader\dist\cjs.js!C:\Front End\projects\webpack-loader-plugin\src\css\index.css
转换为相对路径:
../../node_modules/css-loader/dist/cjs.js!./index.css
*/
const resolvePath = remainingRequest.split('!').map(absolutePath => {
// 通过本 loader 所在的上下文环境和绝对路径,返回一个相对路径
return this.utils.contextify(this.context,absolutePath)
}).join('!')
// 创建 style 标签,将 css-loader 处理后的内容插入到 html 中
// '!!' 在 inline loader内跳过 pre,normal,post loader的执行,这里跳过引入的 css loader 后续阶段的自动执行
const script = `
import style from '!!${resolvePath}'
const styleEl = document.createElement('style')
styleEl.innerHTML = style
document.head.appendChild(styleEl)
`
// 熔断后续 loader 的执行
return script
}
module.exports = styleLoader
module.exports.pitch = styleLoaderPitch
改进后运行webpack,查看打包结果。此时我们的样式全部正常显示了。还记得之前说过的 pitch loader 的使用场景吗?当前loader依赖上个loader的输出结果,且该结果为js而非webpack处理后的资源。 结合这个案例这下明白了吧。
这就是我们的 style-loader,到这里手写loader的练习已经全部完成,相信你已经对loader有了全面的认知,接下来我们深入它的内核,了解它的原理。
六. 执行原理
本章我将按照 loader的执行流程,执行顺序,异步处理,this 这几个常见的问题跟大家进行讲解,接下来我们开始执行流程的讲解。
6.1 loader的执行流程
webpack的运行流程较为复杂不展开讲解,这里我们专注于loader部分。loader大致的执行流程为以下4个步骤。
-
获取 webpack 默认的 loader 配置
-
loaderResover 解析 loader 的路径
-
rule.modules 创建 RuleSet 规则集
-
loader runner 运行匹配的 loader
大白话翻译一下:获取默认配置,解析loader路径,创建规则集合,运行这个集合。 通俗易懂,简洁明了。展开的过程如下。
-
webpack 的 Compiler 对象会将用户配置 webpack.config.js 和它自己的默认配置合并。
-
webpack根据配置创建 ContextModuleFactory 和 NormalModuleFactory 两个类,这两个类都是工厂函数,会生成对应的 ContextModule 和 NormalModule 的实例。工厂类的作用如下表。
ContextModuleFactory | NormalModuleFactory |
---|---|
它会解析请求的目录,为每个文件生成请求,并依据传递来的 regExp 进行过滤。最后匹配成功的依赖关系将被传入NormalModuleFactory(生成依赖) | 从入口点开始,此模块会分解每个请求,解析文件内容以查找进一步的请求,然后通过分解所有请求以及解析新的文件来爬取全部文件。在最后阶段,每个依赖项都会成为一个模块实例(依据依赖生成模块实例) |
-
NormalModule 实例创建后,通过 build 方法构建模块。在构建中首先就要使用 loader runner 调用loader编译模块内容。
-
输出 loader 编译后的内容,进入后续编译流程。
好了这就是loader大概的执行流程,接下来我们讲解它的执行顺序。
6.2 loader的执行顺序
前文讲解过loader的执行顺序。 normal loader 从下往上,从右往左;pitch loader 与之相反。优先级权重为 pre,normal,inline,post。熔断时 pitch loader 返回上一个loader的 normal loader。
这里搞清楚3个问题,loader的执行顺序也就清晰了。
-
loader的执行栈是怎么来的?
-
loader runner如何运行这个执行栈?
-
熔断时发生了什么?
接下来我们分小节逐一解答这个问题。
6.2.1 loader的执行栈
loader的执行栈生成分两步,loader分类,生成执行栈。下面我们分步讲解。
loader分类的主要作用是将webpack获取到的loader按 pre,normal,post 分类,其中 webpack4 和 webpack5 在逻辑上有所不同。
webpack4 中通过 this.ruleSet.exec 传入源码模块的路径,返回的 result 即为获取的loader,因此我们在 webpack.config.js 的 rule 对象设置的 enforce 属性也可以被获取到。获取该属性后,对loader进行分类。
for (const r of result) {
if (r.type === "use") {
if (r.enforce === "post" && !noPrePostAutoLoaders) {
useLoadersPost.push(r.value);
} else if (
r.enforce === "pre" &&
!noPreAutoLoaders &&
!noPrePostAutoLoaders
) {
useLoadersPre.push(r.value);
} else if (
!r.enforce &&
!noAutoLoaders &&
!noPrePostAutoLoaders
) {
useLoaders.push(r.value);
}
}
// ...
}
webpack5 中通过 resolveRequestArray 方法对 loader 进行分类。
this.resolveRequestArray(
contextInfo,
this.context,
useLoadersPost,
loaderResolver,
resolveContext,
(err, result) => {
postLoaders = result;
continueCallback(err);
}
);
this.resolveRequestArray(
contextInfo,
this.context,
useLoaders,
loaderResolver,
resolveContext,
(err, result) => {
normalLoaders = result;
continueCallback(err);
}
);
this.resolveRequestArray(
contextInfo,
this.context,
useLoadersPre,
loaderResolver,
resolveContext,
(err, result) => {
preLoaders = result;
continueCallback(err);
}
);
当loader归类完成后,就需要把它们组装起来,同样 webpack4 和 webpack5 在这里表现也不一样。
webpack4 中使用 neo-async 并行解析loader数组。
asyncLib.parallel(
[
this.resolveRequestArray.bind(
this,
contextInfo,
this.context,
useLoadersPost,
loaderResolver
),
this.resolveRequestArray.bind(
this,
contextInfo,
this.context,
useLoaders,
loaderResolver
),
this.resolveRequestArray.bind(
this,
contextInfo,
this.context,
useLoadersPre,
loaderResolver
)
],
(err, results) => {
// ...
}
);
最终执行栈如下:
/*
results[0]: post loader
loaders: inline loader
results[1]: normal loader
results[2]: pre loader
*/
loaders = results[0].concat(loaders, results[1], results[2]);
webpack5 通过调用 continueCallback 方法,将匹配到的 loader 按 post,normal,pre 的顺序推入 loader 执行栈。
let postLoaders, normalLoaders, preLoaders;
const continueCallback = needCalls(3, err => {
if (err) {
return callback(err);
}
// 默认赋值为 post loader
const allLoaders = postLoaders;
if (matchResourceData === undefined) {
for (const loader of loaders) allLoaders.push(loader);
for (const loader of normalLoaders) allLoaders.push(loader);
} else {
for (const loader of normalLoaders) allLoaders.push(loader);
for (const loader of loaders) allLoaders.push(loader);
}
for (const loader of preLoaders) allLoaders.push(loader);
// ...
});
到这一步,loader执行栈就彻底形成了,可以发现无论是webpack4还是5,组装的执行栈顺序都是 post,inline,normal,pre 这与之前所说的loader执行顺序正好相反。别慌,因为真实的loader执行顺序其实是反向的。具体我们看下一小节的内容 loader runner 的运行。
6.2.2 loader runner的运行
loader runner 用于运行 loader。它运行 loader 时对应的 pitch,normal 2个阶段,分别对应 loader runner 中的 iteratePitchingLoaders,iterateNormalLoaders 2个方法。
iteratePitchingLoaders 会递归执行,同时记录 loader 的 pitch 状态,与当前的 loaderIndex。当它达到最大值(loader执行栈的长度)时,即所有loader的pitch loader已经执行完毕后,开始处理实际的module。此时调用 processResource 方法处理模块资源(添加当前模块为依赖,读取模块内容)。然后 loaderIndex--,并递归执行 iterateNormalLoaders 。
// abort after last loader
if(loaderContext.loaderIndex >= loaderContext.loaders.length)
return processResource(options, loaderContext, callback);
// iterate
if(currentLoaderObject.pitchExecuted) {
loaderContext.loaderIndex++;
return iteratePitchingLoaders(options, loaderContext, callback);
}
loaderContext.loaderIndex--;
iterateNormalLoaders(options, loaderContext, args, callback);
这套流程走下来就完成了loader在 pitch,normal 阶段的执行顺序。
6.2.3 熔断原理
来,全体目光向这段代码看齐。要记得在 loader runner 中,当 loaderIndex 达不到 loader 本身的长度时(有 pitch loader 提前 return 发生了熔断)时, processResource 这个方法是不会触发的,这就导致 addDependency 这个方法也不会触发,因此不会将该模块资源添加进依赖,无法读取模块的内容。继而熔断后续操作。
if(loaderContext.loaderIndex >= loaderContext.loaders.length)
return processResource(options, loaderContext, callback);
好了到这里 loader 的执行顺序就彻底讲解完毕了,下面进入异步处理的讲解。
6.3 loader的异步处理
无论是 loader 的 pitch 还是 normal 阶段,最终是在 loader runner 的 runSyncOrAsync 方法中执行。
在loader中调用 this.async 时,实际是将 loaderContext 上的 async 属性赋值为一个函数。isSync 变量默认为 true,当 loader 中使用 this.async 时,它被置为 false,并返回一个 innerCallback 作为异步回调完成的通知。
context.async = function async() {
if(isDone) {
if(reportedError) return; // ignore
throw new Error("async(): The callback was already called.");
}
isSync = false;
return innerCallback;
}
当 isSync 为 true 时,会在 loader function 执行完毕后同步回调 callback 继续 loader runner 的执行流程。
if(isSync) {
isDone = true;
if(result === undefined)
return callback();
if(result && typeof result === "object" && typeof result.then === "function") {
return result.then(function(r) {
callback(null, r);
}, callback);
}
return callback(null, result);
}
到这里loader的同步,异步原理就彻底讲解完毕了,下面我们讲讲loader中的this。
6.3 loader中的this
webpack官方对于loader的定义中有这样一段,函数中的 this 作为上下文会被 webpack 填充。那这个this到底是什么呢?是webpack的实例吗,其实不是,这个this是 loader runner 中的 loaderContext,我们熟悉的 async,callback 等都来自于这个对象。
// prepare loader objects
loaders = loaders.map(createLoaderObject);
loaderContext.context = contextDirectory;
loaderContext.loaderIndex = 0;
loaderContext.loaders = loaders;
loaderContext.resourcePath = resourcePath;
loaderContext.resourceQuery = resourceQuery;
loaderContext.resourceFragment = resourceFragment;
loaderContext.async = null;
loaderContext.callback = null;
// ...
好了,到这里loader关键部分的原理已经全部讲解完毕了,如有疏漏欢迎在评论区补充。
七. 尾巴
本来以为详解原理篇loader部分不要多久就能写完,但是写着写着竟然再次将近万字,码字不易,希望大家多多点赞。
本文内容偏多,回顾一下,我们从相关概念,分类,使用方式,手写练习,执行原理这几个篇章跟大家全面讲解了webpack loader。希望这篇文章让你对loader有个全面的认知。由于最近又要去忙其他的事了,plugin篇要咕一阵子,希望能尽快跟大家见面!
我是来蹭饭,一个会点儿吉他和编曲,绞尽脑汁想傍个富婆的摸鱼大师,希望本次的分享对你有帮助。

最后贴上本文参考资料链接:
转载自:https://juejin.cn/post/7202431872970276925