对于webpack,一切皆模块。因此,无论什么文件,都需要转换成js可识别模块。你可以理解为,无论什么后缀的文件,都当作js来使用(即使是img、ppt、txt文件等等)。但是直接当作js使用肯定是不行的,需转换为一种能被js理解的方式才能当作js模块来使用——这个转换的过程由webpack的loader来处理。一个webpack loader 是一个导出为函数的 js 模块。webpack内部的
loader runner
会调用这个函数,然后把上一个 loader 产生的结果或者资源文件传入进去,然后返回处理后的结果
下面会从基本使用开始出发,探究一个loader怎么写,并实现raw-loader
、json-loader
、url-loader
、bundle-loader
准备工作: 先安装webpack
、webpack-cli
、webpack-dev-server
,后面的实践用到什么再装什么
loader使用
- 常规方法:webpack.config里面配置rules
module.exports = {
module: {
rules: [
{
test: /\.js$/, // 匹配规则
use: ['babel-loader'] // require的loader路径数组
}
]
}
}
复制代码
写了这个规则,只要匹配的文件名以.js
为结尾的,那就会经过use里面所有的loader处理
- loadername! 前缀方式 比如有一个txt文件,我们想通过
raw-loader
来获取整个txt文件里面的字符串内容。除了使用统一webpack config配置的方式之外,我们还可以在引入的时候,用这样的语法来引入:
import txt from "raw-loader!./1.txt";
// txt就是这个文件里面所有的内容
复制代码
其实使用webpack.config文件统一配置loader后,最终也是会转成这种方式使用loader再引入的。支持多个loader,语法: loader1!loader2!yourfilename
query替代options
使用loadername! 前缀语法:raw-loader?a=1&b=2!./1.txt
,等价于webpack配置:
{
test: /^1\.txt$/,
exclude: /node_modules/,
use: [
{ loader: "raw-loader", options: { a: '1', b: '2' } },
]
},
复制代码
在写自己的loader的时候,经常会使用loader-utils
(不需要特地安装,装了webpack一套就自带)来获取传入参数
const { getOptions } = require("loader-utils");
module.exports = function(content) {
const options = getOptions(this) || {};
// 如果是配置,返回的是options;如果是loadername!语法,返回根据query字符串生成的对象
// ...
};
复制代码
下文为了方便演示,会多次使用此方法配置loader。如果没用过这种方法的,就当作入门学习吧😊。搞起~
一个loader一般是怎样的
一个loader是一个导出为函数的 js 模块,这个函数有三个参数:content, map, meta
- content: 表示源文件字符串或者buffer
- map: 表示sourcemap对象
- meta: 表示元数据,辅助对象
我们实现一个最最最简单的,给代码加上一句console的loader:
// console.js
module.exports = function(content, map, meta) {
return `${content}; console.log('loader exec')`;
};
复制代码
```javascript
webpack配置
module: {
rules: [
{
test: /.js$/,
exclude: /node_modules/,
use: [
{ loader: “./loaders/console” }, // 加上自己写的loader
]
}
]
},
复制代码
![](https://static.javascriptc.com/frontend/webpack/20200408/webpack-188189ff0888189ff0.webp)
我们发现,重新跑构建后,每一个js都打印一下`'loader exec'`
# 最简单的loader——raw-loader和json-loader
这两个loader就是读取文件内容,然后可以使用import或者require导入原始文件所有的内容。很明显,原文件被当作js使用的时候,缺少了一个导出语句,loader做的事情就是加上导出语句。
比如有一个这样的txt
```javascript
this is a txt file
复制代码
假如你把它当作js来用,import或者require进来的时候,执行this is a txt file
这句js,肯定会报错。如果想正常使用,那么这个txt文件需要改成:
export default 'this is a txt file'
复制代码
最终的效果就是,无论是什么文件,txt、md、json等等,都当作一个js文件来用,原文件内容相当于一个字符串,被导出了:
// 自己写的raw-loader
const { getOptions } = require("loader-utils");
// 获取webpack配置的options,写loader的固定套路第一步
module.exports = function(content, map, meta) {
const opts = getOptions(this) || {};
const code = JSON.stringify(content);
const isESM = typeof opts.esModule !== "undefined" ? options.esModule : true;
// 直接返回原文件内容
return `${isESM ? "export default" : "module.exports ="} ${code}`;
};
复制代码
raw-loader
和json-loader
几乎都是一样的,他们的目的就是把原文件所有的内容作为一个字符串导出,而json-loader多了一个json.parse的过程
注意:看了一下官方的loader源码,发现它们还会多一个步骤
JSON.stringify(content)
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
复制代码
\u2028
和\u2029
是特殊字符,和\n
、\b
之类的类似,但它们特殊之处在于——转义后直观上看还是一个空字符串。可以看见它特殊之处:
即使你看得见中间有一个奇怪的字符,但是你再按下enter,还是'ab'
,\u2028
字符串在直观上来看相当于空字符串(实际上字符是存在的,却没有它的带来的效果)。而对于除了2028和2029,比如\u000A
的\n
,是有换行的效果的(字符存在,也有它带来的效果)。因此,对于低概率出现的字符值为2028和2029的转义是有必要的
Unicode 字符值
转义序列
含义
类别
\u0008
\b
Backspace
\u0009
\t
Tab
空白
\u000A
\n
换行符(换行)
行结束符
\u000B
\v
垂直制表符
空白
\u000C
\f
换页
空白
\u000D
\r
回车
行结束符
\u0022
”
双引号 (“)
\u0027
\‘
单引号 (‘)
\u005C
\
反斜杠 ()
\u00A0
不间断空格
空白
\u2028
行分隔符
行结束符
\u2029
段落分隔符
行结束符
\uFEFF
字节顺序标记
空白
raw模式与url-loader
我们前面已经实现了raw-loader
,这个loader是把原文件里面的内容以字符串形式返回。但是问题来了,有的文件并不是一个字符串就可以解决的了的,比如图片、视频、音频。此时,我们需要直接利用原文件的buffer
。恰好,loader函数的第一个参数content,支持string/buffer
如何开启buffer类型的content?
// 只需要导出raw为true
module.exports.raw = true
复制代码
url-loader
的流程就是,读取配置,是否可以转、怎么转=>读取原文件buffer=>buffer转base64输出 => 无法转换的走fallback流程。我们下面实现一个简易版本的url-loader
,仅仅实现核心功能
const { getOptions } = require("loader-utils");
module.exports = function(content) {
const options = getOptions(this) || {};
const mimetype = options.mimetype;
const esModule =
typeof options.esModule !== "undefined" ? options.esModule : true;
// base编码组成:data:[mime类型];base64,[文件编码后内容]
return `${esModule ? "export default" : "module.exports ="} ${JSON.stringify(
`data:${mimetype || ""};base64,${content.toString("base64")}`
)}`;
};
module.exports.raw = true;
复制代码
```javascript
然后,我们随便弄一张图片,import进来试一下:
// loader路径自行修改
// img就是一个base64的图片路径,可以直接放img标签使用
import img from “../../loaders/my-url-loader?mimetype=image!./1.png”;
复制代码
至于`file-loader`,相信大家也有思路了吧,流程就是:读取配置里面的publicpath=>确定最终输出路径=>文件名称加上MD5 哈希值=>搬运一份文件,文件名改新的名=>新文件名拼接前面的path=>输出最终文件路径
# pitch与bundle-loader
[官网对pitching loader介绍][1]是: loader 总是从右到左地被调用。有些情况下,loader 只关心 request 后面的元数据(metadata),并且忽略前一个 loader 的结果。在实际(从右到左)执行 loader 之前,会先从左到右调用 loader 上的 pitch 方法。**其次,如果某个 loader 在 pitch 方法中返回一个结果,那么这个过程会跳过剩下的 loader**
pitch方法的三个参数:
+ remainingRequest: 后面的loader+资源路径,loadername!的语法
+ precedingRequest: 资源路径
+ metadata: 和普通的loader函数的第三个参数一样,辅助对象,而且loader执行的全程用的是同一个对象哦
loader从后往前执行这个过程,你可以视为顺序入栈倒序出栈。比如命中某种规则A的文件,会经历3个loader: `['a-loader', 'b-loader', 'c-loader']`
会经历这样的过程:
+ 执行a-loader的`pitch`方法
+ 执行b-loader `pitch`方法
+ 执行c-loader `pitch`方法
+ 根据import/require路径获取资源内容
+ c-loader 执行
+ b-loader 执行
+ a-loader 执行
如果`b-loader`里面有一个pitch方法,而且这个pitch方法有返回结果,那么上面这个过程自从经过了`b-loader`后,就不会再将`c-loader`入栈
// b-loader
module.exports = function(content) {
return content;
};
// 没做什么,就透传import进来再export出去
module.exports.pitch = function(remainingRequest) {
// remainingRequest路径要加-! 前缀
return import s from ${JSON.stringify(
-!${remainingRequest})}; export default s
;
};
复制代码
b-loader的pitch方法有返回结果,会经历这样的过程:
+ 执行a-loader的`pitch`方法
+ 执行b-loader `pitch`方法(有返回结果,跳过c-loader)
+ 根据import/require路径获取资源内容
+ b-loader 执行
+ a-loader 执行
> 什么情况下需要跳过剩下的loader呢?最常见的,就是动态加载和缓存读取了,要跳过后面loader的计算。`bundle-loader`是一个典型的例子
`bundle-loader`实现的是动态按需加载,怎么使用呢?我们可以对react最终ReactDom.render那一步改造一下,换成动态加载`react-dom`,再体会一下区别
```javascript
- import ReactDom from "react-dom";
+ import LazyReactDom from "bundle-loader?lazy&name=reactDom!react-dom";
+ LazyReactDom(ReactDom => {
+ console.log(ReactDom, "ReactDom");
ReactDom.render(<S />, document.getElementById("root"));
+});
复制代码
可以看见reactdom被隔离开来,动态引入
点开bundle-loader
源码,发现它利用的是require.ensure
来动态引入,具体的实现也很简单,具体看bundle-loader源码。时代在变化,新时代的动态引入应该是动态import
,下面我们自己基于动态import来实现一个新的bundle-loader
。(仅实现lazy引入的核心功能)
// 获取ChunkName
function getChunkNameFromRemainingRequest(r) {
const paths = r.split("/");
let cursor = paths.length - 1;
if (/^index\./.test(paths[cursor])) {
cursor--;
}
return paths[cursor];
}
// 原loader不需要做什么了
module.exports = function() {};
module.exports.pitch = function(remainingRequest, r) {
// 带loadername!前缀的依赖路径
const s = JSON.stringify(`-!${remainingRequest}`);
// 使用注释webpackChunkName来定义chunkname的语法
return `export default function(cb) {
return cb(import(/* webpackChunkName: "my-lazy-${getChunkNameFromRemainingRequest(
this.resource
)}" */${s}));
}`;
};
复制代码
用法和官方的bundle-loader
基本差不多,只是动态import返回一个promise,需要改一下使用方法:
import LazyReactDom from "../loaders/my-bundle!react-dom";
setTimeout(() => {
LazyReactDom(r => {
r.then(({ default: ReactDom }) => {
ReactDom.render(<S />, document.getElementById("root"));
});
});
}, 1000);
复制代码
loader上下文
上文我们看见有在写loader的时候使用this,这个this就是loader的上下文。具体可见官网
一堆上下文的属性中,我们拿其中一个来实践一下: this.loadModule
loadModule(request: string, callback: function(err, source, sourceMap, module))
loadModule
方法作用是,解析给定的 request 到一个模块,应用所有配置的 loader ,并且在回调函数中传入生成的 source 、sourceMap和webpack内部的NormalModule
实例。如果你需要获取其他模块的源代码来生成结果的话,你可以使用这个函数。
很明显,这个方法其中一个应用场景就是,在已有代码上注入其他依赖
let’s coding
背景:已有一个api文件api.js
const api0 = {
log(...args) {
console.log("api log>>>", ...args);
}
};
module.exports = api0;
复制代码
希望效果:我们使用下面这个a.js
js文件的时候,可以直接使用api,且不报错
// a.js
export default function a() {
return 1;
}
// 其他代码
// ...
api.log("a", "b");
复制代码
因此,我们需要构建的时候loader把api打进去我们的代码里面:
// addapi的loader
module.exports = function(content, map, meta) {
// 涉及到加载模块,异步loader
const callback = this.async();
this.loadModule("../src/api.js", (err, source, sourceMap, module) => {
// source是一个module.exports = require(xxx)的字符串,我们需要require那部分
callback(
null,
`const api = ${source.split("=")[1]};
${content};`,
sourceMap,
meta
);
});
return;
};
复制代码
loader写好了,记得去webpack配置里面加上,或者使用loadername!的语法引入a.js(./loaders/addapi!./a.js
)
最后我们可以看见成功运行了api.js的log
平时也有一些熟悉的场景,某某某api、某某某sdk、公共utils方法、每一个index页面的pvuv上报等等,需要先把这些js加载执行完或者导入。如果我们懒得一个个文件加import/require
语句,就可以用这种方式瞬间完成。这种骚操作的前提是,保证后续同事接手项目难度低、代码无坑。注释、文档、优雅命名都搞起来
最后
loader的作用就是,让一切文件,转化为自己所需要、能使用的js模块运行起来。babel和loader双剑合璧更加强大,可以为所欲为的修改代码、偷懒等等。后续还会出webpack插件、babel相关的文章,大家一起来学习交流~
关注公众号《不一样的前端》,以不一样的视角学习前端,快速成长,一起把玩最新的技术、探索各种黑科技
作者:lhyt
链接:https://juejin.im/post/5e3389436fb9a02fef3a707a
看完两件小事
如果你觉得这篇文章对你挺有启发,我想请你帮我两个小忙:
- 把这篇文章分享给你的朋友 / 交流群,让更多的人看到,一起进步,一起成长!
- 关注公众号 「画漫画的程序员」,公众号后台回复「资源」 免费领取我精心整理的前端进阶资源教程
本文著作权归作者所有,如若转载,请注明出处
转载请注明:文章转载自「 Js中文网 · 前端进阶资源教程 」https://www.javascriptc.com