一、为啥要用 loader?
配置过 webpack 的小伙伴都记得,webpack 繁琐的格式配置,其中一项就是不同文件后缀需要配置一堆 loader。那么,为什么我们要使用 loader 呢?
因为 webpack 只认识 js, json 这两种文件。
webpack 在编译过程中,当读取了一个 js 文件的内容:
- 会将它转换 ast 语法树,然后分析其中 cjs 或 esm 格式的依赖
- 然后再去
fs.readFile这个依赖的内容(buffer) - 将读取到的 buffer 通过
toString方法转化成代码字符串
如果这个代码字符串是合法的 js 或 json,则按照深度优先的策略,递归,重复上面的动作,直到穷尽依赖。如果不合法,就会报错。
那我们 loader 的作用,就是把对应的,原本不是合法 js 文件的内容,转化为合法的 js 代码字符串。
历史上 loader 的雏形
起初,要使用一个带有图片,样式等资源的组件,是非常繁琐的:
- 要按照依赖顺序要求引入 js 之外
- 按照需要在头部引入 css 文件
- 在 html 文件中粘贴一大段 html 代码
- 还需要正确引入图片的地址(好在背景图一般是在 css 中靠相对路径来维护,不需要使用者考虑)
自从 js 模块化方案的兴起,开发人员也开始对 js 管理资源有了诉求:
对于一个组件来说,我们希望只考虑在需要的时候一行代码引入它,就可以使用了。至于它依赖了哪些 js、css、html、image,都由这个组件自己维护。
比如 require.js 的时代,我们创建一个符合 AMD 规范的 DatePicker 组件,它大概会这样:
define([
'jquery', // 引入第三方依赖的模块,需要配置 alias
'./Picker', // 相对路径,引入和自己同目录的 Picker.js 文件
'text!./index.ejs', // 将 index.ejs 作为字符串引入,供下方调用
'css!./index.css', // 将 index.css 作为 <style> 内容,自动插入 <head> 中
], function($, Picker, html) {
return function init(options) {
const picker = new Picker(html)
$(document).on(options.type, options.className, function(event) {
picker.show(event.target)
})
$(document).on('click', function() {
picker.hide()
})
}
})
注意其中的 text!./index.ejs 和 css!./index.css。它很像 webpack 的内联式 loader 对不对?其实它们起的作用也是类似的。
在 require.js 时代,就有这样的使用方式。而到了 webpack 的时代,loader 的使用就更普遍。
二、一个简单的例子:raw-loader
比如我们看下面一个简单的例子,在 js 文件中引用 ejs 作为模板:
在 webpack 中的使用
index.js:
import html from './hello.ejs'
console.log(html)
hello.ejs:
<div><%= a %></div>
如果直接使用 webpack 打包会报错:
我们为 webpack.config.js 加上 raw-loader 的配置:
module: {
rules: [
{
test: /\.ejs$/,
use: ['raw-loader']
}
]
}
查看构建后的产物:
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
"use strict";
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {
"default": () => (__WEBPACK_DEFAULT_EXPORT__)
});
const __WEBPACK_DEFAULT_EXPORT__ = ("<div><%= a %></div>");
})
直接看上面的构件产物,可能觉得很复杂。因为 raw-loader 处理过后,webpack 继续对它进行了打包编译。
loader 究竟做了什么?
其实,我们更想探究 loader 单独做了什么事情。这里,我们可以使用 loader-runner 这个库。webpack 底层是调用 loader-runner 来执行 loaders 对资源的转化。
我们可以直接使用这个库,来看到这一“单元步骤”所做的工作:
编写一个 useLoaderRunner.js :
const { runLoaders } = require('loader-runner')
const path = require('path');
const fs = require('fs');
runLoaders({
resource: path.resolve(__dirname, './src/hello.ejs'),
loaders: ['raw-loader'],
readResource: fs.readFile.bind(fs)
}, (err, result) => {
console.log(result.result[0])
})
这个字符串的结果是:
原来,raw-loader 就是把原始文件内容读成字符串,然后给她包裹 export default " 和 ",它就变成了 webpack 所认识的 js 代码了。
三、项目中 loader 使用常见场景
脚本语法转换
主要是 babel-loader 和 ts-loader。
这两者其实都有较多的配置工作,所以更推荐使用 .babelrc 和 tsconfig.json 把它们的配置抽离出去。
然后配置兼容不同后缀:
{
test: /\.jsx?$/,
exclude: /(node_modules|bower_components)/,
use: ['babel-loader'],
},
{
test: /\.tsx?/,
exclude: /(node_modules|bower_components)/,
use: ['babel-loader', 'ts-loader'],
}
有几个性能优化的点:
exclude排除匹配,表示 node_modules 里这个后缀的文件,不需要经过 babel-loader- babel-loader 相较 babel 本身,多了
cacheDirectory配置,可以提高效率
样式转化,插入或提取
主要有 css-loader, style-loader, MiniCssExtractPlugin.loader 以及 样式的语法转换:less-loader, sass-loader, postcss-loader 等
比如:
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader'],
},
{
test: /\.less$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'less-loader'],
}
样式需要注意的点:
- 如果需要把 style 文件提取出来,则使用 MiniCssExtractPlugin.loader 代替 style-loader
- css module 默认的匹配是
/\.module\.\w+$/i
文件转换
webpack v4 的版本,使用的是:
file-loader:把依赖的文件输出到目标地址,并更换 js 和 css 中的路径 url-loader: 把图片内容转 base64 数据,替换 js 和 css 的路径使用 raw-loader:把 txt 的内容直接返回。
webpack v5 之后,改为配置 Rule.type 为 asset/resource, asset/inline, asset/source。比如:
// 输出到目录,并改名,等同于 file-loader
{
test: /.png$/,
type: 'asset/resource',
generator: {
filename: 'asset/[name].[hash][ext]',
},
},
// 转 base 64,等同于 url-loader
{
test: /.webp$/,
type: 'asset/inline',
},
// 会把txt内容直接返回, 等同于 raw-loader
{
test: /.txt$/,
type: 'asset/source',
}
四、loader 的执行次序
四种 loader
上面的例子,我们配置的都属于普通 loader。
实际上,每一条 rule 有一个 enforce 属性,可以设置为 pre 和 post。
另外,loader 除了写在配置文件里面,还可以用内联的写法。
所以总共我们有四种 loader,分别是:
- post 后置
- inline 内联/行内,不推荐在源代码中编写
- normal 普通/正常
- pre 前置
四种 loader 分别是四个数组,这四个数组按上面的顺序,从左往右拼成一个大的执行数组。
注意:webpack v4 的时候还有 cli 可以配置 loader,v5 已经废除。
内联 loader 的前缀
webpack 的官方文档明确表示:
不应使用内联 loader 和
!前缀,因为它是非标准的。它们可能会被 loader 生成代码使用
是什么意思呢?原来,内联 loader 是可以添加前缀来忽略部分 loader 组的。
- 所有普通 loader 可以通过在请求中加上
!前缀来忽略(覆盖)。 - 所有普通和前置 loader 可以通过在请求中加上
-!前缀来忽略(覆盖)。 - 所有普通,后置和前置 loader 可以通过在请求中加上
!!前缀来忽略(覆盖)。
为什么有这个机制呢?为了让生成的代码使用内联 loader,而生成的代码应该能选择性地屏蔽用户配置的部分。
loader 执行的两个阶段
loader 本身是一个函数,它们是从右到左被调用:
- 最右侧的 loader 应该是接收一个参数,源文件的内容字符串。
- 最左侧的 loader 应该返回 webpack 所期望的 js 或 json 代码字符串。
- 中间的 loader 形成调用链条传递,可以是同步,也可以是异步
但是有些情况,loader 只关心请求后面的元数据,而不关心右侧 loader 执行的结果。这个时候如果还要等右侧 loader 一个个执行,就凭白浪费了性能。所以 loader 还提供了一个 pitch 方法,挂载到 loader 函数上。即:
function loader1() {}
loader1.pitch = function() {}
module.exports = loader1
最终整合之后的调用方式是:
- 首先根据
enforce和内联前缀,整合成一个实际调用的最终 loader 执行数组 - 然后从左往右调用
pitch方法,如果有返回就传递给左侧 loader 本身执行 pitch阶段完毕,如果都没有返回,则读取文件内容,把它传递给最右侧 loader 作为参数- loader 本身从右往左执行,前一个的返回值作为后一个的参数,全部执行完传递给 webpack 进行下一步操作
这个流程代表性的 loader 就是 style-loader。
五、自定义 loader
有时候我们会自己编写 loader 并使用。使用肯定会把它发布为一个 npm 公有包或者私有包,然后直接调用。但是调试阶段如果也这样就很麻烦。
调试的方式
- npm link 本地链接
- 把之前的
module.rules[].use.loader中'xxx-loader'改为某个 loader 的绝对路径path.resolve('./my-loader.js') - 配置
resolveLoader
编写自定义 loader
我们接下来编写 2 个最简单的 loader:
它们的功能是给 .mjs 结尾的文件,添加一行 console.log。
两个 loader 分别以同步和异步的方式来实现。
同步 loader
plugins/custom1-loader.js
function custom1Loader(code) {
return `console.log('custom2Loader')\n${code}`
}
module.exports = custom1Loader
同步的 loader 接收上一步的参数,如果它是最右侧,那么 code 代表源文件的内容。
我们做的事情就是拼一个字符串,然后返回。
异步 loader
plugins/custom2-loader.js
function custom2Loader(code) {
const callback = this.async();
setTimeout(function () {
callback(null, `console.log('custom1Loader')\n${code}`)
}, 100)
}
module.exports = custom2Loader
异步 loader 一般想象的返回 promise 的想法不同。
它首先调用 this.async 得到一个 callback 方法。
当异步执行完毕需要返回结果时,需要传两个参数,第一个参数表示错误,如果有就会报错。第二个参数才是传给下一个 loader 或者 webpack 下一步的值。
调用
配置 wepback.config.js
module: {
rules: [
{
test: /.mjs$/,
use: [
path.resolve(__dirname, 'plugins/custom2-loader.js'),
path.resolve(__dirname, 'plugins/custom1-loader.js'),
]
}
]
}
然后我们可以在构建的结果中看到这个模块最上面出现了:
console.log('custom1Loader')
console.log('custom2Loader')
// 模块的原始内容...