webpack Loader 执行机制解析

839 阅读3分钟

loader 的本质

loader 就是导出了函数的 JS 模块,它会返回处理过后的结果给下一个 loader

// ./loaders/myLoader.js
function myLoader(content, map, meta) {
    //自定义 loader 代码
}

//导出 myLoader 函数
module.exports = myLoader

上面的代码就是一个自定义 loader,它接受三个参数,其中最重要的是 content 参数:

  • content:前一个 loader 处理后的结果,如果是当前 loader 是第一个执行的,content 就是某个模块的源代码
  • map:可以被 sourceMap 使用的数据
  • meta:任意的数据内容

这里只需要关注 content 就行了

webpack 中使用自定义 loader

使用自定义 loader 有三种方式:

方式一:配置 loader 绝对路径

在使用自定义 loader 的时候就指定自定义loader 的绝对路径

const path = require("path");

module.exports = {
  // ...
  
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
             // 以绝对路径的形式匹配自定义 loader
             loader: path.resolve(__dirname, "./loaders/loader1.js"),
          },
        ],
      },
    ],
  },
};

方式二:resolveLoader.alias 别名的形式

在 webpack 配置文件中,配置 resolveLoader.alias

const path = require("path");

module.exports = {
  // ...
  
  // 给自定义 loader 取别名
  resolveLoader: {
    alias: {
      loader1: path.resolve(__dirname, "./loaders/loader1.js"),
    },
  },
  
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
             loader: "loader1",
          },
        ],
      },
    ],
  },
};

这种形式当自定义loader 多了后,每使用一个自定义 loader 都要别名一次,太复杂,所以不推荐

方式三(推荐):resolveLoader.modules 匹配规则

在 webpack 配置文件中,配置 resolveLoader.modules 字段指定 loader 的匹配规则

const path = require("path");

module.exports = {
  // ...
  
  // 配置 loader 匹配规则
  resolveLoader: {
    //找 loader 时,先去 loaders 目录下找,第三方 loader 会自动去 node_modules 下找
    modules: ["loaders", "node_modules"],
  },
  
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
             loader: "loader1",
          },
        ],
      },
    ],
  },
};

loader 的分类

loader 的类型分为四种:

  • pre(前置)
  • normal(普通)
  • inline(行内)
  • post(后置)

什么意思?其实 loader 的类型跟 enforce 字段有关,比如当我设置 enforce: "pre" 的时候,此时所使用的 loader 的类型就成了 pre(前置) loader,同理 post(后置),没有设置 enforce 时,就是 normal loader

而对于 inline loader,它的配置方式是:

// inline loader 通过 loader名 + '!' 的形式
import xxx from "inline-loader1!inline-loader2!/src/xxx.js";

它表示:用 inline-loader1inline-loader2 去解析 xxx.js 文件

loader 执行时的顺序

我们知道,当用多个 loader 去解析某个类型的文件时,会按照 从右到左,从下到上的顺序执行 loader,那为什么会这样呢?

loader 的两个阶段

首先,loader 分为两个阶段:

  • Pitching 阶段:也就是去执行 loader 身上的 pitch 方法loader.pitch 可以有返回值,会按照 post(后置)、inline(行内)、noraml(普通)、pre(前置) 的类型顺序调用 loader

    function loader1(content) {
        return content
    }
    //其实就是去执行 loader1 身上的 pitch 方法
    loader1.pitch = function() {
        console.log(' loader1 的 pitching 阶段')
    }
    
  • normal 阶段:也就是去执行 loader 本身这个函数,会按照 pre(前置)、noraml(普通)、inline(行内)、noraml(普通)、post(后置) 的类型顺序调用 loader,模块源码的转换发生在这个阶段,也就是上面 return content 的这个地方

  • 然后,对于同类型的 loader,他们的顺序才会按照从右往左,从下到上的顺序

注意:在 Loader 的运行过程中,如果发现该 Loader 上有 pitch 属性,会先执行 pitch 阶段,再执行 normal 阶段

举个例子:

