Skip to content

webpack的预处理器

一切皆模块

一个 Web 工程通常会包含 HTML、JS、CSS、模板、图片、字体等多种类型的静态资源,并且这些资源之间都存在着某种联系。比如,JS 文件之间有互相依赖的关系,在 CSS 中可能会引用图片和字体等。对于 Webpack 来说,所有这些静态资源都是模块,我们可以像加载一个 JS 文件一样去加载它们,如在 index.js 中加载 style.css:

js
// index.js
import "./style.css";

对于刚开始接触 Webpack 的人来说,可能会认为这个特性很神奇,甚至会觉得不解:从 JS 中加载 CSS 文件具有怎样的意义呢?从结果来看,其实和之前并没有什么差别,这个 style.css 可以被打包并生成在输出资源目录下,对 index.js 文件也不会产生实质性的影响。这句引用的实际意义是描述了 JS 文件与 CSS 文件之间的依赖关系

假设有这样一个场景,项目中的某个页面使用到了一个日历组件,我们很自然地需要将它加载进来,如:

js
// ./page/home/index.js
import Calendar from "./ui/calendar/index.js";

但是加载了其 JS 文件还不够,我们仍然需要引入 calendar 组件的样式。比如下面的代码(以 SCSS 为例):

js
// ./page/home/style.scss
@import './ui/calendar/style.scss';

而实际上,通过 Webpack 我们可以采用一种更简洁的方式来表达这种依赖关系。

js
// ./ui/calendar/index.js
import './style.scss'; // 引用组件自身样式
...
js
// ./page/home/index.js
import Calendar from "./ui/calendar/index.js";
import "./style.scss"; // 引用页面自身样式

可以看到,在 calendar 的 JS 中加载了其组件自身的样式,而对于页面来说只要加载 calendar/index.js 即可(以及页面自身的样式),不需要额外引入组件的样式。

  • JS 和样式分开处理的情况,我们需要分别维护组件 JS 和 SCSS 加载,每当我们添加或者删除一个组件的时候,都要进行两次操作:引入 JS、引入 SCSS 或者删除 JS、删除 SCSS。
  • 使用 Webpack 将 SCSS 通过 JS 来引入的情况,组件的 JS 和 SCSS 作为一个整体被页面引入进来,这样就更加清晰地描述了资源之间的关系。当移除这个组件时,也只要移除对于组件 JS 的引用即可。人为的工作总难免出错,而让 Webpack 维护模块间的关系可以使工程结构更加直观,代码的可维护性更强。
  • 另外,我们知道,模块是具有高内聚性及可复用性的结构,通过 Webpack“一切皆模块”的思想,我们可以将模块的这些特性应用到每一种静态资源上面,从而设计和实现出更加健壮的系统。

loader 概述

每个 loader 本质上都是一个函数。在 Webpack 4 之前,函数的输入和输出都必须为字符串;在 Webpack 4 之后,loader 也同时支持抽象语法树(AST)的传递,通过这种方法来减少重复的代码解析。用公式表达 loader 的本质则为以下形式:

output=loader(input)

这里的 input 可能是工程源文件的字符串,也可能是上一个 loader 转化后的结果,包括转化后的结果(也是字符串类型)、source map,以及 AST 对象;output 同样包含这几种信息,转化后的文件字符串、source map,以及 AST。如果这是最后一个 loader,结果将直接被送到 Webpack 进行后续处理,否则将作为下一个 loader 的输入向后传递。

下面来看一下 loader 的源码结构:

js
module.exports = function loader(content, map, meta) {
  var callback = this.async();
  var result = handler(content, map, meta);
  callback(
    null, // error
    result.content, // 转换后的内容
    result.map, // 转换后的 source-map
    result.meta // 转换后的 AST
  );
};

从上面代码可以看出,loader 本身就是一个函数,在该函数中对接收到的内容进行转换,然后返回转换后的结果(可能包含 source map 和 AST 对象)

loader 的配置

loader 的字面意思是装载器,在 Webpack 中它的实际功能则更像是预处理器。Webpack 本身只认识 JavaScript,对于其他类型的资源必须预先定义一个或多个 loader 对其进行转译,输出为 Webpack 能够接收的形式再继续进行,因此 loader 做的实际上是一个预处理的工作。

