关于 Webpack Loader 的原理与使用

You,13 min read

关于 Webpack Loader 的原理与使用

custom loader document (opens in a new tab)

loader API (opens in a new tab)

loader 是导出为一个函数的 node 模块。该函数在 loader 转换资源的时候调用。给定的函数将调用 Loader API (opens in a new tab),并通过 this 上下文访问

设置

在深入研究不同 loader 以及他们的用法和例子之前,我们先看

三种本地开发测试的方法

方法一:在 rule 对象使用 path.resolve 指定一个本地文件 匹配单个loader

匹配(test)单个 loader,你可以通过在 rule 对象使用 path.resolve 指定一个本地文件:

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

方案二: 使用 resolveLoader.modules 配置 匹配多个loadrs

匹配(test)多个 loaders,你可以使用 resolveLoader.modules 配置,webpack 将会从这些目录中搜索这些 loaders。例如,如果你的项目中有一个 /loaders 本地目录:

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

方案三:npm/yarn link调试本地loader

顺便提一下,如果你已经为 loader 创建了独立的库和包,你可以使用 npm link (opens in a new tab) 来将其链接到你要测试的项目

你可以用 webpack-defaults package (opens in a new tab) 来生成开始编写 loader 必要的样板代码(boilerplate code)。

这个库好像不大行 文档都写得不是很清楚

简单用法

当一个 loader 在资源中使用,这个 loader 只能传入一个参数 - 一个包含资源文件内容的字符串。

同步loader 与 异步loader的区别

同步 loader 可以 return 一个代表已转换模块(transformed module)的单一值。

在更复杂的情况下,loader 也可以通过使用 this.callback(err, values...) 函数,返回任意数量的值。

loader 中的错误如何处理

错误要么传递给这个 this.callback 函数,要么抛给(thrown in)同步 loader 。

loader 会返回一个或者两个值。第一个值的类型是 JavaScript 代码的字符串或者 buffer。第二个可选值是 SourceMap,它是个 JavaScript 对象。

复杂用法

loader 的执行顺序

当链式调用多个 loader 的时候,请记住它们是反方向执行的。取决于数组写法格式,从右向左或者从下向上执行。

在下例中,foo-loader 被传入原始资源,bar-loader 将接收 foo-loader 的产出,返回最终转化后的模块和一个 source map(可选)

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

用法准则(Guidelines)

编写 loader 时应该遵循以下准则。它们按重要程度排序,有些仅适用于某些场景,请阅读下面详细的章节以获得更多信息。

简单(simple)

loaders 应该只做单一任务。这不仅使每个 loader 易维护,也可以在更多场景链式调用。

链式(chaining)

利用 loader 可以链式调用的优势。写五个简单的 loader 实现五项任务,而不是一个 loader 实现五项任务。

功能隔离不仅使 loader 更简单,可能还可以将它们用于你原先没有想到的功能

以通过 loader 选项或者查询参数得到的数据渲染模板为例。可以把源代码编译为模板,执行并输出包含 HTML 代码的字符串写到一个 loader 中。

但是根据用法准则,已经存在这样一个 apply-loader,可以将它和其他开源 loader 串联在一起调用

loader 可以被链式调用意味着不一定要输出 JavaScript。只要下一个 loader 可以处理这个输出,这个 loader 就可以返回任意类型的模块

模块化(modular)

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

无状态(stateless)

确保 loader 在不同模块转换之间不保存状态。每次运行都应该独立于其他编译模块以及相同模块之前的编译结果

loader 工具库(Loader Utilities)

充分利用 loader-utils (opens in a new tab) 包。它提供了许多有用的工具,但最常用的一种工具是获取传递给 loader 的选项。

schema-utils (opens in a new tab) 包配合 loader-utils,用于保证 loader 选项,进行与 JSON Schema 结构一致的校验。这里有一个简单使用两者的例子:

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

loader 依赖(loader dependencies)

