知海

编写 Loader

webpackjsorg-main贡献指南

编写 Loader

Loader 是一个导出函数的 Node 模块。当资源需要被该 loader 转换时,会调用这个函数。给定的函数可以通过 this 上下文访问 Loader API

准备工作

在我们深入研究不同类型的 loader、它们的用法和示例之前,先来看看在本地开发和测试 loader 的三种方式。

要测试单个 loader,你可以在 rule 对象中使用 pathresolve 一个本地文件:

webpack.config.js

js 复制代码
import path from "node:path";

export default {
  // ...
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: path.resolve("path/to/loader.js"),
            options: {
              /* ... */
            },
          },
        ],
      },
    ],
  },
};

要测试多个 loader,你可以利用 resolveLoader.modules 配置来更新 webpack 搜索 loader 的位置。例如,如果你的项目中有一个本地的 /loaders 目录:

webpack.config.js

js 复制代码
import path from "node:path";

const __dirname = import.meta.dirname;

export default {
  // ...
  resolveLoader: {
    modules: ["node_modules", path.resolve(__dirname, "loaders")],
  },
};

顺便说一下,如果你已经为你的 loader 创建了一个单独的仓库和包,你可以通过 npm link 将它链接到你想测试它的项目中。

你可以使用 webpack-defaults 来生成开始编写 loader 所需的样板代码。

简单用法

当单个 loader 应用于资源时,loader 只会被一个参数调用——一个包含资源文件内容的字符串。

同步 loader 可以 return 一个代表转换后模块的单一值。在更复杂的场景中,loader 可以使用 this.callback(err, values...) 函数返回任意数量的值。错误要么传递给 this.callback 函数,要么在同步 loader 中抛出。

Loader 需要返回一个或两个值。第一个值是结果 JavaScript 代码,作为字符串或 buffer。第二个可选值是作为 JavaScript 对象的 SourceMap。

复杂用法

当多个 loader 被链式调用时,重要的是要记住它们是按相反的顺序执行的——从右到左或从下到上,取决于数组的格式。

  • 最后一个 loader(最先被调用)会接收到原始资源的内容。
  • 第一个 loader(最后被调用)需要返回 JavaScript 和一个可选的 source map。
  • 中间的 loader 会使用链中前一个 loader 的结果执行。

在下面的示例中,foo-loader 将接收原始资源,而 bar-loader 将接收 foo-loader 的输出,并返回最终的转换后模块以及必要的 source map。

webpack.config.js

js 复制代码
export default {
  // ...
  module: {
    rules: [
      {
        test: /\.js/,
        use: ["bar-loader", "foo-loader"],
      },
    ],
  },
};

Pitching loaders

Loader 通常从右到左运行。一个 loader 也可以导出一个 pitch 函数,该函数在正常阶段开始之前从左到右运行。这允许一个 loader 将数据传递给它自己的正常阶段,或者短路剩余的 loader 链。

js 复制代码
// my-loader.js
export default function (source) {
  // 正常阶段 — 从右到左运行
  const prefix = this.data.value ?? "";
  return `${prefix}\n${source}`;
}

export function pitch(remainingRequest, precedingRequest, data) {
  // Pitch 阶段 — 在正常 loader 之前从左到右运行
  data.value = "/* 由 my-loader 处理 */";
}

data 仅在同一个 loader 的 pitch 和正常函数之间共享。它不会在链中的不同 loader 之间共享。

短路 loader 链

如果 pitch 函数返回一个值,webpack 会跳过右侧剩余的 loader 并立即反转。当你想要提前生成模块代码并绕过正常阶段时,这会很有用。

js 复制代码
export function pitch(remainingRequest) {
  return `
import style from ${JSON.stringify(`!!${remainingRequest}`)};
const el = document.createElement("style");
el.textContent = style;
document.head.appendChild(el);
  `;
}

pitch 函数返回一个值时,该 loader 的默认导出不会运行。只有当你打算替换正常的处理流程时才使用此模式。

指南

编写 loader 时应遵循以下指南。它们按重要性排序,有些仅适用于特定场景,请阅读后续的详细章节以了解更多信息。

  • 保持 简单
  • 利用 链式调用
  • 输出 模块化 的结果。
  • 确保它们是 无状态 的。
  • 使用 loader 工具
  • 标记 loader 依赖
  • 解析 模块依赖
  • 提取 公共代码
  • 避免 绝对路径
  • 使用 peer 依赖

简单

Loader 应该只做单一任务。这不仅使每个 loader 的维护更容易,还允许它们被链式调用以用于更多场景。

