loader 原理与实现
loader 的执行顺序
loader 的执行顺序是:从右到左、从下到上。在配置 sass 样式时,需要这么去写 loader:
代码语言:javascript复制{
test: /.sass$/,
use: ['style-loader','css-loader','sass-loader']
}
loader 会先执行 sass-loader,让 sass 格式的样式转成 css 格式,然后使用 css-loader 处理样式中引入的图片路径,最后使用 style-loader 将样式插入到 style 标签中。因此是 “从右到左” 执行。再看下面的配置:
代码语言:javascript复制[
{
test: /.js$/,
use: 'babel-loader'
},{
test: /.js$/,
use: 'eslint-loader'
}
]
eslint-loader 放在最后,就是先执行 eslint-loader,检验代码书写规则,然后再执行别的 js loader,所以是 “从下到上” 执行。
loader 的类型
expose-loader
expose-loader 可以将局部变量暴露到 window 上。比如当我们使用 jQuery 时,可以这样引入:import $ from jquery
。但是当获取 window.$
时必不能获得 jQuery 对象。这是因为 webpack 做了处理没让 jquery 变量暴露给 window。如果想让 改变量暴露出来,就可以使用 expose-loader
。用法是将 import
语法修改成下面的样子:
import $ from "expose-loader?$!jquery";
上面的代码中,expose-loader
就是指使用的该模块,而 ?
和 !
是固定格式,它们之间的 $
表示要暴露给 window 的变量。这样,jQuery 对象就暴露给了全局。
当然也可以在 webpack 中进行配置:
代码语言:javascript复制{
rules: [
{
// 当引用了 jquery 模块时就使用 expose-loader
test: require.resolve('jquery'),
use: 'expose-loader?$'
}
]
}
但是使用上面的配置之后,还是要使用 import $ from "jquery";
方式去引入模块。如果不想每次重复去写这一句代码。可以使用 webpack 自带的一个插件:ProvidePlugin
。使用了这个插件之后,也不用再每次都引入。配置如下:
const webpack from 'webpack';
{
plugins: [
new webpack.ProvidePlugin({
// 在每个模块中注入 $ 变量
$: 'jquery'
})
]
}
expose-loader
的配置与别的 loader 有些不同,expose-loader 可以用在 import
语句中。这种 loader 称为内联(inline)loader。
前面已经介绍过,webpack loader 的配置项中,有一个 enforce
配置项可以指定 loader 的执行顺序。
rules: [
{
test: /.js$/,
use: 'loader1',
// pre 表示这个 loader 在前面先执行
enforce: 'pre'
}
]
enforce 有三个取值:
pre
:这个 loader 在前面先执行;post
:这个 loader 在后面执行;normal
:在中间执行(pre 之后,post 之前,normal 是默认的值)
除了这三种 loader 还有一个就是行内 loader(inline)。这四种 loader 的执行顺序是这样的:先执行 pre;在执行 normal;然后执行 inline;最后执行 post。
行内 loader 比较特殊,不能使用 enforce
进行配置。需要在引入文件时进行配置,比如使用行内 loader 处理 a.js 文件的执行,需要这么来写:
// inline-loader-name 就是行内 loader 的名字
// ! 感叹后右边是文件路径
// 当然,import 方式的书写方式与 require 一样
const a = require("inline-loader-name!./a.js");
如果你使用了 inline-loader,又不希望前置 loader 和 normal loader 再去执行,可以使用 -!
的方式禁止:
const a = require("-!inline-loader-name!./a.js");
当前面只加了 !
时表示 normal loader 不会再执行(const a = require("!inline-loader-name!./a.js");
);
前面有两个 !!
时表示 只有 inline-loader 会执行,别的 loader 都不会再执行。
loader 的组成
loader 默认有两部分组成:pitch 和 normal。
pitch无返回值的loader
loader 会先执行 pitch,然后获取资源再执行 normal loader。如果 pitch 有返回值时,就不会走之后的 loader,并将返回值返回给之前的 loader。
pitch有返回值的loader
loader 其实就是一个函数,函数的参数是处理文件的文件内容,参数类型是字符串。这个函数还有一个 pitch 方法,同样也有一个参数,是字符串形式的剩余参数,这个剩余参数中有当前 loader 之后还没有执行的 loader 的所在的绝对路径。
因此 webpack 的配置文件中的 use: [loader3,loader2,loader1]
的执行顺序是这样的:假如三个 loader 的 pitch 函数都没有返回值(或者说没写 loader.pitch 函数,没有返回值的 pitch 函数是没有用的,当写 pitch 函数时就应考虑返回什么),那么就直接获取资源,然后走下面的 normal 部分。如果 loader2 的 pitch 有返回值,则 pitch 的 loader1 和 normal 的 loader1、loader2 就不会执行,而是执行 normal 的 loader3 函数。
loader 的特点
- 第一个 loader 要返回 js 脚本(字符串格式的脚本,这里的第一个 loader 指的是数组的最左边的那个 loader)
- 每个 loader 只做一件事,为了使 loader 在更多的场景中链式调用;
- 每一个 loader 都是一个模块;
- 每个 loader 都是无状态的,确保 loader 在不同的模块转换之间保存状态。
var loader = function(source){
console.log(source);
}
loader.pitch = function(remainingRequest){
return ;
}
实现 babel-loader
需要先下载 @babel/core
、@babel/preset-env
两个 Babel 包:
npm install @babel/core @babel/preset-env
代码语言:javascript复制{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
"presets": [
"@babel/preset-env"
]
}
}
}
下面的库在以下的代码中会用到,需要下载:
@babel/core
babel 核心模块;@babel/preset-env
babel 必备模块,负责代码转码;less
编写 less-laoder 时需要引入;loader-utils
编写 webpack loader 的工具库;schema-utils
一个可以校验变量类型的库;mime
该模块可以获取文件后缀;
babel-loader
代码语言:javascript复制const babel = require("@babel/core");
const loaderUtils = require("loader-utils");
function loader(source){
// loader 中有一个 this 指向 loaderContext
// getOptions 可以获得 loader 中的 options 配置对象
var options = loaderUtils.getOptions(this);
// loaderContext 中有一个 async 方法
// 这个方法是为了能异步的返回处理好的结果
// cb 接受两个参数,第一个参数是 error 信息,
// 第二个参数是 处理好 source 后的结果
var cb = this.async();
// babel 转码函数
babel.transform(source,{
...options,
// 在 loader 中指定了 sourceMap 后还需要在 webpack 中进行配置(devtool: 'source-map')才能生成 sourceMap
sourceMaps: true,
// 指定文件的名字。resourcePath 就是文件所在的绝对路径(因此需要使用 split 方法)
filename: this.resourcePath.split('/').pop()
},function(err,result){
// console.log(result);
// 异步的返回结果
// result.code 就是loader处理后的代码
// result.map 就是 sourceMap
cb(err,result.code,result.map);
});
}
module.exports = loader;
banner-loader
banner-loader
是一个可以将注释插入到 js 文件页面顶部的 loader,这个 loader 可以表示 js 文件的一些说明。banner-loader 接受两个参数:text:直接传入一个注释用的字符串,filename:一个注释模板文件(路径),指定后就会读取模板文件中的内容。
{
test: /.js$/,
use: {
loader: 'banner-loader',
options: {
text: "xxxx",
filename: ""
}
}
}
具体的实现源码:
代码语言:javascript复制const fs = require("fs");
const loaderUtils = require("loader-utils");
// schema-utils 是一个校验模块
const schemaUtils = require("schema-utils");
function loader(source){
// 指定为 false 后,webpack 每次打包都不进行缓存
// webpack 默认有缓存(有缓存是有好处的,可以节约时间)
this.cacheable(false);
var options = loaderUtils.getOptions(this);
var cb = this.async();
// 创建一个验证骨架
var schema = {
// 属性中的参数
properties: {
text: {
type: "string",
},
filename: {
type: 'string',
}
}
}
// 第三个参数表示不符合条件时报出的错误信息
schemaUtils(schema, options, "banner-loader");
if(options.filename){
// 这个方法原理是这样的:
// webpack 中可以指定 witch 配置为 true
// 表示当需要打包的文件更改时,webpack会自动打包
// 而 options.filename 中的文件更改后webpack并不会进行打包
// 因此需要让 webpack 明白,该文件在修改后也会触发 witch 监听并自动打包
this.addDependency(options.filename);
// 读取文件
fs.readFile(options.filename,"utf8",function(err,data){
cb(err,`/** ${data} **/rn${source}`);
});
}else{
// 指定的是text参数
cb(null,`/** ${options.text} **/rn${source}`);
}
return source;
}
module.exports = loader;
file-loader 和 url-loader
file-loader
代码语言:javascript复制const loaderUtils = require("loader-utils");
/**
* file-loader 的作用:
* 根据图片生成一个 MD5 并发射到打包的目录下
* file-laoder 还会返回当前的文件路径(在 js 中可以使用 import 方式进行引入)
* @param {string} source
*/
function loader(source){
// 根据当前的格式和文件内容来创建一个路径
let filename = loaderUtils.interpolateName(this,'[name].[ext]',{
content: source
});
// 发射文件
this.emitFile(filename,source);
// 返回文件的路径
return `module.exports="${filename}"`;
}
// source 接受的是字符串,而图片是二进制文件
// 因此需要使用 raw 属性,将字符串转成 二进制数据
loader.raw = true;
module.exports = loader;
url-loader
代码语言:javascript复制const loaderUtils = require("loader-utils");
// mime 包可以获取文件的后缀
const mime = require("mime");
/**
* url-loader 会处理路径
* url-loader 有一个 options 选项:limit
* limit 选项可以指定文件的大小(字节)
* 当文件小于 limit 值时会生成 base64 的字符串
* 大于 limit 值时才会像 file-loader 一样去处理文件
* 因此,在 webpack 中使用了 url-loader 后,就不用再使用 file-loader 了。
* @param {string} source
*/
function loader(source){
var {limit} = loaderUtils.getOptions(this);
if(limit && limit > source.length){
// 转成 base64 格式
return `module.exports="data:${mime.getType(this.resourcePath)};base64,${source.toString("base64")}"`;
}else{
// 否则的话就交给 file-loader 去处理
return require("./file-loader").call(this,source);
}
}
loader.raw = true;
module.exports = loader;
样式 loader 的编写
webpack 中的配置:
代码语言:javascript复制rules: [
{
test: /.less$/,
use: ['style-loader','css-loader','less-loader']
}
]
less-loader 的实现
less-loader 主要是将 less 格式的样式转成浏览器能认识的原生 css 代码。
- 首先需要先下载
less
:npm install less
。 - 编写
less-loader
的 loader 文件。
// less-loader
let less = require("less");
// source 就是 less 文件中的源码
function loader(source){
let css;
// less 中有一个方法
// 这个方法可以处理 less 文件中的样式
less.render(source,function(err,result){
// 处理好后,回调函数中的 result 参数就是处理好后的结果
css = r.css
});
// 返回处理好的结果
return css;
}
module.exports = loader;
上面就完成了 less-loader 的编写。less-loader 的返回值回传给 css-loader,css-loader 再对样式做进一步的处理。处理好后再把处理好的结果返回,让 style-loader
接受,做最后的处理。
css-loader 的处理过程比较麻烦,这里先介绍一下 style-loader。
style-loader 的编写
style-loader
的作用是将 css 代码插入到 head
标签下的 style
标签中。
webpack 配置文件中的 use 数组中的第一个 loader 应该返回一个 JS 脚本(字符串格式的 JS 脚本),因此 style-loader
需要这么做。
// style-loader
const loaderUtils = require("loader-utils");
function loader(source){
// 创建一个 style 标签,标签里的内容就是 css-loader 处理后的结果
let str = `
let style = document.createElement("style");
style.innerHTML = ${JSON.stringify(source)};
document.head.appendChild(style);
`;
// 返回这个 JS 脚本
return str;
}
css-loader 的实现
css-loader 处理的是样式中引入的图片路径(url(xxx)
)。
我们就需要想办法将源码中的 url()
字符串提取出来,然后给路径做替换。再把替换后的路径插入到源码中。
先说一下 JavaScript 正则表达式中的一个方法:exec
。这个方法很强大。它的调用格式:reg.exec(str)
。
这个方法会返回一个数组,数组里面是匹配到的字符串结果。此数组的第 0 个元素是与正则表达式相匹配的文本,第 1 个元素是与 RegExpObject 的第 1 个子表达式相匹配的文本(如果有的话),第 2 个元素是与 RegExpObject 的第 2 个子表达式相匹配的文本(如果有的话),以此类推。RegExpObject
可以看做是正则表达式中的括号里匹配的内容。比如下面的字符串:
let str = `
body{
background: url('./01.jpg');
}
div{
background: url('./02.png');
}
`;
var reg = /url((. ?))/g,
res = reg.exec(str);
console.log(res);
打印的结果将返回一个数组:["url('./01.jpg')","'./01.jpg'"]
。数组第一项正则表达式匹配的文本,而第二项匹配的是正则表达式中 (. ?)
中的内容。
exec
方法可以连续调用,当再次调用 var next = reg.exec(str)
时,将返回 ["url('./02.png')","'./01.png'"]
。表示匹配下一个符合条件的的字符串。当匹配不到时会返回 null
。
因此可以使用循环找出所有符合条件的结果:
代码语言:javascript复制var current = reg.exec(str),
arr = [];
while(current){
arr.push(current);
current = reg.exec(str);
}
RegExpObject
中有一个 lastIndex
属性,当 exec() 找到了与表达式相匹配的文本时,在匹配后,它将把 RegExpObject 的 lastIndex 属性设置为匹配文本的最后一个字符的下一个位置。比如:
var reg = /123(abc)/g,
str = 'qwe123abcqqq',
reg.exec(str);
console.log(reg.lastIndex); // 9
因此利用 lastIndex
熟悉就可以将截掉的 css 代码拿出来,利用字符串的 slice
方法。
以下就是 css-loader 的源码:
代码语言:javascript复制// css-loader
function loader(source){
// 匹配 url(xxx) 格式的字符串
// 正则表达式的子项(括号里匹配的内容)匹配的就是纯粹的路径
let reg = /url((. ?))/g;
let pos = 0;
let current;
let arr = ['let list = []'];
// current
while(current = reg.exec(source)){
let [matchUrl,g] = current;
// last 就是 url() 字符的第一个字符(u)前面的那个字符的索引
let last = reg.lastIndex - matchUrl.length;
// 将 url() 字符串的前面的内容添加到数组中
arr.push(`list.push(${JSON.stringify(source.slice(pos,last))}`);
// 然后 pos 等于 lastIndex,为了保存 url() 字符串后面的内容
pos = reg.lastIndex;
// 把 g 替换成 require 的写法 => url(require('xxx'))
arr.push(`list.push('url(' require(${g} ')')`);
}
// url() 字符串后面的内容截取完即可
arr.push(`list.push(${JSON.stringify(source.slice(pos))})`);
// 最后将 list 拼接并导出(list 中存入的改过的 css 代码)
arr.push(`module.exports = list.join('')`);
return eval(arr.join('rn'));
}
我们还没有用到 pitch 函数,这里可以在 style-loader 中使用 pitch
函数(当然,不使用也可以,前面都已实现,这里只是再使用 pitch 模拟一下):
// style-loader.js
const loaderUtils = require("loader-utils");
function loader(source){
// 创建一个 style 标签,标签里的内容就是 css-loader 处理后的结果
let str = `
let style = document.createElement("style");
style.innerHTML = ${JSON.stringify(source)};
document.head.appendChild(style);
`;
// 返回这个 JS 脚本
return str;
}
// 在 style-loader 上写 pitch
//(pitch 的执行顺序是从左到右,即:style-loader 先被执行)
// `style-loader` 的 pitch 有返回值时,
// css-loader 和 less-loader 的 pitch 就不在执行了。就是开始执行 `normal`。
loader.pitch = function(remainingRequest){
// remainingRequest 表示为“剩余的请求”,
// 在 pitch 中,先走的是 style-loader
// 因此还剩下 css-loader 和 less-loader 没有执行
// 所以,remainingRequest 就是 css-loader!less-loader! 当前文件路径(就是 less 文件路径) 的格式的字符串
// 让 style-laoder 去处理 remainingRequest
// loaderUtils.stringifyRequest 方法可以将绝对路径转成相对路径
// !!css-loader!less-loader!less文件路径
// remainingRequest 中的路径是绝对路径,需要转换一下
// require 的路径返回的就是 css-loader 处理好的结果
// innerHTML中 引用了 css-loader 和 style-loader
// 这时就会跳过剩余的 pitch,开始获取资源,执行 normal。先执行 less-loader
// 然后执行 css-loader 最后执行 style-loader
var str = `
let style = document.createElement('style');
style.innerHTML = require(${loaderUtils.stringifyRequest(this,'!!' remainingRequest)});
document.head.appendChild(style);
`;
return str;
}
module.exports = loader;
以上就是样式 loader 的实现原理。