这个是探究 Webpack 本质的系列文章,会详细讲解如何手写一些源码如 Webpack, loader, plugin 等等,本文主要讲解的是如何手写 loader
loader 是个啥
webpack 只能处理 js 中的模块,所以若是要处理其他类型的文件的时候,就需要 loader 去做转换(transform)。本质上来说,loader 是用来将一段代码转换成另一段代码的 webpack 加载器。为什么叫做 “loader”?因为当你使用 import
或者其他方式去 “加载” 模块时,这些 loader 就会对模块进行一些预处理的工作。这就是 loader 的作用。
我们经常写 loader
时会这么写
function loader(source) { |
可以看出 loader
本质上是一个函数,并且其参数就是源码
loader 的配置规则
在 webpack.config.js
的配置中,loader
的配置一般有三种写法
在
use
中写死路径module: {
rules: [
{
test: /\.js$/,
// loader 为项目目录的 /loaders/demo-loader.js
use: path.resolve(__dirname, 'loaders', 'demo-loader.js')
}
]
}在
resolveLoader
中建立别名,然后use
使用这个别名resolveLoader: {
alias: {
demoLoader: path.resolve(__dirname, 'loaders', 'demo-loader.js')
}
},
module: {
rules: [
{
test: /\.js$/,
// 别名
use: 'demoLoader'
}
]
}在
resolveLoader
中配置modules
,从自定义的目录开始寻找resolveLoader: {
// node_modules 目录下找不到,就会从 loaders 目录下寻找 loader
modules: ['node_modules', path.resolve(__dirname, 'loaders')]
},
module: {
rules: [
{
test: /\.js$/,
use: 'demo-loader'
}
]
}
loader 的执行顺序
从配置来看,默认的 loader
执行顺序是从下到上、从右往左的。对于 loader
来说,它有如下的分类
- pre (在前面执行)
- normal (默认不配置)
- inline (插入)
- post (在后面执行)
比如下面的配置
module: { |
其执行顺序就变成了 demo-loader -> demo-loader2 -> demo-loader3
那 inline loader
是怎么回事呢?它实际上就是插入的 loader,如果希望在 require
其他模块时将其导到 post loader
前面去执行,就这样写
// ./src/index.js |
这里的 inline-loader
是 inline-loader 的名字,前面加个 !
表示希望交给 inline-loader
去做处理
那么打印顺序就变成了
loader |
但是由于 require
配置的不同,inline-loader
插入的方式也不一样,比如
// ./src/index.js |
那么打印顺序变成
loader |
后面的 pre
以及 normal
的就没有打印出来了
如果只有一个 !
表示后面的 normal
不执行
// ./src/index.js |
那么打印顺序变成
loader |
如果是两个 !
,则表示后面的啥都不执行
// ./src/index.js |
那么打印顺序变成
loader |
总结下符号的含义
-!
禁用前置和正常 loader!
禁用普通 loader!!
禁用前置后置以及正常 loader
这里说一个为何 inline-loader
前面有打印输出,这是因为 loader
本身还由两个阶段组成: pitching 和 normal execution
假如一个 use 配置是这样的
use: ['loader3', 'loader2', 'loader1'] |
如果 loader
的 pitch
方法都没有返回值,那么它执行的顺序为
|- loader3 `pitch` |
如果某一个 loader
的 pitch
方法有返回值(比如 loader2
),那么它执行的顺序为
|- loader3 `pitch` |
最后需要说下的是 loader
的特点
- 第一个
loader
必须返回的是 js 脚本 - 每个
loader
只做其对应的事情,这是为了使loader
能在更多的场景下进行链式调用 - 每一个
loader
都是一个模块 - 每一个
loader
都应该是 无状态 的,确保loader
在不同模块的转换之间不保存状态
手写 babel-loader
首先需要装三个包
yarn add @babel/core @babel/preset-env loader-utils |
- @babel/core: js 编译器的核心组件
- @babel/preset-env babel 的插件预设,用来提供支持新语法的环境
- loader-utils 一个专门用于写 webpack loader 的工具库
然后 webpack.config.js
的内容如下
const path = require('path') |
这里说一下 source-map
,source-map
是一种提供 debug 的源码映射文件,它建立的是一种将压缩文件中的代码对应到源代码文件的方式,有这个source map 文件,出问题了就可以找到对应的代码是哪一段
然后就可以写 babel-loader
了
const babel = require('@babel/core') |
这里的逻辑就是,拿到 loader
的 Context,将其转化成 options
,然后使用 babel
的 transform
API 来进行代码的转换(es6 -> es5),里面除了传入 options
之外,还要传入 sourceMaps
确定生成 soureMap 文件,然后 filename
设置 sourcemap 的文件名。最后是一个异步回调函数,由于是异步的,所以这里就需要 async
这个 API,只要调用它就会自动返回传的参数。
手写 banner-loader
实现这个 loader
的功能主要是这样
- 读取 js 文件,获取其里面的内容,将其作为注释加入到最终生成的源码文件中
- 若这个 js 文件不存在,则读取其配置中的
text
选项的内容作为注释加入到最终生成的源码文件中
先安装 schema-utils
,这是一个 webpack 专门用于验证 loaders
以及 plugins
的 options
的库
yarn add schema-utils |
然后是 webpack.config.js
配置,修改 module.rules[0]
选项
{ |
同时设置 watch
选项为 true
在项目当前目录增加 banner.js
文件,文件里面写上 helloworld
然后在 loaders
目录下增加 banner-loader.js
文件
const fs = require('fs') |
主要的逻辑很简单,就是看有没有配置有没有写对,配置写对了的话就看 filename
存在不存在,filename
不存在就走 text
,filename
存在就读取相应文件然后写进去。这里有两个 loader
的 API 需要注意的,一个是 cacheable
,另一个是 addDependency
,前者是用来缓存文件的,后者是用来配合 webpack
的 watch
配置来用的
手写 file-loader 以及 url-loader
首先新建一个图片 demo.jpg
放入 assets
目录下
然后在 src/index.js
中写入
// 测试 file-loader 以及 url-loader |
file-loader
的作用就是
- 根据图片生成 md5 发射到
dist
目录下 - 返回当前的图片路径
首先是 webpack.config.js
{ |
在项目 loaders
下新建 file-loader.js
文件
const loaderUtils = require('loader-utils') |
这里需要注意的一点是 需要将 source 变成二进制的 buffer,这样便于传参以及后续的处理。最后返回的是一个 module.exports
,因为最终需要的是一个路径,这样相当于改变了模块的源码,别人在引入图片的时候就直接引入了路径了
当然更高级一点的写法是 url-loader
,这个 loader 的作用就是
- 将传入的文件做一个
limit
的判断,如果这个文件大于limit
,则返回的是文件路径 - 若小于
limit
,则返回的是一串base64
写这个之前需要安装 mime
yarn add mime |
mime
是一个判断文件类型的工具包,生成 base64
的时候还需要注明图片类型
const loaderUtils = require('loader-utils') |
这里我们看到使用二进制的好处是便于判断图片的大小
手写 less-loader、css-loader 以及 style-loader
由于上篇文章已经讲过 less-loader
以及 style-loader
的写法(那个时候将 style-loader
当成 css-loader
使用了,不过不影响讲解),这里就列出 less-loader
以及 style-loader
的源码
首先还是要安装 less
yarn add less |
然后是 webpack.config.js
{ |
注意这里 use
数组的顺序不能变
src/index.less
@color: red; |
这里的分号必须是要的!!!
src/index.js
里面引入
import './index.less' |
然后是 less-loader.js
const less = require('less') |
然后是 style-loader
function loader(source) { |
前面讲过,使用 JSON.stringfy
的原因是可以将源码转换成字符串,而且把里面的回车都转成 \r\n
现在将 index.less
改成如下
@color: red; |
很显然,打包进 dist/bundle.js
中的文件路径直接在这里使用是引用不到的,因为 dist
目录下并没有 assets/demo.jpg
。这时候就必须要写 css-loader
来解析这个 less
文件,首先就是要考虑到 url(./assets/demo.jpg)
,将他变成 url(require(./assets/demo.jpg))
就可以了,因为有 require
的情况下 webpack
就会自动将其打包到 bundle.js
中了
那么怎么做呢?可以将这个 index.less
分为三个部分
【@color: red; |
如上,用 “【】”包起来的就是一部分了,接下来看看 css-loader.js
的代码
function loader(source) { |
这里主要的思路就是将如上的 less
中的代码看成三个部分,然后以匹配 url
为界点将代码分成三个部分,前两个部分都在循环匹配 url
,然后将 url
里的路径改为 require
引入。匹配修改完成之后再截取剩下的第三部分。这里用了数组 arr
的方式将 less
代码转变成了另一段代码,而这段代码也是用数组的形式将 less
代码 拼凑起来,最后形成了一个 模块。到最后,style-loader
在使用 require
引入时就以字符串的形式引入了这些代码。
接下来改造 style-loader.js
,增加 pitch
函数
const loaderUtils = require('loader-utils') |
注意,这里使用
stringifyRequest
的意义在于,由于 webpack 在将模块路径转换为模块 id 之前会计算哈希值,因此在这里必须避免使用绝对路径,以确保不同编译之间的哈希值保持一致。
这样写的好处就是,style-loader
的 pitch loader
会首先执行,但是上面的普通 loader
就不会执行了。这里参数 remainingRequest
相当于剩下的请求,打印出来其实就相当于局部路径 css-loader!less-loader!./index.less
。还记得前面说的这些感叹号的意思么? require('css-loader!less-loader!./index.less')
表示内联 loader,优先级是 从右往左。但是这里就有个问题,index.less
会先从 style-loader
的 pitch
开始,因为有 require
的存在,会经过 less-loader
和 css-loader
,然后又会经过 style-loader
的 pitch
,这样一来就会死循环了。所以需要在 require
时添加 !!,也就是 require('!!css-loader!less-loader!./index.less')
,这里 !! 号就表示执行完 less-loader
和 css-loader
就不会调用 style-loader
去执行了,也就解决了之前的死循环的问题。
总结
本文写了比较多的 loader
,现在对 loader
一个最基本的认识就是 loader
就是一个 函数,一旦有模块被 import
或者 require
时它就会去拦截这些模块的源码,对其进行改造,然后输出到另一个模块中,循环往复,最终迭代到入口文件中,形成最终的代码
所以它的本质在于对代码的 拼凑 和 转换,其中用的比较多的库是 loaderUtils
,这个工具库在写 loader
时必用,需要注意。