webpack系列之loader实现

262 阅读3分钟

webpack 是个很强大的构建工具,其丰富灵活的配置决定了使用也不简单。在面试中经常能遇到 webpack 相关的问题,如果平常只是使用脚手架如 vue-cli 而没有好好深入学习研究 webpack 的话,估计答不上什么。我相信,如果没有深入了解,部分面试官也问不出什么。可能也就变成了两个人侃侃如何配置出入口,常见的 loader,plugin 有哪些。

作为一名多年油条前端,一直没有正视 webpack 相关知识,面对 webpack 相关的面试题更是一问三不知。这次准备好好学习研究 webpack相关内容,并且将学习内容记录成 webpack 系列,希望可以让不了解 webpack 的小白能对其有所掌握。

编写一个loader

由于 webpack 只能处理 .js 文件,所以其它类型的文件(如 .vue,.css)需要使用 loader 将其转化为 JS 对象进行处理。当然还有其它一些功能,我们也会放在 loader 中实现,如将 ES6 编译成 ES5

webpack中lodaer的设置

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

lodaer的实现

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

loader 的第一个入参 source 为包含包含资源文件内容的字符串(类型为bufferstring),map 为资源的 sourceMap,meta 为元数据。

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

我们来编写个简单的lodaer,此 loader 帮助我们替换资源中的 [name] 占位符

// loader-utils包含一些lodaer工具函数
import { getOptions } from 'loader-utils';

export default function loader(source, map, meta) {
  const options = getOptions(this);

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

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

一般我们使用 return 来返回编译后的资源,如果需要返回其它值可以使用 callback

this.callback(err, source, map, meta);

其实写一个简单的 loader 真的非常简单,上面我们只用几行代码就实现了一个简单的 loader。但是像 css-loader babel-loader 的复杂度就高了,有兴趣的话可以去研究研究其源码实现。

如何编写一个 lodaer 已经写完了。下面是 loader 的编写准则,和如何对我们编写的 loader 进行单元测试,有兴趣的可以继续看下去。

loader的编写准则

编写 loader 时应该遵循以下准则。它们按重要程度排序,有些仅适用于某些场景。

  • 简单易用(Simple)

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

  • 使用链式传递(Chaining)

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

  • 模块化的输出(Modular)

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

  • 无状态(Stateless)

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

  • 使用 loader utilities

充分利用 loader-utils 包。它提供了许多有用的工具,但最常用的一种工具是获取传递给 loader 的选项。schema-utils 包配合 loader-utils,用于保证 loader 选项,进行与 JSON Schema 结构一致的校验。这里有一个简单使用两者的例子:

import { getOptions } from 'loader-utils';
import validateOptions from 'schema-utils';

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

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

  validateOptions(schema, options, 'Example Loader');

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

  return `export default ${ JSON.stringify(source) }`;
};
  • 记录 loader 的依赖

如果一个 loader 使用外部资源(例如,从文件系统读取),必须声明它。这些信息用于使缓存 loaders 无效,以及在观察模式(watch mode)下重编译。下面是一个简单示例,说明如何使用 addDependency 方法实现上述声明:

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 中,使用 @import 和 url(...) 语句来声明依赖。这些依赖关系应该由模块系统解析。

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

  • 通过把它们转化成 require 语句

  • 使用 this.resolve 函数解析路径

  • 提取通用代码

避免在 loader 处理的每个模块中生成通用代码。相反,你应该在 loader 中创建一个运行时文件,并生成 require 语句以引用该共享模块。

  • 避免绝对路径

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

  • 使用 peer dependencies

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

例如,sass-loader 指定 node-sass 作为同等依赖,引用如下:

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

测试

我们使用 jest 来测试我们编写的 loader

在 package.json 中添加测试命令

"scripts": {
  "test": "jest"
}

安装相关依赖

npm i npm install --save-dev jest babel-jest babel-preset-env @babel/preset-env @babel/plugin-transform-runtime

配置 .babelrc

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "browsers": [
            "last 2 versions"
          ]
        }
      }
    ]
  ],
  "plugins": [
    "@babel/plugin-transform-runtime"
  ]
}

添加测试目录

test
  compiler.js
  example.txt
  loader.test.js

compiler.js 添加webpack配置及运行编译

import path from 'path';
import webpack from 'webpack';
import memoryfs from 'memory-fs';

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, '../loader/lower.js'),
          options: {
            name: 'Alice'
          }
        }
      }]
    }
  });

  compiler.outputFileSystem = new memoryfs();

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

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

example.txt 添加编译前内容

Hey [name]!

lodaer.test.js 为单元测试代码

import compiler from './compiler.js';

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

  expect(output).toBe(`export default "Hey Alice!"`);
});

接下来运行 npm 命令即可检验我们编写的 loader 了

npm run test

参考


欢迎到前端菜鸟群一起学习交流~516913974