webpack手写loader
手写loader
我们在在Webpack配置基础篇介绍过,loader是链式传递的,对文件资源从上一个loader传递到下一个,而loader的处理也遵循着从下到上的顺序,我们简单了解一下loader的开发原则:
- 单一原则: 每个Loader只做一件事,简单易用,便于维护;
- 链式调用: Webpack 会按顺序链式调用每个Loader;
- 统一原则: 遵循
Webpack
制定的设计规则和结构,输入与输出均为字符串,各个Loader
完全独立,即插即用; - 无状态原则:在转换不同模块时,不应该在loader中保留状态;
因此我们就来尝试写一个less-loader
和style-loader
,将less文件
处理后通过style标签的方式渲染到页面上去。
同步loader
loader默认导出一个函数,接受匹配到的文件资源字符串和SourceMap,我们可以修改文件内容字符串后再返回给下一个loader进行处理,因此最简单的一个loader如下:
module.exports = function(source, map){
return source
}
导出的
loader函数
不能使用箭头函数,很多loader内部的属性和方法都需要通过this
进行调用,比如this.cacheable()
来进行缓存、this.sourceMap
判断是否需要生成sourceMap等。
我们在项目中创建一个loader文件夹,用来存放我们自己写的loader,然后新建我们自己的style-loader:
//loader/style-loader.js
function loader(source, map) {
let style = `
let style = document.createElement('style');
style.innerHTML = ${JSON.stringify(source)};
document.head.appendChild(style)
`;
return style;
}
module.exports = loader;
这里的source
就可以看做是处理后的css文件字符串,我们把它通过style标签的形式插入到head中;同时我们也发现最后返回的是一个JS代码的字符串,webpack最后会将返回的字符串打包进模块中。
异步loader
上面的style-loader
都是同步操作,我们在处理source时,有时候会进行异步操作,一种方法是通过async/await,阻塞操作执行;另一种方法可以通过loader本身提供的回调函数callback
。
//loader/less-loader
const less = require("less");
function loader(source) {
const callback = this.async();
less.render(source, function (err, res) {
let { css } = res;
callback(null, css);
});
}
module.exports = loader;
callback的详细传参方法如下:
callback({
//当无法转换原内容时,给 Webpack 返回一个 Error
error: Error | Null,
//转换后的内容
content: String | Buffer,
//转换后的内容得出原内容的Source Map(可选)
sourceMap?: SourceMap,
//原内容生成 AST语法树(可选)
abstractSyntaxTree?: AST
})
有些时候,除了将原内容转换返回之外,还需要返回原内容对应的Source Map,比如我们转换less和scss代码,以及babel-loader转换ES6代码,为了方便调试,需要将Source Map也一起随着内容返回。
//loader/less-loader
const less = require("less");
function loader(source) {
const callback = this.async();
less.render(source,{sourceMap: {}}, function (err, res) {
let { css, map } = res;
callback(null, css, map);
});
}
module.exports = loader;
这样我们在下一个loader就能接收到less-loader返回的sourceMap了,但是需要注意的是:
Source Map生成很耗时,通常在开发环境下才会生成Source Map,其它环境下不用生成。Webpack为loader提供了
this.sourceMap
这个属性来告诉loader当前构建环境用户是否需要生成Source Map。
加载本地loader
loader文件准备好了之后,我们需要将它们加载到webpack配置中去;在基础篇中,我们加载第三方的loader只需要安装后在loader属性中写loader名称即可,现在加载本地loader需要把loader的路径配置上。
module.exports = {
module: {
rules: [{
test: /\.less/,
use: [
{
loader: './loader/style-loader.js',
},
{
loader: path.resolve(__dirname, "loader", "less-loader"),
},
],
}]
}
}
我们可以在loader中配置本地loader的相对路径或者绝对路径,但是这样写起来比较繁琐,我们可以利用webpack提供的resolveLoader
属性,来告诉webpack应该去哪里解析本地loader。
module.exports = {
module: {
rules: [{
test: /\.less/,
use: [
{
loader: 'style-loader',
},
{
loader: 'less-loader',
},
],
}]
},
resolveLoader:{
modules: [path.resolve(__dirname, 'loader'), 'node_modules']
}
}
这样webpack会先去loader文件夹下找loader,没有找到才去node_modules;因此我们写的loader尽量不要和第三方loader重名,否则会导致第三方loader被覆盖加载。
处理参数
我们在配置loader时,经常会给loader传递参数进行配置,一般是通过options属性来传递的,也有像url-loader
通过字符串来传参:
{
test: /\.(jpg|png|gif|bmp|jpeg)$/,
use: 'url-loader?limt=1024&name=[hash:8].[ext]'
}
webpack也提供了query属性
来获取传参;但是query属性
很不稳定,如果像上面的通过字符串来传参,query就返回字符串格式,通过options方式就会返回对象格式,这样不利于我们处理。因此我们借助一个官方的包loader-utils
帮助处理,它还提供了很多有用的工具。
const {
getOptions,
parseQuery,
stringifyRequest,
} = require("loader-utils");
module.exports = function (source, map) {
//获取options参数
const options = getOptions(this);
//解析字符串为对象
parseQuery("?param1=foo")
//将绝对路由转换成相对路径
//以便能在require或者import中使用以避免绝对路径
stringifyRequest(this, "test/lib/index.js")
}
常用的就是getOptions
将处理后的参数返回出来,它内部的实现逻辑也非常的简单,也是根据query属性
进行处理,如果是字符串的话调用parseQuery
方法进行解析,源码如下:
//loader-utils/lib/getOptions.js
'use strict';
const parseQuery = require('./parseQuery');
function getOptions(loaderContext) {
const query = loaderContext.query;
if (typeof query === 'string' && query !== '') {
return parseQuery(loaderContext.query);
}
if (!query || typeof query !== 'object') {
return {};
}
return query;
}
module.exports = getOptions;
获取到参数后,我们还需要对获取到的options
参数进行完整性校验,避免有些参数漏传,如果一个个判断校验比较繁琐,这就用到另一个官方包schema-utils
:
const { getOptions } = require("loader-utils");
const { validate } = require("schema-utils");
const schema = require("./schema.json");
module.exports = function (source, map) {
const options = getOptions(this);
const configuration = { name: "Loader Name"};
validate(schema, options, configuration);
//省略其他代码
}
validate
函数并没有返回值,打印返回值发现是`undefined,因为如果参数不通过的话直接会抛出
ValidationError异常,直接进程中断;这里引入了一个
schema.json,就是我们对
options``中参数进行校验的一个json格式的对应表:
{
"type": "object",
"properties": {
"source": {
"type": "boolean"
},
"name": {
"type": "string"
},
},
"additionalProperties": false
}
properties
中的健名就是我们需要检验的options
中的字段名称,additionalProperties
代表了是否允许options
中还有其他额外的属性。
less-loader源码分析
写完我们自己简单的less-loader
,让我们来看一下官方的less-loader
源码到底是怎么样的,这里贴上部分源码:
import less from 'less';
import { getOptions } from 'loader-utils';
import { validate } from 'schema-utils';
import schema from './options.json';
async function lessLoader(source) {
const options = getOptions(this);
//校验参数
validate(schema, options, {
name: 'Less Loader',
baseDataPath: 'options',
});
const callback = this.async();
//对options进一步处理,生成less渲染的参数
const lessOptions = getLessOptions(this, options);
//是否使用sourceMap,默认取options中的参数
const useSourceMap =
typeof options.sourceMap === 'boolean'
? options.sourceMap : this.sourceMap;
//如果使用sourceMap,就在渲染参数加入
if (useSourceMap) {
lessOptions.sourceMap = {
outputSourceFiles: true,
};
}
let data = source;
let result;
try {
result = await less.render(data, lessOptions);
} catch (error) {
}
const { css, imports } = result;
//有sourceMap就进行处理
let map =
typeof result.map === 'string'
? JSON.parse(result.map) : result.map;
callback(null, css, map);
}
export default lessLoader;
可以看到官方的less-loader和我们写的简单的loader本质上都是调用less.render
函数,对文件资源字符串进行处理,然后将处理好后的字符串和sourceMap通过callback返回。
loader依赖
在loader中,我们有时候也会使用到外部的资源文件,我们需要在loader对这些资源文件进行声明;这些声明信息主要用于使得缓存loader失效,以及在观察模式(watch mode)下重新编译。
我们尝试写一个banner-loader
,在每个js文件资源后面加上我们自定义的注释内容;如果传了filename
,就从文件中获取预设好的banner内容,首先我们预设两个banner的txt:
//loader/banner1.txt
/* build from banner1 */
//loader/banner2.txt
/* build from banner2 */
然后在我们的banner-loader中根据参数来进行判断:
//loader/banner-loader
const fs = require("fs");
const path = require("path");
const { getOptions } = require("loader-utils");
module.exports = function (source) {
const options = getOptions(this);
if (options.filename) {
let txt = "";
if (options.filename == "banner1") {
this.addDependency(path.resolve(__dirname, "./banner1.txt"));
txt = fs.readFileSync(path.resolve(__dirname, "./banner1.txt"));
} else if (options.filename == "banner2") {
this.addDependency(path.resolve(__dirname, "./banner1.txt"));
txt = fs.readFileSync(path.resolve(__dirname, "./banner1.txt"));
}
return source + txt;
} else if (options.text) {
return source + `/* ${options.text} */`;
} else {
return source;
}
};
这里使用了this.addDependency
的API将当前处理的文件添加到文件依赖中(并不是项目的package.json)。如果在观察模式下,依赖的text文件发生了变化,那么打包生成的文件内容也随之变化。
如果不添加
this.addDependency
的话项目并不会报错,只是在观察模式下,如果依赖的文件发生了变化生成的bundle文件并不能及时更新。
缓存加速
在有些情况下,loader处理需要大量的计算非常耗性能(比如babel-loader),如果每次构建都重新执行相同的转换操作每次构建都会非常慢。
因此webpack默认会将loader的处理结果标记为可缓存,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时,它的输出结果必然是相同的;如果不想让webpack缓存该loader,可以禁用缓存:
module.exports = function(source) {
// 强制不缓存
this.cacheable(false);
return source;
};
手写loader所有代码均在webpackdemo19