loader 的引入

与 loader 相关的配置都在 module 对象中,其中 module.rules 代表了模块的处理规则。每条规则内部可以包含很多配置项,这里我们只使用了最重要的两项—test 和 use。

  • test 可接收一个正则表达式或者一个元素为正则表达式的数组,只有正则匹配上的模块才会使用这条规则。在本例中以/.css$/来匹配所有以.css 结尾的文件。
  • use 可接收一个数组,数组包含该规则所使用的 loader。在 Webpack 打包时是按照数组从后往前的顺序将资源交给 loader 处理的,因此要把最后生效的放在前面。

loader options

loader 作为预处理器通常会给开发者提供一些配置项,在引入 loader 的时候可以通过 options 将它们传入。如:

js
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          "style-loader",
          {
            loader: "css-loader",
            options: {
              // css-loader 配置项
            },
          },
        ],
      },
    ],
  },
};

有些 loader 可能会使用 query 来代替 options,从功能来说它们并没有太大的区别,具体参阅 loader 本身的文档

exclude 与 include

exclude 与 include 是用来排除或包含指定目录下的模块,可接收正则表达式或者字符串(文件绝对路径),以及由它们组成的数组。请看下面的例子:

js
rules: [
    {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
        exclude: /node_modules/,
    }
],

上面 exclude 的含义是,所有被正则匹配到的模块都排除在该规则之外,也就是说 node_modules 中的模块不会执行这条规则。该配置项通常是必加的,否则可能拖慢整体的打包速度。

除 exclude 外,使用 include 配置也可以达到类似的效果。请看下面的例子:

js
rules: [
    {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
        include: /src/,
    }
],

include 代表该规则只对正则匹配到的模块生效。假如我们将 include 设置为工程的源码目录,自然而然就将 node_modules 等目录排除掉了。

exclude 和 include 同时存在时,exclude 的优先级更高。

resource 与 issuer

resource 与 issuer 可用于更加精确地确定模块规则的作用范围。请看下面的例子:

js
// index.js
import "./style.css";

在 Webpack 中,我们认为被加载模块是 resource,而加载者是 issuer。如上面的例子中,resource 为/path/of/app/style.css,issuer 是/path/of/app/index.js。 前面介绍的 test、exclude、include 本质上属于对 resource 也就是被加载者的配置,如果想要对 issuer 加载者也增加条件限制,则要额外写一些配置。比如,如果我们只想让/src/pages 目录下的 JS 可以引用 CSS,应该如何设置呢?请看下面的例子:

js
rules: [
    {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
        exclude: /node_modules/,
        issuer: {
            test: /\.js$/,
            include: /src/pages/,
        },
    }
],

上面的配置虽然实现了我们的需求,但是 test、exclude、include 这些配置项分布于不同的层级上,可读性较差。事实上我们还可以将它改为另一种等价的形式。

js
rules: [
    {
        use: ['style-loader', 'css-loader'],
        resource: {
            test: /\.css$/,
            exclude: /node_modules/,
        },
        issuer: {
            test: /\.js$/,
            exclude: /node_modules/,
        },
    }
],

通过添加 resource 对象来将外层的配置包起来,区分了 resource 和 issuer 中的规则,这样就一目了然了。上面的配置与把 resource 的配置写在外层在本质上是一样的,然而这两种形式无法并存,只能选择一种风格进行配置。

enforce

enforce 用来指定一个 loader 的种类,只接收“pre”或“post”两种字符串类型的值。 Webpack 中的 loader 按照执行顺序可分为 pre、inline、normal、post 四种类型,上面我们直接定义的 loader 都属于 normal 类型,inline 形式官方已经不推荐使用,而 pre 和 post 则需要使用 enforce 来指定。请看下面的例子:

js
rules: [
    {
        test: /\.js$/,
        enforce: 'pre',
        use: 'eslint-loader',
    }
],

可以看到,在配置中添加了一个 eslint-loader 来对源码进行质量检测,其 enforce 的值为“pre”,代表它将在所有正常 loader 之前执行,这样可以保证其检测的代码不是被其他 loader 更改过的。类似的,如果某一个 loader 是需要在所有 loader 之后执行的,我们也可以指定其 enforce 为“post”。

