乐趣区

关于webpack:谈下-webpack-loader-的机制

残缺高频题库仓库地址:https://github.com/hzfe/awesome-interview

残缺高频题库浏览地址:https://febook.hzfe.org/

相干问题

  • webpack loader 是如何工作的
  • 如何编写 webpack loader

答复关键点

转换 生命周期 chunk

webpack 自身只能解决 JavaScript 和 JSON 文件,而 loader 为 webpack 增加了解决其余类型文件的能力。loader 将其余类型的文件转换成无效的 webpack modules(如 ESmodule、CommonJS、AMD),webpack 能生产这些模块,并将其增加到依赖关系图中。

loader 实质上是一个函数,该函数对接管到的内容进行转换,返回转换后的后果。

常见的 loader 有:

  • raw-loader:加载文件原始内容。
  • file-loader:将援用文件输入到指标文件夹中,在代码中通过相对路径援用输入的文件。
  • url-loader:和 file-loader 相似,然而能在文件很小的状况下以 base64 的形式将文件内容注入到代码中。
  • babel-loader:将 ES 较新的语法转换为浏览器能够兼容的语法。
  • style-loader:将 CSS 代码注入到 JavaScript 中,通过 DOM 操作加载 CSS。
  • css-loader:加载 CSS,反对模块化、压缩、文件导入等个性。

应用 loader 的形式次要有两种:

  1. 在 webpack.config.js 文件中配置,通过在 module.rules 中应用 test 匹配要转换的文件类型,应用 use 指定要应用的 loader。
module.exports = {
  module: {rules: [{ test: /\.ts$/, use: "ts-loader"}],
  },
};
  1. 内联应用
import Styles from "style-loader!css-loader?modules!./styles.css";

知识点深刻

1. 编写 webpack loader

1.1 同步 loader

同步转换内容后,能够通过 return 或调用 this.callback 返回后果。

export default function loader(content, map, meta) {return someSyncOperation(content);
}

通过 this.callback 能够返回除内容以外的其余信息(如 sourcemap)。

