编写加载器

加载器是一个导出函数的节点模块。当需要通过此加载器转换资源时,会调用此函数。给定的函数将能够通过提供给它的 this 上下文访问 加载器 API

设置

在我们深入探讨不同类型的加载器、它们的用法和示例之前,让我们先看看在本地开发和测试加载器的三种方法。

要测试单个加载器,可以使用 pathresolve 规则对象中的本地文件

webpack.config.js

const path = require('path');

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

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

webpack.config.js

const path = require('path');

module.exports = {
  //...
  resolveLoader: {
    modules: ['node_modules', path.resolve(__dirname, 'loaders')],
  },
};

顺便说一句,如果你已经为你的加载器创建了一个单独的仓库和包,你可以使用 npm link 将其链接到你想测试它的项目中。

简单用法

当单个加载器应用于资源时,加载器只接受一个参数 - 包含资源文件内容的字符串。

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

加载器预期返回一个或两个值。第一个值是作为字符串或缓冲区的最终 JavaScript 代码。第二个可选值是作为 JavaScript 对象的 SourceMap。

复杂用法

当多个加载器链接在一起时,重要的是要记住它们是按相反顺序执行的 - 既可以从右到左,也可以从下到上,具体取决于数组格式。

  • 最后一个加载器(第一个被调用)将接收原始资源的内容。
  • 第一个加载器(最后一个被调用)预期返回 JavaScript 和一个可选的源映射。
  • 中间的加载器将使用链中前一个加载器的结果执行。

在以下示例中,foo-loader 将接收原始资源,而 bar-loader 将接收 foo-loader 的输出,并返回最终转换后的模块和一个可选的源映射。

webpack.config.js

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js/,
        use: ['bar-loader', 'foo-loader'],
      },
    ],
  },
};

指南

编写加载器时应遵循以下指南。它们按重要性排序,有些只适用于特定场景,请阅读以下详细部分以获取更多信息。

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

简单

加载器应该只执行一项任务。这不仅使维护每个加载器的工作变得更容易,而且还允许它们被链接起来以用于更多场景。

链式调用

利用加载器可以链接在一起的事实。与其编写一个处理五个任务的单个加载器,不如编写五个更简单的加载器来划分这项工作。将它们隔离不仅可以使每个加载器保持简单,而且还可以让它们用于你最初没有想到的事情。

以使用加载器选项或查询参数指定的渲染模板文件为例。它可以被编写为一个单个加载器,该加载器从源代码编译模板,执行它并返回一个模块,该模块导出一个包含 HTML 代码的字符串。但是,根据指南,存在一个apply-loader,它可以与其他开源加载器链接起来

  • pug-loader:将模板转换为导出函数的模块。
  • apply-loader:使用加载器选项执行函数并返回原始 HTML。
  • html-loader:接受 HTML 并输出一个有效的 JavaScript 模块。

模块化

保持输出模块化。加载器生成的模块应该遵循与普通模块相同的設計原則。

无状态

确保加载器在模块转换之间不保留状态。每次运行都应该独立于其他编译的模块以及同一模块的先前编译。

加载器工具

利用 loader-utils 包提供的各种实用工具。 除了 loader-utils 之外,还应使用 schema-utils 包来对加载器选项进行一致的基于 JSON Schema 的验证。 以下是一个使用这两个包的简短示例

loader.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));

  // Apply some transformations to the source...

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

加载器依赖项

如果加载器使用外部资源(例如通过从文件系统读取),则**必须**指示它。 此信息用于使可缓存的加载器失效并在监视模式下重新编译。 以下是如何使用 addDependency 方法实现此目的的简短示例

loader.js

import path from 'path';

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

  this.addDependency(headerPath);

  fs.readFile(headerPath, 'utf-8', function (err, header) {
    if (err) return callback(err);
    callback(null, header + '\n' + source);
  });
}

模块依赖项

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

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

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

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

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

通用代码

避免在加载器处理的每个模块中生成通用代码。相反,在加载器中创建一个运行时文件,并生成一个指向该共享模块的 require

src/loader-runtime.js

const { someOtherModule } = require('./some-other-module');

module.exports = function runtime(params) {
  const x = params.y * 2;

  return someOtherModule(params, x);
};

src/loader.js

import runtime from './loader-runtime.js';

export default function loader(source) {
  // Custom loader logic

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

绝对路径

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

// `loaderContext` is same as `this` inside loader function
JSON.stringify(loaderContext.utils.contextify(loaderContext.context || loaderContext.rootContext, request));

对等依赖项

如果您正在使用的加载器是另一个包的简单包装器,那么您应该将该包包含为 peerDependency。这种方法允许应用程序的开发人员在 package.json 中指定所需的精确版本。

例如,sass-loader 指定 node-sass 作为对等依赖项,如下所示

{
  "peerDependencies": {
    "node-sass": "^4.0.0"
  }
}

测试

因此,您已经编写了一个加载器,遵循了上面的指南,并已将其设置为在本地运行。接下来是什么?让我们通过一个单元测试示例来确保我们的加载器按预期工作。我们将使用 Jest 框架来执行此操作。我们还将安装 babel-jest 和一些预设,这些预设将允许我们使用 import / exportasync / await。让我们从安装并将其保存为 devDependencies 开始

npm install --save-dev jest babel-jest @babel/core @babel/preset-env

babel.config.js

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: 'current',
        },
      },
    ],
  ],
};

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

src/loader.js

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

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

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

我们将使用此加载器来处理以下文件

test/example.txt

Hey [name]!

请密切注意下一步,因为我们将使用 Node.js APImemfs 来执行 webpack。这使我们能够避免将 output 输出到磁盘,并能访问 stats 数据,从而获取转换后的模块

npm install --save-dev webpack memfs

test/compiler.js

import path from 'path';
import webpack from 'webpack';
import { createFsFromVolume, Volume } from 'memfs';

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);
    });
  });
};

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

test/loader.test.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

{
  "scripts": {
    "test": "jest"
  },
  "jest": {
    "testEnvironment": "node"
  }
}

一切就绪后,我们可以运行它并查看我们的新加载器是否通过测试

 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.

成功了!现在您应该可以开始开发、测试和部署自己的加载器了。我们希望您能与社区分享您的作品!

7 位贡献者

asulaimanmichael-ciniawskybyzykanikethsahajamesgeorge007chenxsandev-itsheng