例子1:

  1. 首先我先写三个自定义 loader1、loader2、loader3

     // ./loaders/loader1.js
     function loader1(content, map, meta) {
       console.log("=========loader1 的 normal 阶段=========")
       return content + "//(loader1)";
     }
    
     loader1.pitch = function() {
       console.log("=========loader1 的 pitching 阶段=========")
     }
     
     module.exports = loader1;
    
    // ./loaders/loader2.js
    function loader2(content, map, meta) {
      console.log("=========loader2 的 normal 阶段=========")
      return content + "//(loader2)";
    }
    
    loader2.pitch = function() {
      console.log("=========loader2 的 pitching 阶段=========")
    }
    
    module.exports = loader2;
    
    // ./loaders/loader3.js
    function loader3(content, map, meta) {
      console.log("=========loader3 的 normal 阶段=========")
      return content + "//(loader3)";
    }
    
    loader3.pitch = function() {
      console.log("=========loader3 的 pitching 阶段=========")
    }
    
    module.exports = loader3;
    
  2. 配置 webpack.config.js

    const path = require("path");
    const HtmlWebpackPlugin = require("html-webpack-plugin");
    
    module.exports = {
      entry: "./src/main.js",
      output: {
        path: path.resolve(__dirname, "dist"),
        filename: "main.js",
      },
      resolveLoader: {
        modules: ["loaders", "node_modules"],
      },
      module: {
        rules: [
          {
            test: /\.js$/,
            //这里,loader1、loader2、loader3 都是 normal loader
            use: [
              "loader1",
              "loader2",
              "loader3",
            ],
          },
        ],
      },
      plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })],
      mode: "development",
      devtool: "source-map",
    };
    
  3. 根据上面的配置,执行 npm run build,我们发现控制打印的是:

image.png

我们来分析一波:

我们在 wepack.config.js 中配置 loader 时,loader1、loader2、loader3 都是 normal loader(因为没有设置 enforce 字段时默认为 normal),根据前面我们说的 webpack 会根据 loader 的类型和阶段来决定 loader 的执行顺序,所以它的流程如下:

  • 根据配置的顺序执行 loader
  • 发现每一个 loader 都有 pitch 属性,所以都会执行 Pitching 阶段,按照 post(后置)、inline(行内)、noraml(普通)、pre(前置) 的顺序执行
  • 依次输出 loader1-Pitching、loader2-Pitching、loader3-Pitching
  • 然后,执行 Normal阶段,会按照 pre(前置)、noraml(普通)、inline(行内)、noraml(普通)、post(后置) 的顺序执行,又因为是同类型的,所以会按照 从右往左,从后往前 的顺序执行
  • 依次输出 loader3-Normal、loader2-Normal、loader1-Normal

图解如下:

image.png

那如果我们配置 enforce,人为干预 loader 的执行顺序呢?

例子2:

// 更改 webpack.config.js 的配置
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: "./src/main.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "main.js",
  },
  resolveLoader: {
    modules: ["loaders", "node_modules"],
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          "loader1",
        ],
        enforce: 'pre' // loader1 是 preLoader
      },
      {
        test: /\.js$/,
        use: [
          "loader2", // loader2 是 normalLoader
        ],
      },
      {
        test: /\.js$/,
        use: [
          "loader3",
        ],
        enforce: 'post' // loader3 是 postLoader
      },
    ],
  },
  plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })],
  mode: "development",
  devtool: "source-map",
};

我们通过 enforce,将 loader1、loader2、loader3 的类型改变后,执行 npm run build,控制台打印结果如下:

image.png

再来分析一波流程:

  • 根据配置的顺序执行 loader
  • 每个 loader 都有 pitch 属性,所以会执行 Pitching 阶段,根据 post > inline > normal > pre 的顺序执行,此时 loader3 是 postLoader 先输出,loader2是 normalLoader 第二个输出,loader1 是 preLoader 最后一个输出
  • 然后执行 Normal 阶段,根据 pre > normal > inline > post 的顺序执行,loader1 是 preLoader 最先输出,loader2 是 normalLoader 第二个输出,loader3 是 postLoader 最后输出

图解如下:

image.png