export default function loader(content, map, meta) {this.callback(null, someSyncOperation(content), map, meta);
  return; // 当调用 callback() 时,始终返回 undefined}

1.2 异步 loader

通过 this.async 能够获取异步操作的回调函数,并在回调函数中返回后果。

export default function (content, map, meta) {const callback = this.async();
  someAsyncOperation(content, (err, result, sourceMaps, meta) => {if (err) return callback(err);
    callback(null, result, sourceMaps, meta);
  });
}

除非计算很小,否则对于 Node.js 这种单线程环境,尽可能应用异步 loader。

1.3 loader 开发辅助工具及 loaderContext

loader-utilsschema-utils,能够使获取及验证传递给 loader 的参数的工作简单化。

import {getOptions} from "loader-utils";
import {validate} from "schema-utils";

const schema = {
  type: "object",
  properties: {
    test: {type: "string",},
  },
};

export default function (source) {const options = getOptions(this);

  validate(schema, options, {
    name: "Example Loader",
    baseDataPath: "options",
  });

  // Apply some transformations to the source...

  return `export default ${JSON.stringify(source)}`;
}

loader-utils 次要有以下工具办法:

  • parseQuery:解析 loader 的 query 参数,返回一个对象。
  • stringifyRequest:将申请的资源转换为能够在 loader 生成的代码中 require 或 import 应用的相对路径字符串,同时防止绝对路径导致从新计算 hash 值。

    loaderUtils.stringifyRequest(this, "./test.js");
    // "\"./test.js\""
  • urlToRequest:将申请的资源门路转换成 webpack 能够解决的模式。

    const url = "~path/to/module.js";
    const request = loaderUtils.urlToRequest(url); // "path/to/module.js"
  • interpolateName:对文件名模板进行插值。

    // loaderContext.resourcePath = "/absolute/path/to/app/js/hzfe.js"
    loaderUtils.interpolateName(loaderContext, "js/[hash].script.[ext]", {content: ...});
    // => js/9473fdd0d880a43c21b7778d34872157.script.js
  • getHashDigest:获取文件内容的 hash 值。

在编写 loader 的过程中,还能够利用 loaderContext 对象来获取 loader 的相干信息和进行一些高级的操作,常见的属性和办法有:

  • this.addDependency:退出一个文件,作为 loader 产生的后果的依赖,使其在有任何变动时能够被监听到,从而触发从新编译。
  • this.async:通知 loader-runner 这个 loader 将会异步的执行回调。
  • this.cacheable:默认状况下,将 loader 的处理结果标记为可缓存。传入 false 能够敞开 loader 处理结果的缓存能力。
  • this.fs:用于拜访 compilation 的 inputFileSystem 属性。
  • this.getOptions:提取 loader 的配置选项。从 webpack 5 开始,能够获取到 loader 上下文对象,用于代替 loader-utils 中的 getOptions 办法。
  • this.mode:webpack 的运行模式,能够是 “development” 或 “production”。
  • this.query:如果 loader 配置了 options 对象,则指向这个对象。如果 loader 没有 options,而是以 query 字符串作为参数,query 则是一个以 ? 结尾的字符串。

2. webpack loader 工作机制

2.1 依据 module.rules 解析 loader 加载规定

当 webpack 解决一个模块(module)时,会依据配置文件中 module.rules 的规定,应用 loader 解决对应资源,失去可供 webpack 应用的 JavaScript 模块。

依据具体的配置状况,loader 会有不同的类型,能够影响 loader 的执行程序。具体类型如下所示:

rules: [
  // pre 前置 loader
  {enforce: "pre", test: /\.js$/, loader: "eslint-loader"},
  // normal loader
  {test: /\.js$/, loader: "babel-loader"},
  // post 后置 loader
  {enforce: "post", test: /\.js$/, loader: "eslint-loader"},
];

以及内联应用的 inline loader:

import "style-loader!css-loader!sass-loader!./hzfe.scss";

在失常的执行流程中,这些不同类型的 loader 的执行程序是:pre -> normal -> inline -> post。在下一节将会提到的 pitch 流程中,这些 loader 的执行程序是反过来的:post -> inline -> normal -> pre

对于内联 loader,能够告诉润饰前缀扭转 loader 的执行程序:

// ! 前缀会禁用 normal loader
import {HZFE} from "!./hzfe.js";
// -! 前缀会禁用 pre loader 和 normal loader
import {HZFE} from "-!./hzfe.js";
// !! 前缀会禁用 pre、normal 和 post loader
import {HZFE} from "!!./hzfe.js";

个别状况下,! 前缀和 inline loader 一起应用仅呈现在 loader(如 style-loader)生成的代码中,webpack 官网不倡议用户同时应用 inline loader 和 ! 前缀。

webpack rules 中配置的 loader 能够是多个链式串联的。在失常流程中,链式 loader 会依照从后往前的程序执行。

  • 最初的 loader 最先执行,它接管的是资源文件(resource file)的内容。
  • 第一个 loader 最初执行,它将返回 JavaScript 模块和可选的 source map。
  • 位于两头的 loader,对接管和返回没有特定要求,只有能解决之前 loader 返回的内容,产出下一个 loader 可能了解的内容就能够。

2.2 loader-runner 的执行流程

webpack 调用 loader 的机会在触发 compilation 的 buildModule 钩子之后。webpack 会在 NormalModule.js 中,调用 runLoaders 运行 loader:

runLoaders(
  {
    resource: this.resource, // 资源文件的门路,能够有查问字符串。如:'./test.txt?query'
    loaders: this.loaders, // loader 的门路。context: loaderContext, // 传递给 loader 的上下文
    processResource: (loaderContext, resourcePath, callback) => {
      // 获取资源的形式,有 scheme 的文件通过 readResourceForScheme 读取,否则通过 fs.readFile 读取。const resource = loaderContext.resource;
      const scheme = getScheme(resource);
      if (scheme) {
        hooks.readResourceForScheme
          .for(scheme)
          .callAsync(resource, this, (err, result) => {
            // ...
            return callback(null, result);
          });
      } else {loaderContext.addDependency(resourcePath);
        fs.readFile(resourcePath, callback);
      }
    },
  },
  (err, result) => {
    // 当 loader 转换实现后,会将后果返回到 webpack 中持续解决。processResult(err, result.result);
  }
);

runLoaders 函数来自 loader-runner 包。在介绍 runLoaders 的具体流程之前,先介绍一下 pitch 阶段,上一节中所讲的这种从后往前执行 loader 的流程,个别叫做 normal 阶段。与之绝对的,还有一种叫做 pitch 阶段的流程。

一个 loader 如果在导出的函数的 pitch 属性上挂在了办法,那这个办法将在 pitch 阶段执行。pitch 阶段不同于 normal 阶段,pitch 阶段的执行程序是从返回后的,整个流程相似浏览器事件模型或洋葱模型,pitch 阶段先从前往后执行 loader,而后再进入 normal 阶段从后往前执行 loader。留神,pitch 阶段个别不返回值,一旦 pitch 阶段有 loader 返回值,则从这里开始进入从后往前执行的 normal 阶段。

loader-runner 的具体流程如下:

  1. 解决从 webpack 接管的 context,持续增加必要的属性和辅助办法。
  2. iteratePitchingLoaders 解决 pitch loader。

    如果咱们给一个 module 配置了三个 loader,每个 loader 都配置了 pitch 函数:

    module.exports = {
      //...
      module: {
        rules: [
          {
            //...
            use: ["a-loader", "b-loader", "c-loader"],
          },
        ],
      },
    };

    那么解决这个 module 的流程如下:

    |- 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

    如果 b-loader 在 pitch 中提前返回了值,那么流程如下:

    |- a-loader `pitch`
      |- b-loader `pitch` returns a module
    |- a-loader normal execution
  3. iterateNormalLoaders 解决 normal loader。

    当 pitch loader 的流程解决完后,就来到了解决 normal loader 的流程。解决 normal loader 的流程和 pitch loader 类似,只是从后往前迭代。

    iterateNormalLoaders 和 iteratePitchingLoaders 都会调用 runSyncOrAsync 来执行 loader。runSyncOrAsync 会提供 context.async,这是一个返回 callback 的 async 函数,用于异步解决。

3. 常见 webpack loader 原理解析

loader 自身的操作并不简单,就是一个负责转换其余资源到 JavaScript 模块的函数。

3.1 raw-loader 剖析

该 loader 是性能非常简单的同步 loader,它的外围步骤是从文件原始内容中获得序列化的字符串,修复 JSON 序列化特殊字符时的 bug,增加导出语句,使其成为 JavaScript 模块。

该 loader 在 webpack 5 中已废除,间接应用 asset modules 的性能代替即可。该 loader 源码如下:

import {getOptions} from "loader-utils";
import {validate} from "schema-utils";

import schema from "./options.json";

export default function rawLoader(source) {const options = getOptions(this);

  validate(schema, options, {
    name: "Raw Loader",
    baseDataPath: "options",
  });

  const json = JSON.stringify(source)
    .replace(/\u2028/g, "\\u2028")
    .replace(/\u2029/g, "\\u2029");

  const esModule =
    typeof options.esModule !== "undefined" ? options.esModule : true;

  return `${esModule ? "export default" : "module.exports ="} ${json};`;
}

3.2 babel-loader 剖析

babel loader 是一个综合了同步和异步的 loader,在应用缓存配置时以异步模式运行,否则以同步形式运行。该 loader 的次要源码如下:

// imports ...
// ...

const transpile = function (source, options) {
  // ...

  let result;
  try {result = babel.transform(source, options);
  } catch (error) {// ...}
  // ...

  return {
    code: code,
    map: map,
    metadata: metadata,
  };
};

// ...

module.exports = function (source, inputSourceMap) {
  // ...

  if (cacheDirectory) {const callback = this.async();
    return cache(
      {
        directory: cacheDirectory,
        identifier: cacheIdentifier,
        source: source,
        options: options,
        transform: transpile,
      },
      (err, { code, map, metadata} = {}) => {if (err) return callback(err);

        metadataSubscribers.forEach((s) => passMetadata(s, this, metadata));

        return callback(null, code, map);
      }
    );
  }

  const {code, map, metadata} = transpile(source, options);

  this.callback(null, code, map);
};

babel-loader 通过 callback 传递了通过 babel.transform 转换后的代码及 source map。

3.3 style-loader 与 css-loader 剖析

style-loader 负责将款式插入到 DOM 中,使款式对页面失效。css-loader 次要负责解决 import、url 门路等内部援用。

style-loader 只有 pitch 函数。css-loader 是 normal module。整个执行流程是先执行 style-loader 阶段,style-loader 会创立形如 require(!!./hzfe.css) 的代码返回给 webpack。webpack 会再次调用 css-loader 解决款式,css-loader 会返回蕴含 runtime 的 js 模块给 webpack 去解析。style-loader 在上一步注入 require(!!./hzfe.css) 的同时,也注入了增加 style 标签的代码。这样,在运行时(浏览器中),style-loader 就能够把 css-loader 的款式插入到页面中。

常见的疑难就是为什么不依照 normal 模式组织 style-loader 和 css-loader。

首先 css-loader 返回的是形如这样的代码:

import ___CSS_LOADER_API_IMPORT___ from "../node_modules/_css-loader@5.1.3@css-loader/dist/runtime/api.js";
var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(function (i) {return i[1];
});
// Module
___CSS_LOADER_EXPORT___.push([
  module.id,
  ".hzfe{\r\n    height: 100px;\r\n}",
  "",
]);
// Exports
export default ___CSS_LOADER_EXPORT___;

style-loader 无奈在编译时获取 CSS 相干的内容,因为 style-loader 无奈解决 css-loader 生成后果的 runtime 依赖。style-loader 也无奈在运行时获取 CSS 相干的内容,因为无论怎样拼接运行时代码,都无奈获取到 CSS 的内容。

作为代替,style-loader 采纳了 pitch 计划,style-loader 的外围性能如下所示:

module.exports.pitch = function (request) {
  var result = [
    // 生成 require CSS 文件的语句,交给 css-loader 解析 失去蕴含 CSS 内容的 JS 模块
    // 其中 !! 是为了防止 webpack 解析时递归调用 style-loader
    `var content=require("${loaderUtils.stringifyRequest(this, `!!${request}`)}")`,
    // 在运行时调用 addStyle 把 CSS 内容插入到 DOM 中
    `require("${loaderUtils.stringifyRequest(this, `!${path.join(__dirname,"add-style.js")}`)}")(content)`
    // 如果发现启用了 CSS modules,则默认导出它
    "if(content.locals) module.exports = content.locals",
  ];
  return result.join(";");
};
module.exports = function (content) {var style = document.createElement("style");
  style.innerHTML = content;
  document.head.appendChild(style);
};

在 pitch 阶段,style-loader 生成 require CSS 以及注入 runtime 的代码。该后果会返回给 webpack 进一步解析,css-loader 返回的后果会作为模块在运行时导入,在运行时可能取得 CSS 的内容,而后调用 add-style.js 把 CSS 内容插入到 DOM 中。

参考资料

  1. writting a loader
  2. Loader Interface
  3. loader runner
退出移动版