事实上,我们也可以不使用 enforce 而只要保证 loader 顺序是正确的即可。配置 enforce 主要的目的是使模块规则更加清晰,可读性更强,尤其是在实际工程中,配置文件可能达到上百行的情况,难以保证各个 loader 都按照预想的方式工作,使用 enforce 可以强制指定 loader 的作用顺序。

常用的 loader 简介

babel-loader

babel-loader 用来处理 ES6+并将其编译为 ES5,它使我们能够在工程中使用最新的语言特性(甚至还在提案中),同时不必特别关注这些特性在不同平台的兼容问题。

在安装时推荐使用以下命令:

sh
npm install babel-loader @babel/core @babel/preset-env

各个模块的作用如下。

  • babel-loader:它是使 Babel 与 Webpack 协同工作的模块。
  • @babel/core:顾名思义,它是 Babel 编译器的核心模块。
  • @babel/preset-env:它是 Babel 官方推荐的预置器,可根据用户设置的目标环境自动添加所需的插件和补丁来编译 ES6+代码。

在配置 babel-loader 时有一些需要注意的地方。请看下面的例子:

js
rules: [
  {
    test: /\.js$/,
    exclude: /node_modules/,
    use: {
      loader: 'babel-loader',
      options: {
        cacheDirectory: true,
        presets: [[
          'env', {
            modules: false,
          }
        ]],
      },
    },
  }
],
  1. 由于 babel-loader 通常属于对所有 JS 后缀文件设置的规则,所以需要在 exclude 中添加 node_modules,否则会令 babel-loader 编译其中所有的模块,这将严重拖慢打包的速度,并且有可能改变第三方模块的原有行为。
  2. 对于 babel-loader 本身我们添加了 cacheDirectory 配置项,它会启用缓存机制,在重复打包未改变过的模块时防止二次编译,同样也会加快打包的速度。cacheDirectory 可以接收一个字符串类型的路径来作为缓存路径,这个值也可以为 true,此时其缓存目录会指向 node_modules/.cache/babel-loader。
  3. 由于@babel/preset-env 会将 ES6 Module 转化为 CommonJS 的形式,这会导致 Webpack 中的 tree-shaking 特性失效。将@babel/preset-env 的 modules 配置项设置为 false 会禁用模块语句的转化,而将 ES6 Module 的语法交给 Webpack 本身处理。

babel-loader 支持从.babelrc 文件读取 Babel 配置,因此可以将 presets 和 plugins 从 Webpack 配置文件中提取出来,也能达到相同的效果。

ts-loader

ts-loader 与 babel-loader 的性质类似,它是用于连接 Webpack 与 Typescript 的模块。

html-loader

html-loader 用于将 HTML 文件转化为字符串并进行格式化,这使得我们可以把一个 HTML 片段通过 JS 加载进来。

自定义 loader

loader 初始化

有时会遇到现有 loader 无法很好满足需求的情况,这时就需要我们对其进行修改,或者编写新的 loader。

在开发一个 loader 时,我们可以借助 npm/yarn 的软链功能进行本地调试(当然之后可以考虑发布到 npm 等)。下面让我们初始化这个 loader 并配置到工程中。

创建一个 force-strict-loader 目录,然后在该目录下执行 npm 初始化命令。

sh
npm init –y

接着创建 index.js,也就是 loader 的主体。

js
module.exports = function (content) {
  var useStrictPrefix = "'use strict';\n\n";
  return useStrictPrefix + content;
};

现在我们可以在 Webpack 工程中安装并使用这个 loader 了。

sh
npm install <path-to-loader>/force-strict-loader

在 Webpack 工程目录下使用相对路径安装,会在项目的 node_modules 中创建一个指向实际 force-strict-loader 目录的软链,也就是说之后我们可以随时修改 loader 源码并且不需要重复安装了。

下面修改 Webpack 配置

js
module: {
  rules: [
    {
      test: /\.js$/,
      use: "force-strict-loader",
    },
  ];
}

我们将这个 loader 设置为对所有 JS 文件生效。此时对该工程进行打包,应该可以看到 JS 文件的头部都已经加上了启用严格模式的语句。

