对于webpack,一切皆模块。webpack 只能理解 JavaScript 和 JSON 文件,其他类型/后缀的文件都需要经过 loader 处理,将它们转换为js可识别的有效模块 (webpack 天生支持 ECMAScript、CommonJS、资源模块等模块类型)。loader可以做语言翻译(比如将文件从 TypeScript 转换为 JavaScript) 或格式转换(将内联图像转换为 data URL)还有样式编译(允许直接在 JavaScript 模块中 import CSS文件)。
loader 是什么
每个 loader 本质上都是一个导出为函数的 JavaScript 模块。loader runner 会调用此函数,将资源文件或者上一个 loader 产生的结果传进去,经过编译转换把处理结果再输出去(如果后面还有 loader 就传给下一个)。函数中的 this 作为上下文会被 webpack 填充,并且 loader runner 中包含一些实用的方法,比如可以使 loader 调用方式变为异步,或者获取 query 参数。
简言之 loader 就是模块转换器。有点像 Vue 的过滤器。
同步模式
loader 如果返回单个处理结果,可以在直接 return。如果有多个处理结果,则必须调用 this.callback()
。this.callback 方法则更灵活,因为它允许传递多个参数,而不仅仅是 content。
module.exports = function (content, map, meta) {
return someSyncOperation(content);
};
// 需要传递多个参数,用 this.callback
module.exports = function (content, map, meta) {
this.callback(null, someSyncOperation(content), map, meta);
return; // 当调用 callback() 函数时,总是返回 undefined
};
异步模式
由于同步计算过于耗时,在 Node.js 这样的单线程环境下进行此操作并不是好的方案,很多 loader 都是异步的。
在异步 loader 中,必须调用 this.async()
来告知 loader runner 等待异步结果,它会返回 this.callback()
回调函数。随后 loader 必须返回 undefined
并且调用该回调函数。
module.exports = function (content, map, meta) {
var callback = this.async();
someAsyncOperation(content, function (err, result) {
if (err) return callback(err);
callback(null, result, map, meta);
});
};
// 多个处理结果
module.exports = function (content, map, meta) {
var callback = this.async();
someAsyncOperation(content, function (err, result, sourceMaps, meta) {
if (err) return callback(err);
callback(null, result, sourceMaps, meta);
});
};
以下是一个模拟的raw-loader,它用于加载文件的原始内容(utf-8)。比如把一个 import/require() 进来的 txt文件的内容以字符串的形式导出去。这个 loader 虽然在 webpack5 已经弃用了,但我们仍可以参考下自定义 loader 的写法。
// 获取webpack配置options参数的方法,写loader的固定第一步
const { getOptions } = require("loader-utils");
/**
*
* @param {string|Buffer} content 传入的源文件的内容
* @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 数据
* @param {any} [meta] meta 数据,可以是任何内容
*/
module.exports = function(content, map, meta) {
// 如果是module.rules配置,返回的是options;如果是内联loadername!语法,返回根据query字符串生成的对象
const opts = getOptions(this) || {};
const code = JSON.stringify(content);
const isESM = typeof opts.esModule !== "undefined" ? options.esModule : true;
// 根据配置是否开启esModule决定导出语句,直接返回原文件内容
// esModule: false 就是使用 CommonJS 规范
const result = `${isESM ? "export default" : "module.exports ="} ${code}`;
return result;
};
比如我们有一个文本文件 example.txt,调用这个 loader 后,过程相当于:
源文件内容 this is a txt file
被处理成了 js:
// example.js
export default 'this is a txt file'
// 或
module.exports = 'this is a txt file';
然后 webpack 交给 require 去引入:
const source = require('example.js');
console.log(source); // this is a txt file
loader 的使用/配置
1. 在config文件配置
在 module.rules 配置转换规则时,有两个必选属性 test 和 use。
像这样 module: { rules: [{ test: /\.txt$/, use: 'raw-loader' }] }
会告诉 webpack 编译器(compiler) ,当碰到「在 require()/import 语句中被解析为 '.txt' 的路径」时,在你对它打包之前,先用 raw-loader 转换一下(预处理)。
module.exports = {
module: {
rules: [
{
test: /\.vue$/,
use: ['a-loader', 'b-loader', 'c-loader'], // 从右到左,c-loader -> b-loader -> a-loader
},
{
test: /\.css$/, // test属性,规定哪些文件会被转换
use: [ // use属性,在进行转换时,应用哪些 loader
{ loader: 'style-loader' },
{
loader: 'css-loader',
options: {
modules: true
}
},
{ loader: 'sass-loader' }
] // 从下到上,sass-loader -> css-loader -> style-loader
}
],
},
}
2. 用 webpack-chain 配置
end() 方法的作用是向上移动一级,移回更高的上下文环境,就可以继续链式调用上级API的方法。比如这里是到.use() 层级继续链式调用
// 从下到上,vue-loader -> cache-loader
webpackConfig.module
.rule('vue')
.test(/\.vue$/)
.use('cache-loader')
.loader('cache-loader')
.options({...})
.end()
.use('vue-loader')
.loader('vue-loader')
.options(Object.assign({...})
3. 内联方式(inline)(官方不推荐使用)
在 import 语句中显式指定 loader。
使用 !
将资源中的 loader 分开。每个部分都会相对于当前目录解析。对于解析相同的文件类型,inline loader优先级高于config配置文件中的loader。这个方法不用熟记(官方都说了不应该使用orz),了解即可。
通过为内联 import
语句添加前缀,可以覆盖 配置 中的所有 loader, preLoader 和 postLoader:
// 从右到左,css-loader -> style-loader
import Styles from 'style-loader!css-loader?modules!./styles.css'
// 相当于
rules: [{
test: /^styles\.css$/,
use: [
{ loader: 'style-loader' },
{
loader: 'css-loader',
options: {
modules: true
}
},
]
}]
loader 的执行顺序
每个 Loader 的功能都应是单一而专注的,这样不仅便于维护,还能让它们在更多场景中被串联应用。因此 Loader 通常是组合使用的。链式调用一组 loader 时 (无论是模块规则配置还是内联方式),它们会按照相反的顺序执行。即从右到左(或从下到上),依次将前一个 loader 转换后的结果传递给下一个 loader。直到最后一个 loader 返回 webpack 所期望的 JavaScript。有点像 Promise 的 then。
loader 可以用 String 或 Buffer 的形式传递它的处理结果,complier 会把它们在 loader 之间相互转换。最终结果也就是最后一个 loader 会返回一或两个值:第一个是代表模块的 JavaScript 源码的 String 或者 Buffer(这个结果会交给 webpack 的 require,因此一定是一段可执行的 node 模块的 JS 脚本[用字符串存储的]);第二个是可选的 SourceMap (格式为 JSON 对象)。
一组 loader 的执行有两个阶段:Pitching 阶段 和 Normal 阶段,类似于js中的事件捕获、冒泡。
webpack 的 loader-runner 会按正序(从左到右) require 每个 loader,把这个 loader 的模块导出函数 和 pitch函数都存到 loaderContext 对象上,然后执行该 loader 的 pitch 方法(如果有的话);如果一组 loader 的 pitch 都没有返回值,就开始 Normal阶段:反向(从右到左)执行 loader 的导出函数,依次进行模块源码的转换,直到拿到最后的处理结果;但是当 Pitching 阶段某个 loader 的 pitch 有返回值,那么就会跳过剩余未读取的 loader,直接进入执行 loader 的环节。从前一个 require 的 loader 开始执行,pitch 的返回值即是传入的第一个参数。除了 pitch 有返回的那个 loader,倒序执行已经 require 的每个 loader。
原理可参考:浅析 webpack 打包流程(原理) 二 之【执行 loader 阶段,初始化模块 module,并用 loader 倒序转译】部分
module: {
rules: [{ test:/\.vue$/, use: ['a-loader', 'b-loader', 'c-loader'] }]
},
根据以上配置,*.vue
文件在 loader 处理阶段将经历以下步骤:
|- a-loader `pitch` 方法
|- b-loader `pitch` 方法
|- c-loader `pitch` 方法
|- 以模块依赖的形式即 import/require() 获取资源内容
|- c-loader normal 执行
|- b-loader normal 执行
|- a-loader normal 执行
如果 b-loader 的 pitch 方法有返回值,直接跳过 c-loader 进入 loader 执行阶段,并且 b-loader 也不会执行。整个过程就会变成这样:
|- a-loader `pitch` 方法
|- b-loader `pitch` 方法 (有返回结果,则跳过后面未 require 的 loader,直接进入 loader 执行阶段)
|- a-loader normal 执行 (传入参数是 b-loader pitch 的返回值)
图解更清晰:
loader 可以利用 pitch 阶段来做什么?
Pitch 方法是什么:每个
loader
可以挂载一个pitch
函数,该函数主要用于利用module
的request
来提前做一些拦截处理的工作(后面会举例说明),并不实际处理模块内容。
事实上很多 loader 并未定义 pitch,一般定义了 pitch 就是某些情况要返回东西。
详情请看 Pitching Loader。
当一组 loader 被链式调用,像上面的例子,正常情况只有最后一个c-loader
能获得资源文件(起始 loader 只有一个入参:资源文件的内容),b-loader
拿到的是c-loader
处理结果,中间如果再多几个 loader 也是如此,只能拿到上一个传来的值,处理好再传递给下一个。直到第一个a-loader
返回最终结果。
尽管 loaders 常被串联使用,但它们的功能仍旧是单一并独立的,且只关心自己的输入和输出。就像工厂流水线,一个区域的工人/机器只干一种类型的活。所以合理搭配并配置正确的顺序才能得到我们想要的结果。
它只想要 request 后面的 元数据(调用 loader时传入的第三个参数 metadata)。
但有时候我们需要把两个用来做最后处理的 loader 串起来,比如 style-loader 和 css-loader。
但 style-loader 并不需要 css-loader 的结果,它只需要 request 后的元数据。
module: {
rules: [{ test:/\.css$/, use: ['style-loader', 'css-loader'] }]
},
如果按正常流程走,style-loader 只能拿到 css-loader 转换的结果,一个包含可动态执行函数的js字符串(含有模块导出代码,类似module.exports语句)。因此 style-loader 在 pitch 函数里返回了包含类似require(!!css-loader!./*.css)
的js字符串。简化如下:
const path = require('path');
const loaderUtils = require('loader-utils');
// style-loader 导出了一个空函数
module.exports = function () {
}
// style-loader 的 pitch 方法
module.exports.pitch = function(request){
var result = [
'var content=require(' + loaderUtils.stringifyRequest(this),'!!' + request)+')',
'require' + loaderUtils.stringifyRequest(this,'!'+ path.join(__dirname,"add-style.js")) + ')(content)',
'if(content.locals) module.exports = content.locals'
]
return result.join(';');
}
当 webpack 执行这个结果时,就会内联调用require()
语句中的loader
进行处理。!!
前缀可以禁用 webpack 配置中的所有 loader,因此不会再重复递归调用 style-loader,现在只会用 css-loader 处理。
除此之外,传递给 pitch 方法的 第三个参数(data),在主函数执行阶段也会暴露在 this.data 之下,可用于在循环时捕获并共享前面的信息。比如cache-loader
利用 pitch 进行缓存读取,如果存在缓存就跳过后面 loader 的编译。
// cache-loader 主函数
module.exports = function (...args) {
const callback = this.async();
const { data } = this; // 拿到 pitch 方法传递的 data
const toDepDetails = (dep, mapCallback) => {
FS.stat(dep, (err, stats) => {
const mtime = stats.mtime.getTime();
if (mtime / 1000 >= Math.floor(data.startTime / 1000)) {
cache = false;
}
});
};
writeFn(data.cacheKey, {
remainingRequest: pathWithCacheContext(options.cacheContext, data.remainingRequest),
dependencies: deps,
contextDependencies: contextDeps,
result: args
}, () => {
// ignore errors here
callback(null, ...args);
});
};
// cache-loader 的 pitch 方法
module.exports.pitch = function (remainingRequest, prevRequest, dataInput) {
const data = dataInput;
data.remainingRequest = remainingRequest;
data.cacheKey = cacheKeyFn(options, data.remainingRequest);
// 根据 cacheKey 的标识获取对应的缓存文件内容
readFn(data.cacheKey, (readErr, cacheData) => {
// 遍历所有依赖文件路径
async.each(cacheData.dependencies.concat(cacheData.contextDependencies), (dep, eachCallback) => {
// ...
FS.stat(contextDep.path, (statErr, stats) => {
const compStats = stats;
const compDep = contextDep;
// 对比当前文件最新的 mtime 和缓存当中记录的 mtime 是否一致
if (compareFn(compStats, compDep) !== true) {
eachCallback(true);
return;
}
eachCallback();
});
},
(err) => {
if (err) {
data.startTime = Date.now(); // 这个时间能被主函数执行时获取
callback();
return;
}
// ...
callback(null, ...cacheData.result);
}
);
})
};
cache-loader 的 pitch 方法会根据生成的 cacheKey 去查找 node_modules/.cache
目录缓存的 json 文件。如果缓存文件中记录的所有依赖以及这个文件本身都没发生变化,那么就直接读取缓存中的内容并返回,同时跳过后面 loader 的执行。一旦依赖或者这个文件发生变化,那么就正常走后面 loader 的 pitch 方法,以及执行 loader 的流程。
手动指定 loader 的执行顺序
用内联方式为引入模块指定 loader 时,在 import 语句添加前缀可以覆盖 配置 中的所有 loader, preLoader 和 postLoader,从而影响到 pitch 和执行的顺序。
比如使用 -!
前缀,将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoaders。
// 在 /src/file.js 文件中
require('-!./loader1?xyz!loader2!./resource?rrr');
// 或
import Styles from '-!style-loader!css-loader?modules!./styles.css';
那 preLoader、postLoader 是什么意思呢?
其实 pre
和 post
都是我们用 Rule.enforce 配置项指定的loader执行类型(可选值),分别表示 优先处理 和 最后处理。不指定即指按正常顺序处理,即默认都是 normal loader。还有就是我们上面讲的内联 loader,也就是“行内 loader”,即 loader 被应用在 import/require 行内。
Normal 阶段是按 前置(pre)、普通(normal)、行内(inline)、后置(post)
的顺序调用loader的,Pitching 阶段则相反。
如果不加 enforce 属性,用以下顺序配置loader,匹配到css文件时的(单指Normal阶段)默认执行顺序是 style-loader -> css-loader -> sass-loader
(从下往上), 而我们按照自己的需要标上优先和后置顺序后,结果就符合预期了:sass-loader -> css-loader -> style-loader
module: {
rules: [
{
test: /\.css$/,
loader: : 'sass-loader',
enforce: 'pre', // 指定为前置类型
},
{
test: /\.css$/,
loader: : 'css-loader', // 没指定enforce,为普通类型
},
{
test: /\.css$/,
loader: : 'style-loader',
enforce: 'post', // 指定为后置类型
}
]
},
再看 webpack-chain 的实现:
根据文档API和实测,webpack-chain 的 enforce
配置和上面的有很大的区别。.enforce(preOrPost)
传的值是指先执行上面的loader(pre
) 或下面的loader (post
),不要把这里的pre
和post
跟前置loader和后置loader混淆了。
// 速记格式解读
config.module
.rule(name)
.test(test)
.pre() // 指代 .use(loader-name-pre)
.post() // 指代另一个 .use(loader-name-post)
.enforce(preOrPost) // 值为'pre' 则表示先执行上面的loader,即loader-name-pre;值为'post' 则先执行下方的loader,loader-name-post
感觉这个API不是很合理😂
// 本来从下到上,现在 vue-loader -> cache-loader
config.module
.rule('vue')
.test(/\.vue$/)
.use('vue-loader') // 在前面表示 pre
.loader(require.resolve('vue-loader'))
.end()
.use('cache-loader') // 在后面表示 post
.loader(require.resolve('cache-loader'))
.end()
.enforce('pre') // 表示先执行 vue-loader
使用嵌套rules来跳出多余的预处理规则
可以使用属性 rules
和 oneOf
来指定嵌套规则,比较常用且是很推荐的做法。
Rule.oneOf 规则数组:只使用 oneOf 数组中第一个匹配到的规则。
常规写法比如给某类格式的文件用 module.rules 配置了3个loader,执行时就会按 pre-> normal -> post 的顺序都去处理一遍。而 oneOf 可以用 resourceQuery 属性来查询与资源请求字符串的查询部分(即从?
开始)相匹配的 Condition
。看下例:
foo.css?inline
只会经过 url-loader 处理,而 foo.css?external
只会经过 file-loader 处理。
module: {
rules: [
{
test: /\\.css$/,
oneOf: [
{
resourceQuery: /inline/, // foo.css?inline
use: 'url-loader',
},
{
resourceQuery: /external/, // foo.css?external
use: 'file-loader',
},
],
},
],
}
webpack-chain 版本:
config.module
.rule('css')
.oneOf('inline')
.resourceQuery(/inline/)
.use('url')
.loader('url-loader')
.end()
.end()
.oneOf('external')
.resourceQuery(/external/)
.use('file')
.loader('file-loader')
常用loader
文件
-
url-loader
与file-loader
类似,但当文件 size 小于设置的 limit 值,会返回 data URL -
file-loader
将文件保存至输出文件夹中并返回 URL (默认是是绝对路径,可以 outputPath 和 publicPath 通过配置成相对路径)
语法转换
-
babel-loader
使用 Babel 加载 ES2015+ 代码并将其转换为 ES5 -
ts-loader
像加载 JavaScript 一样加载 TypeScript 2.0+
样式
-
style-loader
将样式模块导出的内容以往 <head> 中注入多个 <style> 的形式,添加到 DOM 中 -
css-loader
加载 CSS 文件并解析 @import 的 CSS 文件,将 url() 处理成 require() 请求,最终返回 CSS 代码 -
less-loader
加载并编译 LESS 文件 -
sass-loader
加载并编译 SASS/SCSS 文件 -
postcss-loader
使用 PostCSS 加载并转换 CSS/SSS 文件 -
stylus-loader
加载并编译 Stylus 文件
框架
-
vue-loader
加载并编译 —— Vue 组件 vue-cli4 之 vue-loader 工作流程
参考:
Writing a loader
webpack使用笔记(四)loader原理与实现
手把手教你撸一个 Webpack Loader
Webpack中Loader的pitch方法