手写一个 Webpack Loader
注意,本文不是一篇详细的 Loader 开发教程,只是介绍了开发 Loader 的大致流程,让对 Loader 开发感兴趣的读者有一个大致的了解。详细的教程还请查看 Webpack 官方文档 —— Loader Interface 。
1. Loader 是什么?
如果你的项目是使用 Webpack 作为打包工具,那么你一定跟 loader
打过交道,一个项目先上来就是安装各种 loader
。举个例子:
// webpack.config.js
module.exports = {
// ...其他配置
module: {
rules: [
{
test: /\.vue$/,
use: "vue-loader"
},
{
test: /\.(s[ac]|c)ss$/i,
use: [
"style-loader",
"css-loader",
"postcss-loader",
"sass-loader"
]
},
// ...其他 loader
]
}
}
复制代码
我们使用 vue-loader
来处理 Vue 文件,使用 css-loader
、sass-loader
等来处理 CSS,此外还有其他各式各样的 loader
。
loader
在项目中的使用如此广泛,以致于在面试中我们经常会听到这些问题:
loader
是什么?- 用过哪些
loader
? - 自己写过
loader
吗?
那么,loader
到底是什么?
上面这句话是来自 Webpack 文档中对于 loader
的解释,本质上,一个 loader
就是一个导出为函数的 JavaScript
模块。
由于 Webpack 内部默认只支持处理 JS 和 JSON 文件,所以如果想要处理其他类型的文件,就必须借助 loader
来处理。从这点上来看,说它是一个文件转换器也不为过。
得益于开源社区,我们日常开发需要用到的 loader
基本上都能找得到,但是如果我们真的碰到需要自己开发 loader
的场景,那就只能自己上了。这里有一份官方文档 Loader Interface,建议大家先去看一遍,我帮大家把一些重要的概念摘录出来。
2. Loader 上下文
在开发 loader
的过程中,我们会频繁地使用到 loader
上下文,你可以使用 this
来访问 Webpack 提供的各种属性和方法,通过 this
可以获取当前 loader
的一些信息。更详细的可以看 The Loader Context。
function loader(content, map, meta) {
console.log(this.mode) // 可能的值为:"production", "development", "none"
}
复制代码
3. Loader 的执行顺序
在下面这段代码中,loader
的执行顺序是倒过来的,并且上一个 loader
输出会成为下一个 loader
的输入:
// webpack.config.js
module.exports = {
// ...其他配置
module: {
rules: [
{
test: /\.(s[ac]|c)ss$/i,
use: [
"style-loader",
"css-loader",
"postcss-loader",
"sass-loader"
]
}
]
}
}
复制代码
上面 loader
的执行顺序为:sass-loader
-> postcss-loader
-> css-loader
-> style-loader
。
4. Loader 的输入和输出
Loader 的输入
/**
* @param {string|Buffer} content 源文件的内容
* @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 数据
* @param {any} [meta] meta 数据,可以是任何内容
*/
function loader(content, map, meta) {
}
复制代码
content
:必选,上一个loader
的输出。对于第一个loader
而言,只有只一个入参,其输入是资源文件的内容。map
:可选,SourceMap 数据。meta
:可选,元数据,可以是任何内容,用于loader
之间传递额外信息,Webpack 不会对其做处理。
Loader 的输出
loader
的返回的结果应该是 String
或者 Buffer
类型(可以被转成 String
类型)。
对于最后一个 loader
而言,其结果代表了模块的 JavaScript
源码,即 JavaScript
代码字符串。这个结果会传给 Webpack Compiler 进行下一步的处理。
举个例子,有以下的 SCSS
文件:
.home {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
.button {
width: 60px;
}
}
复制代码
sass-loader
的输出,将 SCSS
编译成 CSS
:
.home {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.home .button {
width: 60px;
}
复制代码
postcss-loader
的输出,自动添加浏览器前缀:
.home {
width: 100%;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.home .button {
width: 60px;
}
复制代码
css-loader
的输出,这个结果会传给 Webpack Compiler 进行下一步的处理:
// Imports
import ___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___ from "../node_modules/css-loader/dist/runtime/noSourceMaps.js";
import ___CSS_LOADER_API_IMPORT___ from "../node_modules/css-loader/dist/runtime/api.js";
var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___);
// Module
___CSS_LOADER_EXPORT___.push([module.id, ".home[data-v-7ba5bd90] {\n width: 100%;\n display: -webkit-box;\n display: -ms-flexbox;\n display: flex;\n -webkit-box-orient: vertical;\n -webkit-box-direction: normal;\n -ms-flex-direction: column;\n flex-direction: column;\n -webkit-box-align: center;\n -ms-flex-align: center;\n align-items: center;\n}\n.home .button[data-v-7ba5bd90] {\n width: 60px;\n}", ""]);
// Exports
export default ___CSS_LOADER_EXPORT___;
复制代码
5. 如何返回 Loader 的处理结果
loader
有同步 loader
和异步 loader
。
同步 Loader
对于同步 loader
而言,可以使用 return
或 this.callback()
来返回转换后的结果:
function loader(content, map, meta) {
return syncFn(content)
}
复制代码
又或者:
function loader(content, map, meta) {
this.callback(null, syncFn(content), map, meta)
return // 当调用 `this.callback()` 函数时,总是返回 undefined
}
复制代码
异步 Loader
对于异步 loader
而言,使用 this.async()
来获取 callback()
函数,然后再返回结果的时候调用:
async function loader(content, map, meta) {
const callback = this.async()
let result
try {
result = await asyncFn(content)
} catch (error) {
callback(error)
}
callback(null, result, map, meta)
}
复制代码
不管是同步 Loader 还是异步 Loader,其回调函数 callback()
都使用 Error-First
风格,即第一个参数为错误信息,如果没有错误,则设置为 null
。如果你写过 Node.js
,那么应该很熟悉这种风格。
了解以上这些信息,你就可以自己动手实现一个 loader
了。
6. 自己实现一个 Loader
Demo 源码在这里,你也可以下载下来自己折腾:webpack-learning。
首先,新建目录 custom-loader
,入口文件为 index.js
,用于存放我们的自定义 loader
:
// custom-loader/index.js
function loader(content, map, meta) {
const logger = this.getLogger()
logger.info('[custom-loader] running...')
this.callback(null, content, map, meta)
return
}
module.exports = loader
复制代码
接着,引入 custom-loader
:
// webpack.config.js
module.exports = {
// ...其他配置
module: {
rules: [
{
test: /\.(s[ac]|c)ss$/i,
use: [
"style-loader",
"css-loader",
"postcss-loader",
"sass-loader",
"custom-loader"
]
}
]
}
}
复制代码
按照我们前面得到的信息,这些 loader
的执行顺序为:custom-loader
-> sass-loader
-> …
然后,执行 yarn dev
,这时候会报错,说找不到 custom-loader
:
Module not found: Error: Can't resolve 'custom-loader' in xxx
复制代码
一般情况下,我们都是通过 NPM/Yarn 来安装 loader
的:
# npm
npm install sass-loader --save-dev
#yarn
yarn add sass-loader -D
复制代码
如果要使用本地的 loader
,我们可以使用 NPM Link;又或者配置 Webpack 的 resolveLoader
属性,告诉 Webpack 去哪里找到这个 loader
:
// webpack.config.js
module.exports = {
// ...其他配置
module: {
rules: [
{
test: /\.(s[ac]|c)ss$/i,
use: [
"style-loader",
"css-loader",
"postcss-loader",
"sass-loader",
"custom-loader"
]
}
]
},
// `custom-loader`
resolveLoader: {
alias: {
"custom-loader": path.resolve(__dirname, "./custom-loader/index.js")
}
}
}
复制代码
再次运行 yarn dev
,如无意外,可以看到成功运行的信息:
[custom-loader] running...
复制代码
下面我们以一个 Vue
demo 为例,来简单实现一个 loader
。
// App.vue
<template>
<div class="home">
<img class="img" src="https://juejin.cn/post/assets/logo.png" alt="logo" />
<h1 class="title">Hello, {{ msg }}!</h1>
<button class="button" @click="toggle">toggle</button>
<div>{{ greeting }}</div>
</div>
</template>
<script setup>
import { ref } from "vue";
const msg = ref("Vue.js");
const greeting = ref("");
function toggle() {
msg.value = msg.value === "Vue.js" ? "Webpack" : "Vue.js";
}
</script>
<style lang="scss" scoped>
.home {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
.button {
width: 60px;
}
}
</style>
复制代码
示例截图:
注意此时的 title
,只有默认样式。接下来,我们改造一下 custom-loader
:
// custom-loader/index.js
function loader(content, map, meta) {
const logger = this.getLogger()
logger.info('[custom-loader] running...')
logger.info('input content:', content)
content = `
.home {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
.title {
width: 360px;
padding: 5px 0;
text-align: center;
color: #ffffff;
background-color: #1e90ff;
}
.button {
width: 60px;
}
}
`
this.callback(null, content, map, meta)
logger.info('[custom-loader] done.')
return
}
module.exports = loader
复制代码
在上面的例子中,我们打印了输入的内容,同时又修改了输入,增加了 title
的样式,运行 yarn dev
看一下效果:
示例截图:
注意此时 title
的样式,在 App.vue
中,我们并没有设置 title
的样式,但在页面中它却有了自定义样式,这是因为我们在 custom-loader
中手动添加了。
就这样,一个自定义 loader
就完成了,是不是很容易?你也来动手试一试吧。
7. 再进一步,支持 options
一个 loader
,支持 options
是很常见的事情吧,就像这样:
// webpack.config.js
module.exports = {
// ...其他配置
module: {
rules: [
{
test: /\.(s[ac]|c)ss$/i,
use: [
"style-loader",
"css-loader",
"postcss-loader",
{
loader: "sass-loader",
options: {
sourceMap: true
}
}
]
}
]
}
}
复制代码
要实现这样的功能也很简单,我们可以在 loader
内通过 this.getOptions()
拿到用户传入的 options:
// custom-loader/index.js
function loader(content, map, meta) {
const logger = this.getLogger()
const options = this.getOptions()
logger.info('[custom-loader] running...')
// 拿到用户传入的 options
logger.info('options:', options)
logger.info('input content:', content)
content = `
.home {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
.title {
width: 360px;
padding: 5px 0;
text-align: center;
color: #ffffff;
background-color: #1e90ff;
}
.button {
width: 60px;
}
}
`
this.callback(null, content, map, meta)
logger.info('[custom-loader] done.')
return
}
module.exports = loader
复制代码
然后,在使用的时候自定义 options:
// webpack.config.js
module.exports = {
// ...其他配置
module: {
rules: [
{
test: /\.(s[ac]|c)ss$/i,
use: [
"style-loader",
"css-loader",
"postcss-loader",
"sass-loader",
"custom-loader",
{
loader: "custom-loader",
// 自定义 options
options: {
value: "str",
count: 2
}
}
]
}
]
}
}
复制代码
8. 如何校验 options?
看到这个问题,你可能会想这样来实现:
// custom-loader/index.js
function loader(content, map, meta) {
const options = this.getOptions()
const { value, count } = options
if (typeof value !== 'string') {
this.callback(new Error("`value` 必须为 string 类型"))
}
if (typeof count !== 'number') {
this.callback(new Error("`count` 必须为 number 类型"))
}
// ...其他代码
return
}
module.exports = loader
复制代码
在 options 少的时候,这样做是没问题的,但是如果有多个 option 的话,这样校验很麻烦,并且也不够灵活,每次修改 option 类型就需要修改代码。如果能够把校验 options 和 loader
的代码实现分开来就好了。
实际上,this.getOptions(schema)
支持传入 JSON Schema 来校验用户传入的 options,JSON Schema
可以对属性的类型、是否必须进行配置。
配置 JSON Schema
:
// custom-loader/options.json
{
"title": "Custom Loader options",
"type": "object",
"properties": {
"value": {
"description": "`value` 必须为 string 类型",
"type": "string"
},
"count": {
"description": "`count` 必须为 number 类型",
"type": "number"
}
},
// `value` 为必须
"required": [
"value"
]
}
复制代码
修改一下 custom-loader
:
// custom-loader/index.js
// 引入 JSON Schema
const schema = require("./options.json")
function loader(content, map, meta) {
const logger = this.getLogger()
// 在获取 options 的时候,传入 JSON Schema,Webpack 会自动帮我们校验
const options = this.getOptions(schema)
// ...其他代码
return
}
module.exports = loader
复制代码
使用的时候,不传 value
:
// webpack.config.js
module.exports = {
// ...其他配置
module: {
rules: [
{
test: /\.(s[ac]|c)ss$/i,
use: [
"style-loader",
"css-loader",
"postcss-loader",
"sass-loader",
"custom-loader",
{
loader: "custom-loader",
// 自定义 options
options: {
// value: "str",
count: 2
}
}
]
}
]
}
}
复制代码
再次运行 yarn dev
,这时候会报错,提示我们缺少 value
:
另外,你也可以使用 schema-utils 来校验,用法也差不多。
9. 总结
对于自定义 loader
的实现,总结起来就三点:
- 输入,上一个
loader
产生的结果(起始loader
的输入是资源文件的内容) - 输出,输出的结果应该是
String
或Buffer
类型 - 实现,将输入变成输出的过程
想进一步了解的可以看一下 sass-loader 的源码,代码量不多,也很容易看懂。
另外,loader
的职责应该是单一的,比如 sass-loader
就只负责将 Sass/SCSS
文件编译成 CSS
。至于编译成 CSS
后的事,是将其进行压缩,亦或是添加浏览器前缀,就不是 sass-loader
要做的了。
10. 写在最后
如果这篇文章能对你哪怕有一丁点帮助的话,我会感到非常开心。然而由于能力有限,如果文中有错误的话,还请多多指教,我在此先行谢过。
最后,希望看完这篇文章的大家都能在不远的未来变得更加牛逼!