启用缓存

当文件输入和其依赖没有发生变化时,应该让 loader 直接使用缓存,而不是重复进行转换的工作。在 Webpack 中可以使用 this.cacheable 进行控制,修改我们的 loader。

js
// force-strict-loader/index.js
module.exports = function (content) {
  if (this.cacheable) {
    this.cacheable();
  }
  var useStrictPrefix = "'use strict';\n\n";
  return useStrictPrefix + content;
};

通过启用缓存可以加快 Webpack 打包速度,并且可保证相同的输入产生相同的输出。

获取 options

前文讲过,loader 的配置项通过 use.options 传进来,如:

js
rules: [
    {
        test: /\.js$/,
        use: {
            loader: 'force-strict-loader',
            options: {
                sourceMap: true,
            },
        },
    }
],

上面我们为 force-strict-loader 传入了一个配置项 sourceMap,接下来我们要在 loader 中获取它。首先需要安装一个依赖库 loader-utils,它主要用于提供一些帮助函数。在 force-strict-loader 目录下执行以下命令:

sh
npm install loader-utils

接着更改 loader。

js
// force-strict-loader/index.js
var loaderUtils = require("loader-utils");
module.exports = function (content) {
  if (this.cacheable) {
    this.cacheable();
  }
  // 获取和打印 options
  var options = loaderUtils.getOptions(this) || {};
  console.log("options", options);
  // 处理 content
  var useStrictPrefix = "'use strict';\n\n";
  return useStrictPrefix + content;
};

通过 loaderUtils.getOptions 可以获取到配置对象,这里我们只是把它打印了出来。下面我们来看如何实现一个 source-map 功能。

source-map

source-map 可以便于实际开发者在浏览器控制台查看源码。如果没有对 source-map 进行处理,最终也就无法生成正确的 map 文件,在浏览器的 dev tool 中可能就会看到错乱的源码。

下面是支持了 source-map 特性后的版本:

js
// force-strict-loader/index.js
var loaderUtils = require("loader-utils");
var SourceNode = require("source-map").SourceNode;
var SourceMapConsumer = require("source-map").SourceMapConsumer;
module.exports = function (content, sourceMap) {
  var useStrictPrefix = "'use strict';\n\n";
  if (this.cacheable) {
    this.cacheable();
  }
  // source-map
  var options = loaderUtils.getOptions(this) || {};
  if (options.sourceMap && sourceMap) {
    var currentRequest = loaderUtils.getCurrentRequest(this);
    var node = SourceNode.fromStringWithSourceMap(
      content,
      new SourceMapConsumer(sourceMap)
    );
    node.prepend(useStrictPrefix);
    var result = node.toStringWithSourceMap({ file: currentRequest });
    var callback = this.async();
    callback(null, result.code, result.map.toJSON());
  }
  // 不支持source-map情况
  return useStrictPrefix + content;
};

首先,在 loader 函数的参数中我们获取到 sourceMap 对象,这是由 Webpack 或者上一个 loader 传递下来的,只有当它存在时我们的 loader 才能进行继续处理和向下传递。

之后我们通过 source-map 这个库来对 map 进行操作,包括接收和消费之前的文件内容和 source-map,对内容节点进行修改,最后产生新的 source-map。

在函数返回的时候要使用 this.async 获取 callback 函数(主要是为了一次性返回多个值)。callback 函数的 3 个参数分别是抛出的错误、处理后的源码,以及 source-map。

小结

loader 就像 Webpack 的翻译官。Webpack 本身只能接受 JavaScript,为了使其能够处理其他类型的资源,必须使用 loader 将资源转译为 Webpack 能够理解的形式。

在配置 loader 时,实际上定义的是模块规则(module.rules),它主要关注两件事:该规则对哪些模块生效(test、exclude、include 配置),使用哪些 loader(use 配置)。loader 可以是链式的,并且每一个都允许拥有自己的配置项。

loader 本质上是一个函数。第一个 loader 的输入是源文件,之后所有 loader 的输入是上一个 loader 的输出,最后一个 loader 则直接输出给 Webpack。

备案号:闽ICP备2024028309号-1