kangyana / daily-question

When your heart is set on something, you get closer to your goal with each passing day.
https://www.webpack.top
MIT License
3 stars 0 forks source link

【Q091】loader #91

Open kangyana opened 2 years ago

kangyana commented 1 year ago

1. loader

loader 用于对 模块源代码 进行转换。 loader 可以使你在 import加载模块 时预处理文件。 因此,loader 类似于其他构建工具中 task,并提供了处理前端构建步骤的得力方式。 loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript 或将内联图像转换为 data URLloader 甚至允许你直接在 JavaScript 模块中 import CSS 文件!

2. 示例

例如,你想要加载 CSS 文件、讲TypeScript 转为 JavaScript。 为此,首先安装相对应的 loader:

npm install --save-dev css-loader ts-loader

然后对每个 .css 文件 使用 css-loader,对每个 .ts 文件使用 ts-loader

// webpack.config.js
module.exports = {
  module: {
    rules: [
      { test: /\.css$/, use: 'css-loader' },
      { test: /\.ts$/, use: 'ts-loader' },
    ],
  },
};

3. 使用 loader

loader 有两种使用的方式:

配置方式 Configuration

module.rules 允许你在 webpack 配置中指定多个 loader。 这种方式是展示 loader 的一种简明方式,并且有助于使代码变得简洁和易于维护。

loader 的执行顺序是从上到下。

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          // [style-loader](/loaders/style-loader)
          { loader: 'style-loader' },
          // [css-loader](/loaders/css-loader)
          {
            loader: 'css-loader',
            options: {
              modules: true
            }
          },
          // [sass-loader](/loaders/sass-loader)
          { loader: 'sass-loader' }
        ]
      }
    ]
  }
};

内联方式

可以在 import 语句或任何 与 "import" 方法同等的引用方式 中指定 loader。 使用 ! 将资源中的 loader 分开。每个部分都会相对于当前目录解析。

import Styles from 'style-loader!css-loader?modules!./styles.css';

通过为内联 import 语句添加前缀,可以覆盖 configuration 中的所有 loader, preLoaderpostLoader

注意:尽可能使用 module.rules,这样可以减少源码中样板文件的代码量。并且可以在出错时快速定位 loader 的问题。

kangyana commented 1 year ago

4. loader 特性

可以通过 loader 的预处理函数,进行压缩、打包、编译等功能。

5. 解析 loader

loader 遵循标准 模块解析 规则。 多数情况下,loader 将从 模块路径 加载(通常是从 npm install, node_modules 进行加载)。

预期 loader 模块导出为一个函数,并且编写为 Node.js 兼容的 JavaScript。 通常使用 npm 进行管理 loader,但是也可以将应用程序中的文件作为自定义 loader。 按照约定,loader 通常被命名为 xxx-loader(例如 json-loader)。

kangyana commented 1 year ago

6. 编写 loader

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

匹配 loader

匹配单个 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: {},
          },
        ],
      },
    ],
  },
};

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

// webpack.config.js

const path = require('path');

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

简单用法

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

同步 loader 可以 return 一个代表已转换模块的单一值。 在更复杂的情况下,loader 也可以通过使用 this.callback(err, values...) 函数,返回任意数量的值。 错误要么传递给这个 this.callback 函数,要么抛给同步 loader

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

复杂用法

当链式调用多个 loader 的时候,请记住它们是倒序执行的。

最后的 loader 最早调用,将会传入原始资源内容。 第一个 loader 最后调用,期望值是传出 JavaScript 和 source map(可选)。 中间的 loader 执行时,会传入前一个 loader 的结果。

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

// webpack.config.js

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

用法准则

编写 loader 时应该遵循以下准则:

简单

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

链式

利用 loader 可以链式调用的优势。 写五个简单的 loader 实现五项任务,而不是一个 loader 实现五项任务。 功能隔离不仅使 loader 更简单,还利于后续维护与开发。

以通过 loader 选项或者查询参数得到的数据渲染模板为例。 可以把源代码编译为模板,执行并输出包含 HTML 代码的字符串写到一个 loader 中。 但是根据用法准则,已经存在这样一个 apply-loader,可以将它和其他开源 loader 串联在一起调用。

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

模块化

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

无状态

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

loader 工具库

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

schema-utils 包配合 loader-utils,用于保证 loader 选项,进行与 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('请求文件路径:', urlToRequest(this.resourcePath));

  // 对资源应用一些转换……

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

loader 依赖

如果一个 loader 使用外部资源(例如,从文件系统读取),必须声明它。 这些信息用于使缓存 loaders 无效,以及在 观察模式 下重编译。 下面是一个简单示例,说明如何使用 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(...) 语句来声明依赖。 这些依赖关系应该由模块系统解析。

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

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

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

通用代码

避免在 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(),
  })}`;
}

绝对路径

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

同一个依赖

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

例如,sass-loader 指定 node-sass 作为同等依赖,引用如下: saas-loader/package.json

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

测试

我们将使用 Jest 框架。 然后还需要安装 babel-jest 和允许我们使用 import / exportasync / await 的一些预设环境(presets)。 让我们开始安装,并且将这些依赖保存为 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',
        },
      },
    ],
  ],
};

我们的 loader 将会处理 .txt 文件,并且将任何实例中的 name 直接替换为 loader 选项中设置的 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)}`;
}

使用memfs 去执行 webpack

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

注意:这种情况下,我们可以内联 webpack 配置,也可以把配置作为参数传给导出的函数。这允许我们使用相同的编译模块测试多个设置。

最后,我们来编写测试,并且添加 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 是否能通过测试:

 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.