一、loader 在webpack 中的作用
loader 用于对模块的源代码进行转换。
loader 可以使你在 import
或 "load(加载)" 模块时预处理文件。
因此,loader 类似于其他构建工具中“任务(task)”,并提供了处理前端构建步骤的得力方式。
loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript 或将内联图像转换为 data URL。
loader 甚至允许你直接在 JavaScript 模块中 import
CSS文件!
二、loader 运行的总体流程
1、默认配置
webpack 入口文件 webpack.js ,根据配置文件 设置配置的options
options = new WebpackOptionsDefaulter().process(options);
compiler = new Compiler(options.context);
compiler.options = options;
WebpackOptionsDefaulter 加载 默认配置
// WebpackOptionsDefaulter.js
this.set("module.defaultRules", "make", options => [
{
type: "javascript/auto",
resolve: {}
},
{
test: /\.mjs$/i,
type: "javascript/esm",
resolve: {
mainFields:
options.target === "web" ||
options.target === "webworker" ||
options.target === "electron-renderer"
? ["browser", "main"]
: ["main"]
}
},
{
test: /\.json$/i,
type: "json"
},
{
test: /\.wasm$/i,
type: "webassembly/experimental"
}
]);
//...
this.set("optimization.splitChunks.cacheGroups.default", {
automaticNamePrefix: "",
reuseExistingChunk: true,
minChunks: 2,
priority: -20
});
this.set("optimization.splitChunks.cacheGroups.vendors", {
automaticNamePrefix: "vendors",
test: /[\\/]node_modules[\\/]/,
priority: -10
});
// ...
上图可以看出一些默认配置已经加载,WebpackOptionsApply 模块主要是根据options选项的配置,设置compile的相应的插件,属性,里面写了大量的 apply(compiler);
使得模块的this指向compiler
2、创建NormalModuleFactory
处理完一些webpac.config 和一些内部配置,在一个module 构建的过程中,首先根据module 的依赖类型,调用对应的构造函数来创建对应的模块
// Compiler.js
createNormalModuleFactory() {
const normalModuleFactory = new NormalModuleFactory(
this.options.context,
this.resolverFactory,
this.options.module || {}
);
this.hooks.normalModuleFactory.call(normalModuleFactory);
return normalModuleFactory;
}
newCompilationParams() {
const params = {
normalModuleFactory: this.createNormalModuleFactory(),
contextModuleFactory: this.createContextModuleFactory(),
compilationDependencies: new Set()
};
return params;
}
当NormalModuleFactory 实例化完成之后,在compilation 内部调用 这个实例create 创建normalModule
3、解析loader 路径
这里需要用到上一个阶段讲到的 NormalModuleFactory 实例, NormalModuleFactory 的 create 方法是创建 NormalModule 实例的入口, 内部需要解析一写module ,其中就包含 loaders ,资源路径 resource 等等,最终将解析完毕的参数传给 NormalModule 构建函数直接实例化
在NormalModuleFactory中,创建出NormalModule实例之前会涉及到四个钩子:
- beforeResolve
- factory: 负责来基于resolve钩子返回值来创建NormalModule
- resolver : 负责解析loader 模块路径 (例如css-loader这个loader的模块路径是什么)
- afterResolve
resolve钩子上注册的方法较长,其中还包括了模块资源本身的路径解析。resolver有两种,分别是loaderResolver和normalResolver
// NormalModuleFactory.js
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
const contextInfo = data.contextInfo;
const context = data.context;
const request = data.request;
const loaderResolver = this.getResolver("loader");
const normalResolver = this.getResolver("normal", data.resolveOptions);
....
}
loader 路径解析也分为两种:inline loader和config文件中的loader。resolver钩子中会先处理inline loader
resolver钩子中会先处理inline loader
比如: import Styles from 'style-loader!css-loader?modules!./styles.css';
通过 上面的 request 解析出来 所需要的loader路径
// NormalModuleFactory.js
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
const contextInfo = data.contextInfo;
const context = data.context;
const request = data.request;
// 假如 import Styles from 'style-loader!css-loader?modules!./styles.css'
// 这时候 request 就是
const loaderResolver = this.getResolver("loader");
const normalResolver = this.getResolver("normal", data.resolveOptions);
let matchResource = undefined;
let requestWithoutMatchResource = request;
...
// 是否忽略perLoader
const noPreAutoLoaders = requestWithoutMatchResource.startsWith("-!");
// 是否忽略normalLoader
const noAutoLoaders =
noPreAutoLoaders || requestWithoutMatchResource.startsWith("!");
// 忽略所有的 perLoader、normalLoader、postLoader
const noPrePostAutoLoaders = requestWithoutMatchResource.startsWith("!!");
// 首先解析出所需要的 loader,这种loader 为内联loader
let elements = requestWithoutMatchResource
.replace(/^-?!+/, "")
.replace(/!!+/g, "!")
.split("!");
let resource = elements.pop(); // 获取资源路径
// 获取每个loader对应的options 配置
elements = elements.map(identToLoaderRequest);
....
}
从上面可知 request :style-loader!css-loader?modules!./styles.css
let elements = requestWithoutMatchResource
.replace(/^-?!+/, "")
.replace(/!!+/g, "!")
.split("!");
从style-loader!css-loader?modules!./styles.css中可以取出
两个loader:style-loader和css-loader
webpack首先解析了inline loader的绝对路径与配置。接下来则是解析config文件中的loader (source code),
// NormalModuleFactory.js
const result = this.ruleSet.exec({
resource: resourcePath,
realResource:
matchResource !== undefined
? resource.replace(/\?.*/, "")
: resourcePath,
resourceQuery,
issuer: contextInfo.issuer,
compiler: contextInfo.compiler
});
ruleSet: 它可以根据模块路径名,匹配出模块所需的loader
返回的result就是当前模块匹配出的config中的loader。
const settings = {};
const useLoadersPost = []; // post loader
const useLoaders = []; // normal loader
const useLoadersPre = []; // pre loader
for (const r of result) {
if (r.type === "use") {
if (r.enforce === "post" && !noPrePostAutoLoaders) {
useLoadersPost.push(r.value);
} else if (
r.enforce === "pre"
&&!noPreAutoLoaders
&&!noPrePostAutoLoaders) {
useLoadersPre.push(r.value); // perLoader
} else if (!r.enforce &&!noAutoLoaders &&!noPrePostAutoLoaders) {
useLoaders.push(r.value); // normal loader
}
} else if (
typeof r.value === "object" &&r.value !== null &&settings[r.type] !== null
) {
settings[r.type] = cachedCleverMerge(settings[r.type], r.value);
} else {
settings[r.type] = r.value;
}
}
最后,使用neo-aysnc来并行解析三类loader数组
asyncLib.parallel(
[
this.resolveRequestArray.bind(
this,
contextInfo,
this.context,
useLoadersPost,
loaderResolver,
),
this.resolveRequestArray.bind(
this,
contextInfo,
this.context,
useLoaders,
loaderResolver,
),
this.resolveRequestArray.bind(
this,
contextInfo,
this.context,
useLoadersPre,
loaderResolver,
),
]
不同类型 loader 上的 pitch 方法执行的顺序为:
postLoader.pitch -> inlineLoader.pitch -> normalLoader.pitch -> preLoader.pitch
最终 loader 所执行的顺序对应为:
preLoader -> normalLoader -> inlineLoader -> postLoader
loader是从右至左执行的,真实的loader执行顺序是倒过来的,因此inlineLoader是整体后于config中normal loader执行的
经过上面一下步骤,loader 解析工作基本完成。
补充: 前面提到 RuleSet - 会将resourcePath应用于所有的module.rules规则,从而筛选出所需的loader
- RuleSet - 含有类静态方法.normalizeRule() 和实例方法.exec()
- 通过其上的静态方法.normalizeRule()将配置值转换为标准化的test对象,会存储一个this.references属性是一个map类型的存储,key是loader在配置中的类型和位置。例如,ref-2表示loader配置数组中的第三个。
- this.ruleSet.exec()中传入源码模块路径,返回的result就是当前模块匹配出的config中的loader
4、loader的运行
loader的绝对路径解析完毕后,在NormalModuleFactory的factory钩子中会创建当前模块的NormalModule对象
在创建完 NormalModule 实例之后会调用 build 方法继续进行内部的构建。我们熟悉的 loaders 将会在这里开始应用
4.1、 loader-runner - loader的执行库
loader-runner分为了两个部分:loadLoader.js与LoaderRunner.js。
- module 开始构建的过程中,会先创建一个 loaderContext 对象。
所有的 loader 会共享这个 loaderContext 对象,每个loader 执行的时候,上下文 是这个对象
doBuild(options, compilation, resolver, fs, callback) {
const loaderContext = this.createLoaderContext(
resolver,
options,
compilation,
fs
);
runLoaders(
{
resource: this.resource,
loaders: this.loaders,
context: loaderContext, // loaderContext 上下文
readResource: fs.readFile.bind(fs) // 读取文件的
},
....
)
}
- 初始化 loaderContext ,完成之后,开始调用runLoader 方法,到了loaders 执行。
//
exports.runLoaders = function runLoaders(options, callback) {
// read options
var resource = options.resource || ""; // 模块路径
var loaders = options.loaders || []; // 所需要的loader
var loaderContext = options.context || {}; // 在 normalModule 里创建的loaderContext
var readResource = options.readResource || readFile;
//
var splittedResource = resource && splitQuery(resource);
var resourcePath = splittedResource ? splittedResource[0] : undefined; // 模块实际路径
var resourceQuery = splittedResource ? splittedResource[1] : undefined; // 模块路径 query 参数
var contextDirectory = resourcePath ? dirname(resourcePath) : null; //
// execution state
var requestCacheable = true;
var fileDependencies = [];
var contextDependencies = [];
// prepare loader objects
loaders = loaders.map(createLoaderObject); // 处理loader
loaderContext.context = contextDirectory;
loaderContext.loaderIndex = 0;
loaderContext.loaders = loaders;
loaderContext.resourcePath = resourcePath;
loaderContext.resourceQuery = resourceQuery;
loaderContext.async = null;
loaderContext.callback = null;
loaderContext.cacheable = function cacheable(flag) {
if(flag === false) {
requestCacheable = false;
}
};
loaderContext.dependency = loaderContext.addDependency = function addDependency(file) {
fileDependencies.push(file);
};
loaderContext.addContextDependency = function addContextDependency(context) {
contextDependencies.push(context);
};
loaderContext.getDependencies = function getDependencies() {
return fileDependencies.slice();
};
loaderContext.getContextDependencies = function getContextDependencies() {
return contextDependencies.slice();
};
loaderContext.clearDependencies = function clearDependencies() {
fileDependencies.length = 0;
contextDependencies.length = 0;
requestCacheable = true;
};
// 被构建的模块 路径, loaderContext.resource -> getter/setter
Object.defineProperty(loaderContext, "resource", {
enumerable: true,
get: function() {
if(loaderContext.resourcePath === undefined)
return undefined;
return loaderContext.resourcePath + loaderContext.resourceQuery;
},
set: function(value) {
var splittedResource = value && splitQuery(value);
loaderContext.resourcePath = splittedResource ? splittedResource[0] : undefined;
loaderContext.resourceQuery = splittedResource ? splittedResource[1] : undefined;
}
});
// 所有的loader以及 这个模块的resource 所组成request 字符串
Object.defineProperty(loaderContext, "request", {
enumerable: true,
get: function() {
return loaderContext.loaders.map(function(o) {
return o.request;
}).concat(loaderContext.resource || "").join("!");
}
});
// 执行loader 提供的 pitch 函数阶段传入函数,未被调用的 loader.pitch 组成的request 字符串
Object.defineProperty(loaderContext, "remainingRequest", {
enumerable: true,
get: function() {
if(loaderContext.loaderIndex >= loaderContext.loaders.length - 1 && !loaderContext.resource)
return "";
return loaderContext.loaders.slice(loaderContext.loaderIndex + 1).map(function(o) {
return o.request;
}).concat(loaderContext.resource || "").join("!");
}
});
Object.defineProperty(loaderContext, "currentRequest", {
enumerable: true,
get: function() {
return loaderContext.loaders.slice(loaderContext.loaderIndex).map(function(o) {
return o.request;
}).concat(loaderContext.resource || "").join("!");
}
});
// 包含已经执行的loader.pitch 所组成的request 字符串
Object.defineProperty(loaderContext, "previousRequest", {
enumerable: true,
get: function() {
return loaderContext.loaders.slice(0, loaderContext.loaderIndex).map(function(o) {
return o.request;
}).join("!");
}
});
// 获取当前正在执行loader 的query参数
//
Object.defineProperty(loaderContext, "query", {
enumerable: true,
get: function() {
var entry = loaderContext.loaders[loaderContext.loaderIndex];
return entry.options && typeof entry.options === "object" ? entry.options : entry.query;
}
});
// 每个loader 在pitch 阶段和正常执行都可以共享的 data 数据
Object.defineProperty(loaderContext, "data", {
enumerable: true,
get: function() {
return loaderContext.loaders[loaderContext.loaderIndex].data;
}
});
// finish loader context
if(Object.preventExtensions) {
Object.preventExtensions(loaderContext);
}
var processOptions = {
resourceBuffer: null,
readResource: readResource
};
// 开始执行每个loader 上的pitch 函数
iteratePitchingLoaders(processOptions, loaderContext, function(err, result) {
if(err) {
return callback(err, {
cacheable: requestCacheable,
fileDependencies: fileDependencies,
contextDependencies: contextDependencies
});
}
callback(null, {
result: result,
resourceBuffer: processOptions.resourceBuffer,
cacheable: requestCacheable,
fileDependencies: fileDependencies,
contextDependencies: contextDependencies
});
});
};
- 从上面 runLoader 方法来看,loader-runner中对应的
iteratePitchingLoaders()和iterateNormalLoaders()两个方法 - iteratePitchingLoaders()会递归执行,并记录loader的pitch状态与当前执行到的loaderIndex(loaderIndex++)
从下图可以知道 iteratePitchingLoaders 中 通过pitchExecuted 属性来判断 是否执行过pitch
- 当loaderContext.loaderIndex值达到整体loader数组长度时,表明所有pitch都被执行完毕。所有pitch都被执行完毕(执行到了最后的loader),这时会调用processResource()来处理模块资源
if(loaderContext.loaderIndex >= loaderContext.loaders.length)
return processResource(options, loaderContext, callback);
- processResource 会递归执行iterateNormalLoaders()并进行loaderIndex--操作,因此loader会“反向”执行。
function processResource(options, loaderContext, callback) {
// set loader index to last loader
loaderContext.loaderIndex = loaderContext.loaders.length - 1;
var resourcePath = loaderContext.resourcePath;
if(resourcePath) {
loaderContext.addDependency(resourcePath);
options.readResource(resourcePath, function(err, buffer) {
if(err) return callback(err);
options.resourceBuffer = buffer;
iterateNormalLoaders(options, loaderContext, [buffer], callback);
});
} else {
iterateNormalLoaders(options, loaderContext, [null], callback);
}
}
function iterateNormalLoaders(options, loaderContext, args, callback) {
...
if(currentLoaderObject.normalExecuted) {
loaderContext.loaderIndex--;
return iterateNormalLoaders(options, loaderContext, args, callback);
}
...
}
- loader 中的this其实是一个叫loaderContext的对象,那么this.data的实现其实就是loaderContext.data
// 每个loader 在pitch 阶段和正常执行都可以共享的 data 数据
Object.defineProperty(loaderContext, "data", {
enumerable: true,
get: function() {
return loaderContext.loaders[loaderContext.loaderIndex].data;
}
});
调用this.data时,不同的normal loader由于loaderIndex不同,会得到不同的值;而pitch方法的形参data也是不同的loader下的data
4.2、 pitch 和 normal 执行 都是在 runSyncOrAsync
function runSyncOrAsync(fn, context, args, callback) {
var isSync = true; // 是否为同步
var isDone = false;
var isError = false; // internal error
var reportedError = false;
// 给loaderContext 上下文赋值 async 函数,用来将loader 异步并返回 异步回调
context.async = function async() {
if(isDone) {
if(reportedError) return; // ignore
throw new Error("async(): The callback was already called.");
}
isSync = false;
return innerCallback;
};
// callback 这种形式,可以向下一个loader 传递多个参数
var innerCallback = context.callback = function() {
if(isDone) {
if(reportedError) return; // ignore
throw new Error("callback(): The callback was already called.");
}
isDone = true;
isSync = false;
try {
callback.apply(null, arguments);
} catch(e) {
isError = true;
throw e;
}
};
try {
var result = (function LOADER_EXECUTION() { // 开始执行loader
return fn.apply(context, args);
}());
if(isSync) {
isDone = true;
if(result === undefined) // 如果没有返回值,执行callback 开始下一个loader 执行
return callback();
if(result && typeof result === "object" && typeof result.then === "function") {
return result.then(function(r) { // loader 返回一个promise 实例,待实例被resolve 或者reject后执行下一个loader
callback(null, r);
}, callback);
}
return callback(null, result);
}
} catch(e) {
if(isError) throw e;
if(isDone) {
// loader is already "done", so we cannot use the callback function
// for better debugging we print the error on the console
if(typeof e === "object" && e.stack) console.error(e.stack);
else console.error(e);
return;
}
isDone = true;
reportedError = true;
callback(e);
}
}
pitch:
// runLoader.js
// 开始执行 pitch 函数
runSyncOrAsync(
fn,
loaderContext, [loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}],
function(err) {
if(err) return callback(err);
var args = Array.prototype.slice.call(arguments, 1);
if(args.length > 0) {
loaderContext.loaderIndex--;
iterateNormalLoaders(options, loaderContext, args, callback);
} else {
iteratePitchingLoaders(options, loaderContext, callback);
}
}
);
下图显示 : loaderContext.remainingRequest 表示剩余 loader 拼接成字符 (未被调用的 loader.pitch 组成的request 字符串)
normal:
runSyncOrAsync(fn, loaderContext, args, function(err) {
if(err) return callback(err);
var args = Array.prototype.slice.call(arguments, 1);
iterateNormalLoaders(options, loaderContext, args, callback);
});
4.3、webpack 中loader 四种类型
- pre (前置)
{
test: /test\.js$/,
loader: 'loader1',
enforce: 'pre'
}
rules: [{
test: /\.js$/,
use: ['eslint-loader'],
enforce: 'pre',
exclude: /node_modules/,
}]
Pre表示这个loader在所有的loader之前执行。配置表示在所有处理js文件模块loader之前执行eslint-loader,这样我们可以在js代码未被处理的时候就进行eslint代码规范校验
- normal : 是普通loader ,没有配置就是普通的loader
- inline :
import 'loader1!loader2!./test.js';
- post:
{
test: /test\.js$/,
loader: 'loader5',
enforce: 'post'
},
调用顺序:
不同类型 loader 上的 pitch 方法执行的顺序为:
postLoader.pitch -> inlineLoader.pitch -> normalLoader.pitch -> preLoader.pitch
最终 loader 所执行的顺序对应为:
preLoader -> normalLoader -> inlineLoader -> postLoader
- 比如: a!b!c!module, 正常调用顺序应该是c、b、a,但是真正调用顺序是
a(pitch)、b(pitch)、c(pitch)、c、b、a,比如如果b返回了字符串"result b", 接下来只有a会被系统执行,且a的loader收到的参数是result b
三、 常用loader的执行机制分析
style-loader
style-loader 中pitch 中的作用:
我们知道css-loader最后会导出一段js字符串,里面可能包含需要动态执行的函数。按照正常的执行顺序,style-loader只能拿到这些字符串而并不能把他们转成真正的css代码
// style-loader .js
loaderApi.pitch = function loader(request) {
// // 获取webpack配置的options
const options = _loaderUtils.default.getOptions(this);
(0, _schemaUtils.validate)(_options.default, options, {
name: 'Style Loader',
baseDataPath: 'options'
});
// // 定义了两个变量,不难看出insert的默认值为head,injectType默认值为styleTag
const insert = typeof options.insert === 'undefined' ? '"head"' : typeof options.insert === 'string' ? JSON.stringify(options.insert) : options.insert.toString();
const injectType = options.injectType || 'styleTag';
const esModule = typeof options.esModule !== 'undefined' ? options.esModule : true;
const namedExport = esModule && options.modules && options.modules.namedExport;
const runtimeOptions = {
injectType: options.injectType,
attributes: options.attributes,
insert: options.insert,
base: options.base
};
switch(injectType){
case 'linkTag':
{
// ...
}
case 'lazyStyleTag':
case 'lazySingletonStyleTag':
{
// ...
}
case 'styleTag':
case 'singletonStyleTag':
default:
{
// ...
}
}
}
switch(){
....
case:...
..
default:
{
const isSingleton = injectType === 'singletonStyleTag';
const hmrCode = this.hot ? `
if (module.hot) {
if (!content.locals || module.hot.invalidate) {
var isEqualLocals = ${_isEqualLocals.default.toString()};
var oldLocals = ${namedExport ? 'locals' : 'content.locals'};
module.hot.accept(
${_loaderUtils.default.stringifyRequest(this, `!!${request}`)},
function () {
${esModule ? `if (!isEqualLocals(oldLocals, ${namedExport ? 'locals' : 'content.locals'}, ${namedExport})) {
module.hot.invalidate();
return;
}
oldLocals = ${namedExport ? 'locals' : 'content.locals'};
update(content);` : `content = require(${_loaderUtils.default.stringifyRequest(this, `!!${request}`)});
content = content.__esModule ? content.default : content;
if (typeof content === 'string') {
content = [[module.id, content, '']];
}
if (!isEqualLocals(oldLocals, content.locals)) {
module.hot.invalidate();
return;
}
oldLocals = content.locals;
update(content);`}
}
)
}
module.hot.dispose(function() {
update();
});
}` : '';
return `${esModule ? `import api from ${_loaderUtils.default.stringifyRequest(this, `!${_path.default.join(__dirname, 'runtime/injectStylesIntoStyleTag.js')}`)};
import content${namedExport ? ', * as locals' : ''} from ${_loaderUtils.default.stringifyRequest(this, `!!${request}`)};` : `var api = require(${_loaderUtils.default.stringifyRequest(this, `!${_path.default.join(__dirname, 'runtime/injectStylesIntoStyleTag.js')}`)});
var content = require(${_loaderUtils.default.stringifyRequest(this, `!!${request}`)});
content = content.__esModule ? content.default : content;`}
var options = ${JSON.stringify(runtimeOptions)};
options.insert = ${insert};
options.singleton = ${isSingleton};
var update = api(content, options);
${hmrCode}
${esModule ? namedExport ? `export * from ${_loaderUtils.default.stringifyRequest(this, `!!${request}`)};` : 'export default content.locals || {};' : 'module.exports = content.locals || {};'}`;
}
}
简化 大概是:pitch 方法 返回一个字符串
const isSingleton = injectType === 'singletonStyleTag';
const hmrCode = this.hot ? `
// ...
` : '';
return `
// _loaderUtils.default.stringifyRequest这里就不叙述了,主要作用是将绝对路径转换为相对路径
var content = require(${_loaderUtils.default.stringifyRequest(this, `!!${request}`)});
if (typeof content === 'string') {
content = [[module.id, content, '']];
}
var options = ${JSON.stringify(options)}
options.insert = ${insert};
options.singleton = ${isSingleton};
var update = require(${_loaderUtils.default.stringifyRequest(this, `!${_path.default.join(__dirname, 'runtime/injectStylesIntoStyleTag.js')}`)})(content, options);
if (content.locals) {
module.exports = content.locals;
}
${hmrCode}
`;`
- 首先调用require方法获取css文件的内容,将其赋值给content
- 如果content是字符串,则将content赋值为数组,即:[[module.id], content, ''],接着我们覆盖了options的insert、singleton属性
- 又使用require方法引用了runtime/injectStyleIntoStyleTag.js,它返回一个函数,我们将content和options传递给该函数,并立即执行它:
module.exports = function (list, options) {
options = options || {};
options.attributes = typeof options.attributes === 'object' ? options.attributes : {}; // Force single-tag solution on IE6-9, which has a hard limit on the # of