所以,loader 的执行顺序我们现在就很清楚了:

  • Pitching 阶段: 调用 loader.pitch 方法,该方法可以有返回值,按照 后置(post) > 行内(inline) > 普通(normal) > 前置(pre) 的顺序调用。

  • Normal 阶段: 执行 loader 本身函数,按照 前置(pre) > 普通(normal) > 行内(inline) > 后置(post) 的顺序调用。模块源码的转换, 发生在这个阶段

image.png

Pitching 有返回值的情况

前面说了,loader.pitch 方法可以有返回值,如果有返回值的时候会怎么样呢?

我们把前面的 例子2 中的 loader2 修改一下:

function loader2(content, map, meta) {
  console.log("=========loader2 的 normal 阶段=========")
  return content + "//(loader2)";
}

loader2.pitch = function() {
  console.log("=========loader2 的 pitching 阶段=========")
  return 'Jolyne'
}

module.exports = loader2

然后执行 npm run build,控制台输出如下:

image.png

来分析一波流程:

  • 首先都有 pitch,走 Pitching 阶段,按照 post > inline > normal > pre 的顺序执行 loader
  • 执行到 loader2 时,它的 pitch 方法 return 'Joylne'
  • 跳回到 loader3,执行 loader3 的 Normal 阶段
  • 所有 loader 执行完毕

经过分析我们可以得出结论:

在 Pitching 阶段,如果当前 Loader.pitch 有返回值,就直接结束当前 loader 的 Pitching 阶段,并直接跳到当前 Loader 执行 pitching 阶段时的 前一个 loader 的normal 阶段,然后继续执行(若无前置 loader,则直接返回)

图解如下:

image.png

inlineLoader 的过滤运算符

  • !前缀:排除 normal Loader
  • !!前缀:只使用 inline Loader,其他类型的 loader 被禁用
  • -!前缀:排除 pre Loader、normal Loader

就拿前面的例子来看:比如 loader1 是 preLoader,loader2 是 normal Loader,loader3 是 post Loader 时:

  1. !前缀

    //webpack.config.js
    module.exports = {
        //...
        module: {
            rules: [
              {
                test: /\.js$/,
                use: [
                  "loader1",
                ],
              },
              {
                test: /\.js$/,
                use: [
                  "loader3",
                ],
                enforce: 'post'
              },
            ],
      },
    }
    
    // ./src/a.js
    const print = () => {
      console.log("Jolyne");
    };
    
    export default print;
    
    // ./src/main.js
    import print from "!loader2!./a"; // 这里使用 !前缀,排除掉 normal loader
    
    const a = 1;
    print();
    

    执行 npm run build,控制台输出如下:

    image.png

    我们发现,确实排除了 loader1

    图解如下:

    image.png

  2. !!前缀

    // ./src/main.js
    import print from "!!loader2!./a"; // 这里使用 !!前缀,只使用 inline loader
    
    const a = 1;
    print();
    

    打包后,控制台输出如下:

    image.png

    图解如下: image.png

  3. -!前缀

    import print from "!!loader2!./a"; // 这里使用 -!前缀,排除 preLoader、normal Loader
    
     const a = 1;
     print();
    

    打包后,控制台输出如下:

    image.png

    图解如下:

    image.png

总结

  • loader 本质是一个 导出结果为函数的 JS 模块,它可以接受前面的 loader 处理后的结果作为参数,能返回处理后的结果

  • loader 根据 enforce 的配置,分为 pre(前置)normal(普通)inline(行内)、post(后置)四个类型

  • loader 的执行阶段分为两个:Pitching阶段、Normal阶段

    • Pitching阶段:就是执行 loader 的 pitch 方法,它可以有返回值,如果有返回值,就直接结束当前 loader 的 Pitching 阶段,并直接跳到当前 Loader 执行 pitching 阶段时的 前一个 loader 的normal 阶段,然后继续执行(若无前置 loader,则直接返回),该阶段按照 post > inline > normal > pre 的顺序执行
    • Noraml阶段:也就是去执行 loader 本身这个函数,模块源码的转换发生在这个阶段,该阶段按照 pre > normal > inline > post 的顺序执行
  • inline Loader 有三种前缀修饰符

    • !前缀:排除 normal Loader
    • !!前缀:只使用 inline Loader,其他类型的 loader 被禁用
    • -!前缀:排除 pre Loader、normal Loader