从零开始的Webpack原理剖析(五)——loader
前言
上篇文章,我们在学习webpack
工作流程前的时候,有介绍过loader
和plugin
是如何去写,并且也简单介绍了其工作的原理,那么接下来的几篇文章,我们将详细的讲解下loader
和plugin
,然后再写几个复杂的案例,来加深对loader
和plugin
的理解。
loader的执行顺序
我们在之前的文章中也有提到过,loader
的执行顺序是从下到上,从右到左,其实这句话并不是非常的严谨,
在loader
的执行过程中,其实是分2个阶段的,比如下边这张图所示。我们可以大致看一下,在后文会详细讲解这2个阶段。
另外,loader
其实配置的不同,分为4种,前3种是大家常见的,就是根据webpack.config.js
文件里,rules
中enforce
的不同配置项,分为:1、不配置:默认loader
;enforce: pre
:前置loader
,即优先执行;enforce: post
:后置loader
,即最后执行,那么还有一种,叫做行内loader
,这个大家几乎没见到过,它也不是写在配置文件中的,而是写在具体的loader
里边,写法为:
// 用 ! 进行不同loader的分隔
let inline_loader = `inline-loader1!inline-loader2!${entryFile}`;
那么,简单了解了以上概念,我们便继续往下看吧,等看完所有内容,再回过头来看上边的流程图,便能完全理解了。
loader如何被执行
使用loader-runner
运行loader
在上篇手写一个简易的webpack
文章中,我们是在拿到webpack.config.js
配置文件中的module.rules
的配置,然后找到loader
的位置,直接循环遍历执行相应的loader
。但其实这只是为了理解核心概念,才写的简易逻辑;那么在webpack
中loader
到底是如何执行的呢,其实,是有一个专门的库,来执行这一个个loader
的,从名字上我们也能够明白这个库是干啥的,这个库的名字叫:loader-runner说了这么多,我们先去用一下这个loader-runner
库,看看怎么让loader
执行起来。
同样,我们为了演示整个过程,要先初始化新的目录和新建文件:
// step1: 创建文件夹和文件
webpack-loader
|——src
|——index.js
|——loaders
|——pre-loader1.js
|——pre-loader2.js
|——normal-loader1.js
|——normal-loader2.js
|——inline-loader1.js
|——inline-loader2.js
|——post-loader1.js
|——post-loader2.js
|——runner.js
|——package.json
/* step2: 把每个xxx-loader.js里边都写上如下所示的代码,只是console.log中打印的内容和注释添加的内容
不同,这里只是以pre-loader1.js为例,其他内容自行手动添加(比如normal-loader1.js中就
console.log('normal1执行'),注释就写// normal1,其他文件类似) */
// pre-loader1.js
function loader(input) {
console.log('pre1执行')
return input + '// pre1'
}
module.exports = loader
// step3: 安装依赖
npm install webpack webpack-cli -D
做好准备工作后,我们就要开始在runner.js
中配置一下loader
,然后再用loader-runner
把配置的这些loader
运行一下吧~
// runner.js
const { runLoaders } = require('loader-runner')
const path = require('path')
const fs = require('fs')
const entryFile = path.resolve(__dirname, 'src/index.js')
// 配置行内loader
let inlineLoader = `inline-loader1!inline-loader2!${entryFile}`
// 我们这边的rules配置成和webpack.config.js中一样的写法
let rules = [
{
test: /\.js$/,
use: ['normal-loader1', 'normal-loader2']
},
{
test: /\.js$/,
enforce: 'post',
use: ['post-loader1', 'post-loader2']
},
{
test: /\.js$/,
enforce: 'pre',
use: ['pre-loader1', 'pre-loader2']
}
]
// 处理行内loader
inlineLoader = inlineLoader.split('!')
// 因为最后一项是入口文件,所以要pop弹出
inlineLoader.pop()
// 声明其他三种类型的loader
let preLoaders = [], postLoaders = [], normalLoaders = []
// 循环遍历rules,来将不同类型的loaderpush到相应数组
for (let i = 0; i < rules.length; i++) {
let rule = rules[i]
if (rule.test.test(entryFile)) {
if (rule.enforce === 'pre') {
preLoaders.push(...rule.use)
}else if (rule.enforce === 'post') {
postLoaders.push(...rule.use)
}else {
normalLoaders.push(...rule.use)
}
}
}
// 要按照顺序,将各种数组放进loaders数组中
let loaders = [
...postLoaders,
...inlineLoader,
...normalLoaders,
...preLoaders
]
// 根据数组名称,解析成绝对路径,不然runLoaders就找不到相应的loader
let resolveLoader = loader => path.resolve(__dirname, 'loaders', loader)
loaders = loaders.map(resolveLoader)
runLoaders({
resource: entryFile, // 需要处理的资源文件(入口文件)
loaders, // 使用了哪些loader进行处理
context: { name: '柠檬soda水', age: 666}, // 保存的一些信息
readResource: fs.readFile // 读文件的方法
}, (err, result) => {
console.log('报错信息:', err)
console.log('转换后的文件内容:', result.result[0].toString('utf8')) // 转换后的结果
console.log('转换前文件内容:', result.resourceBuffer?.toString('utf8')) // 转换前的结果
})
我们执行node runner.js
,查看结果,发现每个loader
都能按照顺序执行,文件也被成功的处理了。
有的小伙伴可能来问了,那个行内的loader
是咋用呢?为啥要有感叹号连接?首先那个感叹号是特殊的语法,只能在webpack
作为打包工具的项目中使用,当然不止有一个感叹号的写法,还有另外2种,那么根据不同的行内loader
的配置,可以选择不使用哪些前置或者后置或者普通的loader
,如果比较难以理解也没关系,后边会有具体的应用场景,到时候再来详细解释:
符号 | 含义 | 解释 |
---|---|---|
-! | 不要前置和普通loader | |
! | 不要普通loader | |
!! | 不要前后置和不同loader ,只要内联loader |
有的小伙伴可能又要继续问了,loader
的类型,感觉pre normal post
这三种就够了啊,那个行内的loader
是干嘛用的呢?当然有它独特的作用了,别急,我们在后文中自己实现一些常见loader
的时候,就会讲到。
pitch阶段
在前文我们提到过,loader
在执行的时候,其实不是从右向左执行的,而是先从左向右执行pitch
阶段,我们之前写的loader
代码,相当于只写了normal
阶段的代码,而没有写pitch
阶段的代码,所以看上去loader
顺序像是从右向左来执行的。如果在某个loader
的pitch
阶段有了返回值,那就相当于在它以及它右边的loader
已经执行完毕了,说直白点,它和它右边的loader
,就会不走了。啥意思呢?我们还是以刚才的代码为例,我们将pre-loader1.js
文件中的代码做下改动:
// pre-loader1.js
// 这个函数为normal阶段执行的代码
function loader(input) {
console.log('pre1执行')
return input + '// pre1'
}
// 添加pitch阶段的代码,实际上也是一个函数
loader.pitch = () => {
return 'pre1的pitch阶段'
}
module.exports = loader
我们再次执行node runner.js
查看结果如下:
神奇的事情发生了,馆擦汗结果可以发现:pre-loader1.js和pre-loader2.js
中normal
阶段代码直接没有被执行,观察转换后的文件内容可以发现,被执行的是pre-loader1.js
中pitch
阶段返回的代码。是不是有所顿悟了呢?
接下来我们还原pre-loader1.js
中的代码,在pre-loader2.js
中的代码中添加pitch
阶段,如下:
// pre-loader2.js
function loader(input) {
console.log('pre2执行')
return input + '// pre2'
}
loader.pitch = () => {
return 'pre2的pitch阶段'
}
module.exports = loader
再次执行node runner.js
,发现此时结果又有所不同:
此时pre-loader1.js
中normal
阶段的代码正常执行了,但是因为pre-loader2.js
中添加了pitch
阶段,所以pre-loader2.js
文件中的normal
阶段没有被执行,直接返回的是pitch
阶段里边的逻辑。诶,此时此刻,再回过头来看文章开头的那张图,是不是就能看懂了呢?
babel-loader编写
我们之前写的loader
要么就是打印了一些信息,要么就是加了下注释,看起来并没有什么卵用和实际意义,当然,这都是为了辅助理解loader
;那么接下来,我们要实现一些常见的loader
,再次加深对loader
如何处理代码全过程的理解。首先,来实现babel-loader
。
我们创建一个新的干净的目录,来实现我们接下来用到的一些插件:
// 目录结构
webpack-babel-loader
|——loader
|——babel-loader.js
|——src
|——index.js
|——webpack.config.js
|——package.json
// 安装相应的依赖
npm init -y // 初始化package.json文件,并配置 build命令为webpack
npm install webpack webpack-cli @babel/core @babel/preset-env babel-loader -D
// webpack.config.js文件中的配置
const path = require('path')
module.exports = {
entry: './src/index.js',
mode: 'development',
devtool: false,
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js'
},
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
plugins: []
}
}
}
]
}
}
// index.js
let test = () => console.log(333)
test()
对babel
不熟悉的小伙伴,可以看下我之前写的这篇文章: 小白都能听懂的最新版Babel配置探索——保姆级教学,本篇文章不再详细解释babel
配置和相应包的作用。
老规矩,配置完后,我们先尝试运行下,看看有没有配错的地方,执行npm run build
命令,发现生成打包文件dist/main.js
,里边内容也成功被转译成了ES5
语法:
/******/ (() => { // webpackBootstrap
var __webpack_exports__ = {};
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
var test = function test() {
return console.log(333);
};
test();
/******/ })()
;
配置的没问题,那么我们便开始写自己的babel-loader
了,那么如何在webpack
中使用自己的loader
呢?发包到npm
上固然是可以,但是毕竟要单独建个项目,单独写loader
,未免有些麻烦,其实我们可以在项目中写自己的loader
,然后在配置里边修改下路径就好了,怎么做呢?具体有以下三种方法:
// webpack.config.js文件中的配置
// 方法1:直接给loader指定绝对路径
const path = require('path')
module: {
rules: [
{
test: /\.js$/,
loader: path.resolve(__dirname, 'loader/babel-loader.js'),
exclude: /node_modules/
}
]
}
// 方法2:配置resolveLoader的别名与其绝对路径,然后在rules中直接使用别名
const path = require('path')
resolveLoader: {
alias: {
'babel-loader': path.resolve(__dirname, 'loader/babel-loader.js')
}
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/
}
]
}
// 方法3:配置resolveLoader的模块,通过修改loader查找的目录(默认在node_modules文件中查找)
const path = require('path')
resolveLoader: {
// 优先使用我们自定义的loader目录中的loader
modules: [path.resolve(__dirname, 'loader'), path.resolve(__dirname, 'node_modules')]
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/
}
]
}
配置好我们自己的babel-loader
路径之后,我们真正开始在babel-loader.js
文件中实现的逻辑了。
// babel-loader.js
const core = require('@babel/core')
function BabelLoader(resource) {
let options = this.getOptions()
// 使用@babel/core进行转换ast并处理
const { code } = core.transformSync(resource, options)
// 返回处理后的结果
return code
}
module.exports = BabelLoader
loader
中的this
是指loaderContext
上下文对象,里边包含了很多属性和方法,比如拿到我们webpack.config.js
配置中的rules
的options
用到的就是getOptions
方法,查看具体的可以在webpack
官方文档的API
模块中看到。
没错,babel-loader
就是这么简单,当然我们为了好理解,也只是写了核心的逻辑,至此,我们可以顺便分析一下babel-loader
工作的整个流程:
babel-loader只提供了一个转换的函数,并不知道要做什么,需要做事情的是
@babel/core
将我们传入的源码转化成ast
,但是它并不知道如何转化ES
语法,所以还需要使用babel
的一系列的插件或预设对ast
进行转化,得到结果之后@babel/core
再将处理过后的ast
转化成源代码,进行返回。
less-loader编写
我们继续新建一些文件,为了能更好的看到展示效果:
// step1: 安装依赖包
npm install html-webpack-plugin less -D
/* step2: 在loader文件夹中新建两个文件:less-loader.js和style-loader.js,在src目录下初始化
index.html文件,在body中添加<div id="app">我是标题</div>*/
// step3: 在src目录下新建index.less文件,然后在index.js中 import './index.less':
// index.less文件内容:
@color: green;
#app {
color: @color
}
// step4: webpack.config.js中增加配置项
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: './src/index.js',
mode: 'development',
devtool: false,
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js'
},
resolveLoader: {
alias: {
'babel-loader': path.resolve(__dirname, 'loader/babel-loader.js'),
'style-loader': path.resolve(__dirname, 'loader/style-loader.js'),
'less-loader': path.resolve(__dirname, 'loader/less-loader.js')
}
},
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
plugins: []
}
}
}, {
test: /\.less$/,
use: [
'style-loader',
'less-loader'
]
}
]
},
plugins: [new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'src/index.html')
})]
}
新建完文件后,我们便可以写less-loader
和style-loader
了,可能有小伙伴要问了,为啥没有css-loader
呢?其实css-loader
主要作用就是处理@import
和url()
语法的,我们这里只演示最简单的案例,所以就不写css-loader
了,代码如下所示:
// less-loader.js
const less = require('less')
function loader(source) {
const callback = this.async()
less.render(source, { filename: this.resource, compress: true }, (err, output) => {
callback(err, output.css)
})
}
module.exports = loader
this.async()
是什么意思呢?其实它返回的就是callback
函数,我们只需要知道它的用法就是用在异步函数的回调函数中,这样我们自定义的loader
才能返回异步处理的结果。其实less-loader
的作用就是找到.less
结尾的文件,然后调用less
的render
方法,将其转化成css
并返回给下一个loader
。那么,我们继续写style-loader
,其实原理更简单,就是生成一个style
标签,插入到html
文件的头部即可。
// style-loader.js
function loader(source) {
let script = `
let style = document.createElement("style");
style.innerHTML = ${JSON.stringify(source)};
document.head.appendChild(style);
`;
return script;
}
module.exports = loader;
写完两个loader
之后,我们执行npm run build
命令进行打包,然后在浏览器中打开打包结果的dist/index.html
文件,发现样式被加载进来,并且成功生效了。
稍稍进行改造一下
如果,less-loader
中返回的不是一个css
模块,而是一个CommonJs
导出的模块呢?那这就是个js
代码了,事实上css-loader
就是通过module.exports
的方式进行导入的,我们这里为了演示方便,同样忽略css-loader
,直接在less-loader
通过module.exports
进行导出:
let less = require('less')
function loader(source) {
let callback = this.async()
less.render(source, { filename: this.resource, compress: true }, (err, output) => {
callback(err, `module.exports = ${JSON.stringify(output.css)}`) // 这里不再导出css模块了,而是通过module.exports进行导出
})
}
module.exports = loader
如果这时候,我们再执行npm run build
,然后查看页面,会发现样式不能正常生效了,打开控制台我们可以发现,我们style-loader
执行过后的结果,是module.exports
导出的结果,并不是css
样式表,那怎么才能让样式生效呢?
有了前几篇文章的基础,我们可能突然就闪过一个思路:我们用require()
进行调用执行,取里边的内容,再插入到<script>
标签中,不就可以了么?有了想法我们便可以来写代码了,接下来就会用到前边所说的pitch
阶段和行内loader
:
const path = require('path')
// 因为loader中存在pitch函数,所以不再走normal阶段了,所有的逻辑都在pitch阶段即pitch函数中处理
function loader() {}
loader.pitch = function(remainingRequest) {
// remainingRequest代表着还有多少个loader没有走到,返回的是loader和文件的绝对路径,并用!进行了分隔
// 比如此处的remainingRequest即为:xxx/xxx/xxx/less-loader.js!xxx/xxx/xxx/index.less
// 1.因为我们require中传入的参数是相对路径,所以我们首先要将remainingRequest的路径转化为用!分隔的相对路径
// 2.this.context为当前文件所在的上下文路径,次数为src文件夹的绝对路径
let request = remainingRequest.split('!').map(absolutePath => ('./' + path.relative(this.context, absolutePath))).join('!')
// 3.前边拼接!! 为什么前边要拼接两个感叹号呢,就是上文我们提到过的,表示只使用行内loader,不使用webpack.config.js中配置的loader
request = '!!' + request // 此时的request为:!!./../loader/less-loader.js!./index.less
// 4.用行内loader,利用require进行调用,即require('!!./../loader/less-loader.js!./index.less'),注意,加感叹号是上文提到的webpack中loader-runner支持的语法
let script = `
const css = require(${JSON.stringify(request)})
const style = document.createElement('style')
style.innerHTML = css
document.head.appendChild(style)
`
return script
}
module.exports = loader;
我们可以从代码中看到,最后less-loader
依旧被执行了,但是是通过行内loader
的方式进行调用的,所以小小总结一下这里loader
的执行顺序:
- 首先
style-loader
中因为有pitch
函数,所以直接走到了pitch
阶段,normal
阶段和其余在webpack.config.js
配置的loader
,统统都不再执行了。style-loader
的pitch
阶段中,又调用了行内loader
,即require('!!./../loader/less-loader.js!./index.less')
,!!
代表只执行行内loader
,不再执行webpack.config.js
中配置的loader
了,否则会造成loader
调用的死循环。require()
执行后,会按照顺序,用less-loader
处理index.less
文件,把结果进行了返回。
其实我们只要了解了核心的概念,其他的事情,基本就是查询相关API
了,至此,简版的style-loader
便写完了。
总结
通过上边的两个loader
小案例,我们可以发现,loader
的作用其实就是提供一个转换的函数,真正的转译是要依靠其他的核心包或插件的,正是所谓的分工明确,各有不同。其实在学习写loader
之前,一直感觉是一个高深莫测的事情,但经过一段时间的接触和学习,已经不是当初被问到就会胆战心惊的状态了,那么文中所写的loader
也都是用最少的代码,来解释最核心的概念,方便我们每个初学者都能理解,而不是看到陌生的代码就直接溜溜球🪀了。还是那句话,就算我们工作中很少能够接触到这方面的代码,至少在面试造火箭的时候,我们不再方了,在项目中使用别人写的loader
的时候,也会比较有底气,能够知道它是怎么执行的,而不是仅仅停留在会配的阶段。
转载自:https://juejin.cn/post/7156129237160689671