链式调用

利用 loader 可以链式调用这一特性。与其编写一个处理五个任务的单一 loader,不如编写五个更简单的 loader 来分担这项工作。将它们隔离不仅保持每个 loader 简单,而且可能允许它们用于你最初没有想到的用途。

以使用 loader options 或 query 参数指定的数据渲染模板文件为例。它可以编写为一个单一的 loader,从源代码编译模板,执行它并返回一个导出包含 HTML 代码字符串的模块。但是,按照指南,存在一个 apply-loader 可以与其他开源 loader 链式使用:

  • pug-loader: 将模板转换为导出函数的模块。
  • apply-loader: 使用 loader options 执行该函数并返回原始 HTML。
  • html-loader: 接收 HTML 并输出有效的 JavaScript 模块。

Loader 可以链式调用这一事实也意味着它们不一定非要输出 JavaScript。只要链中的下一个 loader 能够处理其输出,loader 就可以返回任何类型的模块。

模块化

保持输出模块化。Loader 生成的模块应遵循与普通模块相同的设计原则。

无状态

确保 loader 不会在模块转换之间保留状态。每次运行都应始终独立于其他已编译模块以及同一模块的先前编译。

Loader 工具

利用 loader-utils 包,它提供了各种有用的工具。 除了 loader-utils,还应使用 schema-utils 包来进行基于 JSON Schema 的 loader options 一致性校验。以下是一个同时使用两者的简短示例:

loader.js

js 复制代码
import { urlToRequest } from "loader-utils";
import { validate } from "schema-utils";

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

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

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

  console.log("The request path", urlToRequest(this.resourcePath));

  // 对源代码应用一些转换...

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

数据共享

在 webpack 中,loader 可以链式调用,并与链中后续的 loader 共享数据。为此,你可以在原始 loader 中使用 this.callback 方法将数据与内容(源代码)一起传递。在原始 loader 的默认导出函数中,你可以使用 this.callback 的第四个参数传递数据。

js 复制代码
export default function (source) {
  const options = getOptions(this);
  // 使用 this.callback 的第四个参数传递数据
  this.callback(null, `export default ${JSON.stringify(source)}`, null, {
    some: data,
  });
}

在上面的示例中,this.callback 第四个参数中的 some 属性用于将数据传递给链中的下一个 loader。

Loader 依赖

如果 loader 使用了外部资源(例如,从文件系统读取),它们必须指明这一点。此信息用于在 watch 模式下使可缓存的 loader 失效并重新编译。以下是一个使用 addDependency 方法实现此目的的简短示例:

loader.js

js 复制代码
import path from "node:path";

export default function (source) {
  const callback = this.async();
  const headerPath = path.resolve("header.js");

  this.addDependency(headerPath);

  fs.readFile(headerPath, "utf8", (err, header) => {
    if (err) return callback(err);
    callback(null, `${header}\n${source}`);
  });
}

模块依赖

根据模块类型,可能使用不同的模式来指定依赖。例如,在 CSS 中,使用 @importurl(...) 语句。这些依赖应由模块系统解析。

这可以通过以下两种方式之一完成:

  • 将它们转换为 require 语句。
  • 使用 this.resolve 函数来解析路径。

css-loader 是第一种方法的一个很好的例子。它通过将 @import 语句替换为对其他样式表的 require,以及将 url(...) 替换为对引用文件的 require,将依赖转换为 require

对于 less-loader,它不能将每个 @import 都转换为 require,因为所有 .less 文件必须在一次传递中编译以进行变量和 mixin 跟踪。因此,less-loader 使用自定义路径解析逻辑扩展了 less 编译器。然后,它利用第二种方法 this.resolve 通过 webpack 解析依赖。

如果该语言只接受相对 url(例如,url(file) 总是指 ./file),你可以使用 ~ 约定来指定对已安装模块(例如 node_modules 中的模块)的引用。对于 url,它看起来像 url('~some-library/image.jpg')

公共代码

  • 避免在每个由 loader 处理的模块中生成本地公共代码。相反,在 loader 中创建一个运行时文件,并将其 import(或 require)为共享模块:

虽然 CommonJS 语法(require)得到完全支持,但我们强烈建议新 loader 使用 ECMAScript Modules(import)。

src/loader-runtime.js

js 复制代码
import { someOtherModule } from "./some-other-module.js";

export default function runtime(params) {
  const x = params.y * 2;

  return someOtherModule(params, x);
}

src/loader.js

