深入理解 Webpack5【一】:从加载至执行,彻底解读 Loader 的生命周期
今天我们要探讨一个在我们日常开发工作中重要且不可忽视的主题 --- Webpack 的加载器(loader)工作流程。Webpack loader 是一种非常特殊且强大的工具,它帮助我们将任何类型的文件转化为 Webpack 可以处理的有效模块 🚀。
在这篇文章中,我们会拆解 Webpack Loader 的工作流程,详细阐述其在软件打包工具 Webpack 中的关键作用。我们会从基本工作原理出发,通过实际的源码示例与分析,以下方便易懂的方式,深入理解 Webpack loader 是如何将各种类型的文件转化为 Webpack 能理解和处理的 JavaScript 模块。如果你是想要增强对 Webpack 深度理解的娴熟开发者,希望这篇文章能带给你全新的视角与收获。
开始
webpack loader 在宇宙的诞生来源于源码中的lib/NormalModule.js
中的 runLoaders
函数:
js
runLoaders(
{
resource: this.resource,
loaders: this.loaders,
context: loaderContext,
processResource: (loaderContext, resourcePath, callback) => {
const resource = loaderContext.resource;
const scheme = getScheme(resource);
hooks.readResource
.for(scheme)
.callAsync(loaderContext, (err, result) => {
if (err) return callback(err);
if (typeof result !== "string" && !result) {
return callback(new UnhandledSchemeError(scheme, resource));
}
return callback(null, result);
});
}
},
(err, result) => {
// loader 执行完之后的流程...
}
)
这里来看一下入参,尤其是context
,这个变量贯穿了 loaders 的整个生命周期!:
loaders
: 满足当前文件的规则所配置的 loaders。resource
: 当前文件的路径。loaderContext
: loader 生命周期的上下文。
其中 runLoaders
里面又调用了iteratePitchingLoaders
,顾名思义,递归 loaders,到了这里就开始了 loaders 的 Pitching 阶段了。
Loader Pitching
一个常见的认知是:Webpack loaders 的执行顺序是从右到左或者从下到上,这并没有错,但事实上,loaders 被加载(初始化)的顺序却是从左至右或者从上至下的!更为有趣的是,在这其中还包含了一个被称为 "pitching" 的阶段,它在整个流程中起着非常关键的作用。
从源码中可以看出,一开始loaderContext.loaderIndex = 0
以此递增地去加载 loader,并将 loader 本身赋值给了currentLoaderObject.normal
,loader.pitch
赋值给currentLoaderObject.pitch
,然后将pitchExecuted
设置为true
,以便将loaderIndex++
继续递归到下一个loader
。
如果 loader.pitch 存在,则会执行 pitch 函数,这个阶段就是 pitch 阶段。
js
function iteratePitchingLoaders(options, loaderContext, callback) {
// abort after last loader
// pitch结束,loaderIndex到达最后一个,开始执行normal函数(即loader本身的处理函数)
if(loaderContext.loaderIndex >= loaderContext.loaders.length)
return processResource(options, loaderContext, callback);
var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
// @read pitch方法: 在loader调用的阶段,存在一个叫"pitching phase"的阶段,
// 此阶段Webpack会从右往左依次调用loader的pitch方法。
// 每个loader的pitch方法会接收剩下的loader处理后的结果作为参数。
// 如果在loader的pitch方法中返回了结果,那么剩余的loaders将被跳过。
if(currentLoaderObject.pitchExecuted) {
loaderContext.loaderIndex++;
return iteratePitchingLoaders(options, loaderContext, callback);
}
// load loader module
// 加载loader,会将loader本身赋值给normal函数,pitch赋值给pitch
loadLoader(currentLoaderObject, function(err) {
var fn = currentLoaderObject.pitch;
currentLoaderObject.pitchExecuted = true;
if(!fn) return iteratePitchingLoaders(options, loaderContext, callback);
// 如果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);
// Determine whether to continue the pitching process based on
// argument values (as opposed to argument presence) in order
// to support synchronous and asynchronous usages.
// 如果pitch函数存在返回值则跳过后续的递归处理流程,直接掉头处理loader的normal函数
var hasArg = args.some(function(value) {
return value !== undefined;
});
if(hasArg) {
loaderContext.loaderIndex--;
iterateNormalLoaders(options, loaderContext, args, callback);
} else {
iteratePitchingLoaders(options, loaderContext, callback);
}
}
);
});
}
// 加载loader
module.exports = function loadLoader(loader, callback) {
try {
var module = require(loader.path);
} catch(e) {
}
return handleResult(loader, module, callback);
}
function handleResult(loader, module, callback) {
// 省略若干错误处理
loader.normal = typeof module === "function" ? module : module.default;
loader.pitch = module.pitch;
loader.raw = module.raw;
callback();
}
};
可能很多人不知道,什么是 loader pitching?根据官网的描述可以知道:webpack.js.org/api/loaders....
如果说我们配置了以下 3 个 loader:
js
module.exports = {
//...
module: {
rules: [{
// ...
use: ['a-loader', 'b-loader', 'c-loader'],
}],
},
};
那么 loader 的全流程的示意图如下:
bash
|- a-loader `pitch`
|- b-loader `pitch`
|- c-loader `pitch`
|- requested module is picked up as a dependency
|- c-loader normal execution
|- b-loader normal execution
|- a-loader normal execution
如果 loader 中 pitch 方法返回了一个结果,则会立即停止递归,并且掉头执行 normal 方法,此时的流程图如下:
bash
|- a-loader `pitch`
|- b-loader `pitch` returns a module
|- a-loader normal execution
Async Loader
Webpack loader 不仅仅支持同步,还支持异步 loader。在源码中,从 runSyncOrAsync 函数名称就可以出初见端倪,接下来仔细看看:
js
function runSyncOrAsync(fn, context, args, callback) {
// 默认是同步loader
var isSync = true;
var isDone = false;
var isError = false; // internal error
var reportedError = false;
// 在自定义loader内部可以使用的this.async,如果是异步loader应该调用一下这个方法,告知该loader是异步的
context.async = function async() {
if(isDone) {
if(reportedError) return; // ignore
throw new Error("async(): The callback was already called.");
}
isSync = false;
return innerCallback;
};
// 如果是异步loader,在执行结束时应该调用一下,告知函数已经执行完毕。类似promise.resolve
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() {
return fn.apply(context, args);
}());
// 同步loader执行
if(isSync) {
isDone = true;
if(result === undefined)
return callback();
if(result && typeof result === "object" && typeof result.then === "function") {
return result.then(function(r) {
callback(null, r);
}, callback);
}
return callback(null, result);
}
} catch(e) {
// ...省略
}
}
这个runSyncOrAsync
函数主要做了三件事情:
- 定义了
context.callback
、context.async
方法 - 执行
fn
函数。 - 执行
callback
函数
后两步很好理解,但是第一步中的两个方法有什么用呢?这正是专门给异步 loader 去调用的。webpack 并没有办法感知异步 loader 什么时候执行完毕,从而去调用 callback
。
因此我们在自定义异步 loader 的时候,内部可以调用 this.async 以及 this.callback,以防止 webpack 内部发生不可检测的错误。
而同步的 loader 不需要用到这两个方法。
Normal Loader
等到加载完所有的 loader,pitching 阶段结束后,就来到了 webpack loader 的从后往前的执行阶段,也就是processResource
函数。
js
function processResource(options, loaderContext, callback) {
// set loader index to last loader. 这就是为什么loaders的执行是从后往前的了
loaderContext.loaderIndex = loaderContext.loaders.length - 1;
var resourcePath = loaderContext.resourcePath;
if(resourcePath) {
// 触发 readResource Hooks
options.processResource(loaderContext, resourcePath, function(err) {
if(err) return callback(err);
var args = Array.prototype.slice.call(arguments, 1);
options.resourceBuffer = args[0];
iterateNormalLoaders(options, loaderContext, args, callback);
});
} else {
iterateNormalLoaders(options, loaderContext, [null], callback);
}
}
readResource Hook 绑定在了 FileUrlPlugin 上,并使用 fs.readFile 方法读取了目标 js 文件的 Buffer。之后就可以从后往前传递给 loaders 进行代码转换啦:
js
function iterateNormalLoaders(options, loaderContext, args, callback) {
if(loaderContext.loaderIndex < 0)
return callback(null, args);
var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
// iterate
if(currentLoaderObject.normalExecuted) {
loaderContext.loaderIndex--;
return iterateNormalLoaders(options, loaderContext, args, callback);
}
var fn = currentLoaderObject.normal;
currentLoaderObject.normalExecuted = true;
if(!fn) {
return iterateNormalLoaders(options, loaderContext, args, callback);
}
// buffer.toString('utf-8') 转换成字符串
convertArgs(args, currentLoaderObject.raw);
runSyncOrAsync(fn, loaderContext, args, function(err) {
if(err) return callback(err);
// 这里第二个参数就是经过loader处理后的结果,将其继续传递给iterateNormalLoaders依次递归下去
var args = Array.prototype.slice.call(arguments, 1);
iterateNormalLoaders(options, loaderContext, args, callback);
});
}
function runSyncOrAsync(){
var result = (function LOADER_EXECUTION() {
return fn.apply(context, args);
}());
}
这个函数的中心任务是利用 loaderIndex
迭代所有的 loaders,并校验每一个 loader 是否已被执行,若为 true
,则减小 loaderIndex
并递归调用这个函数,即跳过这个已执行的 loader,进入处理下一个 loader。若遇到尚未执行的 loader,则修改其状态为执行中,然后调用 runSyncOrAsync
函数,处理 loader 的同步或异步操作,并将结果传递给下一个 loader 直至所有 loader 执行完毕。
我们日常写的简单自定义 loader 的执行阶段就是在这里。这段逻辑比较简单。normal 阶段执行结束后,递归回溯到 runloader
的回调函数,继续编译。
css-loader 原理解读
css-loader
源码只有一个normal function,整体的流程比较简单,因此这里就不展开源码进行解析了。
CSS-loader 主要做两件事:
- 解析 CSS 文件并且解析
@import
和url()
引用,以解决 CSS 中的依赖关系。例如,如果你的 CSS 文件中使用@import
导入了一个其他 CSS 文件,或者使用url()
引用了一个图像,那么 css-loader 将确保这些依赖关系在打包过程中得到正确处理。 - 将解析和处理后的 CSS 转换为 JavaScript 对象,然后通过 webpack 打包到你的 bundle 中。当你在 JavaScript 文件中 import 或 require() 一个 CSS 文件时,这个 CSS 将作为一个 JavaScript 对象插入到你的 bundle 中。
如果我们将以下css文件通过import
引入,并打印出来,则会出现这个结果:
css
.foo {
color: red;
}
核心逻辑是使用 postcss进行解析Css ast ,默认配置postcss-import-parser
和postcss-url-parser
(这保证了css中的@import()
和url()
语句可以被正确解析),最终解析成为了js模块,最终输入格式为${importCode}${moduleCode}${exportCode}
的字符串:
经过css-loader
处理后的 bundle代码如下:
js
// 转换前
.foo{
color: red;
}
// 转换后
// Imports
import ___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___ from "./loaders/css-loader/dist/runtime/noSourceMaps.js";
import ___CSS_LOADER_API_IMPORT___ from "./loaders/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, `.foo {
color: red;
}
`, ""]);
// Exports
export default ___CSS_LOADER_EXPORT___;
Style-loader 原理解读
style-loader就是一个典型的picth loader,它的源码中只有pitch函数,normal函数几乎没有做任何逻辑。其主要工作流程分为打包阶段和runtime阶段。
打包阶段
js
/** request: css文件的路径 */
loader.pitch = function pitch(request) {
const options = this.getOptions(schema);
// 插入类型
const injectType = options.injectType || "styleTag";
// 判断插入类型,得到最终的代码
switch (injectType) {
case "linkTag": {
}
case "lazyStyleTag":
case "lazyAutoStyleTag":
case "lazySingletonStyleTag": {
}
case "styleTag":
case "autoStyleTag":
case "singletonStyleTag":
default: {
return ...
}
};
从逻辑上可以看到,style-loader在执行的时候会生成一部份js代码,主要是为了引入依赖,但实际上主要样式处理、热更新等核心逻辑都是在runtime阶段运行的。我们最简单的例子最终生成出来的代码如下:
js
import API from "!./loaders/style-loader/dist/runtime/injectStylesIntoStyleTag.js";
import domAPI from "!./loaders/style-loader/dist/runtime/styleDomAPI.js";
import insertFn from "!./loaders/style-loader/dist/runtime/insertBySelector.js";
import setAttributes from "!./loaders/style-loader/dist/runtime/setAttributesWithoutAttributes.js";
import insertStyleElement from "!./loaders/style-loader/dist/runtime/insertStyleElement.js";
import styleTagTransformFn from "!./loaders/style-loader/dist/runtime/styleTagTransform.js";
// inline loader:只获取通过css-loader处理后的css模块
import content, * as namedExport from "!!./loaders/css-loader/dist/cjs.js!./index.css";
var options = {};
options.styleTagTransform = styleTagTransformFn;
options.setAttributes = setAttributes;
options.insert = insertFn.bind(null, "head");
options.domAPI = domAPI;
options.insertStyleElement = insertStyleElement;
var update = API(content, options);
export * from "!!./loaders/css-loader/dist/cjs.js!./index.css";
export default content && content.locals ? content.locals : undefined;
总结:style-loader
在打包阶段会生成一段 JavaScript 代码。这段代码在运行时,会将 CSS 代码作为<style>
元素动态注入到 HTML <head>
中。
runtime 阶段
runtime阶段的主要逻辑是var update = API(content, options)
,那继续看一下injectStylesIntoStyleTag
这个方法做了什么?
js
// injectStyleIntoStyleTag
module.exports = (list = [], options = {}) => {
let lastIdentifiers = modulesToDom(list, options);
return function update(newList) {
newList = newList || [];
// 去除旧的Identifiers 有省略
for (let i = 0; i < lastIdentifiers.length; i++) {
const identifier = lastIdentifiers[i];
const index = getIndexByIdentifier(identifier);
if (stylesInDOM[index].references === 0) {
stylesInDOM[index].updater(); // updater
stylesInDOM.splice(index, 1);
}
}
// 新的style identifiers
const newLastIdentifiers = modulesToDom(newList, options);
lastIdentifiers = newLastIdentifiers;
};
};
function modulesToDom(list, options) {
const identifiers = [];
for (let i = 0; i < list.length; i++) {
const item = list[i];
const id = options.base ? item[0] + options.base : item[0];
const count = idCountMap[id] || 0;
const identifier = `${id} ${count}`;
const indexByIdentifier = getIndexByIdentifier(identifier);
const obj = {
css: item[1],
media: item[2],
sourceMap: item[3],
supports: item[4],
layer: item[5]
};
if (indexByIdentifier !== -1) {
stylesInDOM[indexByIdentifier].references++;
stylesInDOM[indexByIdentifier].updater(obj);
} else {
// 定义更新函数
const updater = addElementStyle(obj, options);
options.byIndex = i;
stylesInDOM.splice(i, 0, {
identifier,
updater,
references: 1
});
}
identifiers.push(identifier);
}
return identifiers;
}
function addElementStyle(obj, options) {
const api = options.domAPI(options); // styleDomAPI
api.update(obj);
// 如果css有更新则update,没更新则直接return。
const updater = newObj => {
if (newObj) {
if (
newObj.css === obj.css &&
newObj.media === obj.media &&
newObj.sourceMap === obj.sourceMap &&
newObj.supports === obj.supports &&
newObj.layer === obj.layer
) {
return;
}
api.update((obj = newObj));
} else {
api.remove();
}
};
return updater;
}
这段代码有点让人摸不着头脑,其实大部分代码可以不需要看,它只是在判断当前css模块有没有发生变化。
但是我们可以窥探到主要的逻辑就是当css有更新的时候则update
,没更新直接return
,如果没有css的话,则直接remove
掉。这里关键的update
以及remove
则是在styleDomApi
方法中:
js
// insertBySelector.js
function insertBySelector(insert, style) {
const target = getTarget(insert);
target.appendChild(style);
}
// styleDomApi.js
function removeStyleElement(styleElement) {
// istanbul ignore if
if (styleElement.parentNode === null) {
return false;
}
styleElement.parentNode.removeChild(styleElement);
}
// 创建style并且在head中插入
function insertStyleElement(options) {
const element = document.createElement("style");
options.setAttributes(element, options.attributes);
options.insert(element, options.options);
return element;
}
function domAPI(options) {
// 拿到插入好的style,执行update
const styleElement = options.insertStyleElement(options);
return {
update: (obj) => {
apply(styleElement, options, obj);
},
remove: () => {
removeStyleElement(styleElement);
},
};
}
上述代码创建好了新的style标签,并且将它插入到head标签中,之后再创建update
方法,update
方法实际上就是把处理好的css代码,插入刚刚创建好的style
标签中:
js
function styleTagTransform(css, styleElement) {
if (styleElement.styleSheet) {
styleElement.styleSheet.cssText = css;
} else {
while (styleElement.firstChild) {
styleElement.removeChild(styleElement.firstChild);
}
styleElement.appendChild(document.createTextNode(css));
}
}
这里就是回归到了最基本的dom元素的插入以及清除上了。
总结:
我们在style-loader/runtime/injectStylesIntoStyleTag.js
中打上断点,跟着运行时走一遍流程:
- 首先,
injectStylesIntoStyleTag
拿到的是经过css-loader
处理后的css模块对象。 - 经过
addElementStyle
,拿到DomApi,在头部插入创建的Style标签insertStyleElement
- 最后创建
updater
,即styleTagTransfrom
中的清除、更新styleappendChild
。
我在这里提个自我思考的问题 :为什么配置时
style-loader
要在css-loader
之前呢?明明style-loader
的 pitch 函数有返回值,实际上根本不会执行到css-loader
。
在简单了解过 loader 的生命周期以及 css-loader
和 style-loader
后,我的理解是:
在 webpack 打包过程中,其实根本不需要走完 loader normal 阶段。最直观的体现在于,当你单独设置css-loader
的时候,import style from xx.css
可以发现引入的 style 是一个js数组;然而当你再加上style-loader
后,style 则变成了 undefined
。
打包过程中,只需要通过style-loader
处理变成一段js
代码:在runtime阶段是动态插入并更新目标 css 到 Html 文件中。
然而这里又会有一个问题?style-loader
怎么获取到经过css-loader
处理后的js模块呢?
答案是通过 「inline loader」 的方式。我们可以回头看下经过style-loader
打包后的代码,可以发现有这样一段代码:
js
import content, * as namedExport from "!!./loaders/css-loader/dist/cjs.js!./index.css";
这就是inline-loader
,通过行内引入的方式来覆盖全局的 loader rules。这里表示只用css-loader
进行处理index.css
文件。
结尾
行文至此,想必读完了文章的同学们都对webpack loader的生命周期有了一定的理解。通读源码,我们也能从中学习到许多知识:
- Loader pitching 阶段:从前往后执行,再从后往前执行 normal 阶段。如果 pitching 时有返回值,则直接掉头执行前一个 loader 的 normal 阶段。
- loader的不同种类:异步 loader 必须要调用
this.async()
以及this.callback
来告知 webpack 我是异步 loader,否则会发生错误。 - loader的编写:可以使用 this 获取 loader 的上下文属性以及方法、校验用户传的 options 格式可以使用
schema-utils
库、可以使用loader-utils
来使用 loader 上下文方法
js
import { getOptions } from "loader-utils";
import { validate } from "schema-utils";
import schema from "path/to/schema.json";
function loader(src, map) {
const options = getOptions(this);
validate(schema, options, {
name: "Loader Name",
baseDataPath: "options",
});
// Code...
}
export default loader;
我是「盐焗乳鸽还要香锅」,喜欢我的文章欢迎关注噢
- github 链接github.com/1360151219
- 公众号、掘金账号、知乎账号《盐焗乳鸽还要香锅》