深入理解 Webpack5【一】:从加载至执行,彻底解读 Loader 的生命周期

深入理解 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.normalloader.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函数主要做了三件事情:

  1. 定义了context.callbackcontext.async方法
  2. 执行 fn函数。
  3. 执行 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 文件并且解析 @importurl() 引用,以解决 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-parserpostcss-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中的清除、更新style appendChild

我在这里提个自我思考的问题 :为什么配置时style-loader要在css-loader之前呢?明明style-loader的 pitch 函数有返回值,实际上根本不会执行到 css-loader

在简单了解过 loader 的生命周期以及 css-loaderstyle-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.js.org/concepts/lo...

结尾

行文至此,想必读完了文章的同学们都对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
  • 公众号、掘金账号、知乎账号《盐焗乳鸽还要香锅》
相关推荐
一只欢喜24 分钟前
uniapp使用uview2上传图片功能
前端·uni-app
尸僵打怪兽37 分钟前
后台数据管理系统 - 项目架构设计-Vue3+axios+Element-plus(0920)
前端·javascript·vue.js·elementui·axios·博客·后台管理系统
ggome1 小时前
Uniapp低版本的安卓不能用解决办法
前端·javascript·uni-app
Ylucius1 小时前
JavaScript 与 Java 的继承有何区别?-----原型继承,单继承有何联系?
java·开发语言·前端·javascript·后端·学习
前端初见1 小时前
双token无感刷新
前端·javascript
、昔年1 小时前
前端univer创建、编辑excel
前端·excel·univer
emmm4591 小时前
前端中常见的三种存储方式Cookie、localStorage 和 sessionStorage。
前端
Q186000000001 小时前
在HTML中添加视频
前端·html·音视频
bin91531 小时前
前端JavaScript导出excel,并用excel分析数据,使用SheetJS导出excel
前端·javascript·excel
Rattenking2 小时前
node - npm常用命令和package.json说明
前端·npm·json