js 复制代码
import runtime from "./loader-runtime.js";

export default function loader(source) {
  // 自定义 loader 逻辑

  return `${runtime({
    source,
    y: Math.random(),
  })}`;
}

绝对路径

不要将绝对路径插入到模块代码中,因为当项目根目录被移动时会破坏哈希。你可以使用下面的代码将绝对路径转换为相对路径。

js 复制代码
// `loaderContext` 与 loader 函数内部的 `this` 相同
JSON.stringify(
  loaderContext.utils.contextify(
    loaderContext.context || loaderContext.rootContext,
    request,
  ),
);

Peer 依赖

如果你正在开发的 loader 是另一个包的简单包装器,那么你应将这个包作为 peerDependency 包含进来。这种方法允许应用程序开发者在需要时在 package.json 中指定确切的版本。

例如,sass-loadernode-sass 指定为 peer 依赖,如下所示:

json 复制代码
{
  "peerDependencies": {
    "node-sass": "^4.0.0"
  }
}

测试

所以,你已经编写了一个 loader,遵循了上面的指南,并已设置在本地运行。下一步是什么?让我们通过一个单元测试示例来确保我们的 loader 按照预期的方式工作。我们将使用 Jest 框架来做到这一点。我们还将安装 babel-jest 和一些 presets,它们将允许我们使用 import / exportasync / await。让我们开始将它们作为 devDependencies 安装和保存:

bash 复制代码
npm install --save-dev jest babel-jest @babel/core @babel/preset-env

babel.config.js

js 复制代码
export default {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current",
        },
      },
    ],
  ],
};

我们的 loader 将处理 .txt 文件,并将 [name] 的任何实例替换为提供给 loader 的 name 选项。然后,它将输出一个有效的 JavaScript 模块,其中包含作为其默认导出的文本:

src/loader.js

js 复制代码
export default function loader(source) {
  const options = this.getOptions();

  source = source.replaceAll(/\[name\]/, options.name);

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

我们将使用这个 loader 来处理以下文件:

test/example.txt

bash 复制代码
Hey [name]!

请密切关注下一步,因为我们将使用 Node.js APImemfs 来执行 webpack。这让我们避免将 output 输出到磁盘,并允许我们访问 stats 数据,我们可以使用这些数据来获取转换后的模块:

bash 复制代码
npm install --save-dev webpack memfs

test/compiler.js

js 复制代码
import path from "node:path";
import { Volume, createFsFromVolume } from "memfs";
import webpack from "webpack";

const __dirname = import.meta.dirname;

export default (fixture, options = {}) => {
  const compiler = webpack({
    context: __dirname,
    entry: `./${fixture}`,
    output: {
      path: path.resolve(__dirname),
      filename: "bundle.js",
    },
    module: {
      rules: [
        {
          test: /\.txt$/,
          use: {
            loader: path.resolve(__dirname, "../src/loader.js"),
            options,
          },
        },
      ],
    },
  });

  compiler.outputFileSystem = createFsFromVolume(new Volume());
  compiler.outputFileSystem.join = path.join.bind(path);

  return new Promise((resolve, reject) => {
    compiler.run((err, stats) => {
      if (err) reject(err);
      if (stats.hasErrors()) reject(stats.toJson().errors);

      resolve(stats);
    });
  });
};

在这个例子中,我们内联了 webpack 配置,但你也可以将配置作为导出函数的参数来接受。这将允许你使用同一个编译器模块测试多种设置。

现在,最后,我们可以编写测试并添加一个 npm 脚本来运行它:

test/loader.test.js

js 复制代码
/**
 * @jest-environment node
 */
import compiler from "./compiler.js";

test("Inserts name and outputs JavaScript", async () => {
  const stats = await compiler("example.txt", { name: "Alice" });
  const output = stats.toJson({ source: true }).modules[0].source;

  expect(output).toBe('export default "Hey Alice!\\n"');
});

package.json

json 复制代码
{
  "scripts": {
    "test": "jest"
  },
  "jest": {
    "testEnvironment": "node"
  }
}

一切就绪后,我们可以运行它,看看我们的新 loader 是否通过了测试:

bash 复制代码
 PASS  test/loader.test.js
  ✓ Inserts name and outputs JavaScript (229ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.853s, estimated 2s
Ran all test suites.

成功了!此时,你应该准备好开始开发、测试和部署自己的 loader 了。我们希望你能与社区的其他成员分享你的成果!

帮助我们改进文档

发现翻译问题或内容错误?请告诉我们。