如果一个 loader 使用外部资源(例如,从文件系统读取),必须使用addDependency 声明它。

这些信息用于使缓存 loaders 无效,以及在观察模式(watch mode)下重编译。下面是一个简单示例,说明如何使用 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);
  });
}

模块依赖(module dependencies)

根据模块类型,可能会有不同的模式指定依赖关系。

例如在 CSS 中,使用 @import 和 url(...) 语句来声明依赖。

这些依赖关系应该由模块系统解析。

loader 中的模块依赖关系有哪些实现方案?

可以通过以下两种方式中的一种来实现:

css-loader 是第一种方式的一个例子。它将 @import 语句替换为 require 其他样式文件,将 url(...) 替换为 require 引用文件,从而实现将依赖关系转化为 require 声明。

less-loader 如何解决 @import 依赖问题?

对于 less-loader,无法将每个 @import 转化为 require,因为所有 .less 的文件中的变量和混合跟踪必须一次编译。因此,less-loader 将 less 编译器进行了扩展,自定义路径解析逻辑。然后,利用第二种方式,通过 webpack 的 this.resolve 解析依赖

如果语言只支持相对 url(例如 url(file) 总是指向 ./file),通过使用 ~ 来指定已安装模块(例如,引用 node_modules 中的那些模块)。所以对于 url,相当于 url('~some-library/image.jpg')

通用代码(common code)

避免在 loader 处理的每个模块中生成通用代码。相反,你应该在 loader 中创建一个运行时文件,并生成 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) {
  // 自定义的 loader 逻辑
 
  return `${runtime({
    source,
    y: Math.random(),
  })}`;
}

参考css-loader源码实现 的确有一个 runtime 文件目录 require 引用共享模块

绝对路径(absolute paths)

不要在模块代码中插入绝对路径,因为当项目根路径变化时,文件绝对路径也会变化。loader-utils 中的 stringifyRequest (opens in a new tab) 方法,可以将绝对路径转化为相对路径。

同等依赖(peer dependencies)

如果你的 loader 简单包裹另外一个包,你应该把这个包作为一个 peerDependency 引入。这种方式允许应用程序开发者在必要情况下,在 package.json 中指定所需的确定版本。

例如,sass-loader 指定 node-sass (opens in a new tab) 作为同等依赖,引用如下:

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

测试:Jest 写单元测试

当你遵循上面的用法准则编写了一个 loader,并且可以在本地运行。下一步该做什么呢?让我们用一个简单的单元测试,来保证 loader 能够按照我们预期的方式正确运行。 我们将使用 Jest (opens in a new tab) 框架。然后还需要安装 babel-jest 和允许我们使用 import / export 和 async / await 的一些预设环境(presets)。让我们开始安装,并且将这些依赖保存为 devDependencies

npm install --save-dev jest babel-jest @babel/core @babel/preset-env
 
yarn add -D jest babel-jest @babel/core @babel/preset-env
// babel.config.js
module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: 'current',
        },
      },
    ],
  ],
};

test/example.txt

Hey [name]!

请注意留心接下来的步骤,我们将会使用 Node.js API (opens in a new tab) 和 memfs (opens in a new tab) 去执行 webpack。这让我们避免向磁盘产生 输出文件,并允许我们访问获取转换模块的统计数据 stats

memfs: In-memory file-system with Node's fs API (opens in a new tab).

安装 webpack 和 memfs 包

npm install --save-dev webpack memfs
yarn add webpack memfs -D

编写 webpack 测试代码

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

这种情况下,我们可以内联 webpack 配置,也可以把配置作为参数传给导出的函数。这允许我们使用相同的编译模块测试多个设置 test 目录写在与src同一级上

编写jest.test.js 测试用例来添加 npm script 运行

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

运行测试用例

准备就绪后,我们可以运行它,然后看新的 loader 是否能通过测试:

  webpack git:(main)  yarn test
yarn run v1.22.19
$ jest
 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.

Jest Document (opens in a new tab)

2026 © Lizhenyui.