FrankKai / FrankKai.github.io

FE blog
https://frankkai.github.io/
362 stars 39 forks source link

[译]如何写一个webpack Loader? #263

Open FrankKai opened 2 years ago

FrankKai commented 2 years ago

原文:https://webpack.js.org/contribute/writing-a-loader/#setup demo仓库:https://github.com/FrankKai/webpack-loader-demo

什么是loader

loader是导出了一个函数的node模块。 这个函数会在资源被这个loader转换时,调用。这个函数,通过this上下文,拥有Loader的API。

安装

在我们深入不同类型的loader之前,它们的用法,它们的示例前,先看看你可以在本地开发和测试的3种方式。

为了测试单个loader,你可以在rule对象中,使用path去解析一个本地文件:

// webpack.config.js
const path = require('path');

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

如果想测试多个文件,可以利用resolveLoader.modules配置去升级webpack搜索多个loader。例如,如果你有本地的/loaders目录:

// webpack.config.js
const path = require('path');

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

如果拆了仓库或者包出去,可以使用npm link测试。

简单用法

当一个loader应用到资源时,loader只会接收一个参数被调用-这个参数是一个包含了资源文件内容的字符串

同步loader可以返回一个单值,这个值代表转化后的模块。在更复杂的场景中,loader可以通过this.callback(err, values...)函数返回任意数量的值。异常也可以传。

loader需要一个或者两个值。第一个值是string或者buffer类型的js代码。第二个值是SourceMap类型的js对象。

复杂用法

当多个loader链式处理时,有一点很重要,他们会以逆序执行,从右到左还是从下到上取决于数组的格式。

在下面这个例子中,foo-loader会被传入raw(生的)资源,并且bar-loader会接收foo-loader的输出,并且返回最终转化后的模块和source map。

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

指南

指南部分是写loader的详细部分。根据重要性排序,有一些仅在特定场景下使用,阅读详细章节去获得更多信息。

简约

loader应该只做一个任务。这不仅仅会让维护每个loader变得更容易,同样也可以允许它们被任意链式组合,在更多场景使用。

链式转化

用加载器可以链接在一起这一事实。不要在一个loader里对付5个任务,而是拆成5个简单loader各自分工。隔离它们不仅保持单个独立loader的简洁性,而且允许它们在一些你没有预料到的场景中使用。

假设现在有一个场景,通过loader选项或者查询参数渲染一个模板文件。可以通过写一个单loader,通过资源编译模板,执行它,并且返回一个模块,这个模块导出一个包含了HTML代码的字符串。

但是,根据指南,存在可以与其他开源加载器链接的应用加载器:

模块化

将输出模块化。loader生成的模块,需要尊重和常规模块一样的设计原则。

无状态

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

loader工具库

loader-utils提供了一系列有用的工具。schema-utils可用于对持续的用于loader配置的JSON Schema做校验。这里有一个简单的使用utilizes的例子:

// loader.js
import { urlToRequest } from 'loader-utils';
import { validate } from 'schema-utils';

const shema = {
    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 requrest path", urlToRequest(this.resourcePath));

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

loader 依赖

如果一loader使用了额外的资源,必须要标明它。此信息用于使可缓存加载程序无效并在监视模式下重新编译。这里有一个使用addDependency方法来添加loader依赖的例子:

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

模块依赖

取决于模块的类型,可以使用不同的schema去指明依赖。例如在CSS,@import和url(...)语句会用到。这些依赖应该被模块系统解析。

有下面两种实现方式:

css-loader就是第一种方法的典型例子。它将依赖转换为requires,通过替换@import语句为require另一个样式表以及url(...)也通过require去引用文件。

对于less-loader,它不能加个每个@import转换为require,因为所有的.less文件必须被编译一次,从而进行变量和mixin追踪。因此,less-loader使用自定义的路径解析逻辑,去扩展了less编译器。就是利用第二种方式,this.resolve去实现的,通过webpack去解析依赖。

抽离 通用代码

避免在每一个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);
}

铺垫了这么多,终于可以写loader了

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

export default function loader(source) {
    // 自定义的loader逻辑
    return ``${runtime({
        source,
        y: Math.random(),
    })}
}

绝对路径

不要插入绝对路径,因为如果项目根路径发生变化时,会导致哈希崩溃。有一个叫做stringifyRequest方法在loader-utils中,可用于转换绝对路径为相对路径。

对等依赖

如果loader是另一个包的简单包装器,你需要将这个包裹作为对等依赖。这个方法允许应用的开发者在package.json去声明精确的版本。

例如sass-loader声明了node-sass为对等依赖

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

测试

使用Jest测试loader,并且使用babel-jest是的我们可以用import/export以及async/await。

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

我们的loader将处理.txt类型的文件并且通过loader的name option去替换任意的[name]示例。然后它将输出一个包含了default export文本的有效的js模块:

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

我们将用这个loader去处理下面这个文件:

test/example.txt

Hey [name]!

请注意,我们后面将使用nodejs以及memfs去运行webpack。这可以允许我们不将output输出到磁盘上并且去观察状态。

npm install --save-dev webpack memfs
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,
          }
        }
      ]
    }
  })

  // Volume可以理解为文件系统卷,内存上的文件系统卷
  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 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"
  }
}
> webpack-loader-demo@1.0.0 test
> jest

  console.log
    loader before: Hey [name]!

      at Object.log (src/loader.js:4:11)

  console.log
    loader after: Hey Alice!

      at Object.log (src/loader.js:8:11)

 PASS  test/loader.test.js
  ✓ Inserts name and outputs JavaScript (1226 ms)

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

成功了! 现在开始,你可以去开发、测试、部署你自己的loader。我们期待在社区中分享你的灵感和创造!