likes
comments
collection
share

webpack Loader核心原理解析

作者站长头像
站长
· 阅读数 17

配置过webpack的朋友应该都知道webpack中有一个loader的概念,当webpack进行编译时,会从入口文件出发,调用所有配置的loader对模块进行编译。本文来简单梳理一下何为loader?loader有哪些类型以及loader具体运行机制是怎样的。

1. 何为loader

  • 在webpack内部只能识别javascript模块,其他类型的模块是处理不了的。loader其实就充当着翻译官的角色,可以用来处理非js模块。

  • loader的本质是一个导出为函数的JavaScript 模块,它接收上一个loader产生的结果或资源文件作为入参。也可以用多个loader函数组成loader chain。

2. 如何配置loader

在webpack配置文件中,我们可以通过module中的rules字段进行loader配置

// webpack.config.js

module: {
    rules: [
      {
        test: /.less$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              url: true,
              import: true,
              modules: false,
            },
          },
          'less-loader',
        ],
      },
    ],
  }

这个是一个针对.less文件的loader经典配置。

2.1 参数说明

2.1.1 test

一个正则表达式,用来匹配资源文件名称,如果命中此规则,则交由配置的loader进行处理。

2.1.2 use

如果是一个数组,表示匹配到的资源文件用这些loader进行处理。use中的每一项可以是一个字符串,这代表着一个loader的名字,比如上面的less-loader。如果你想针对loader传入一些配置项,则需要将其配置为一个对象形式,loader字段对应loader的名字,options字段则为传入的配置项,如上css-loader。

如果某种资源文件只需要使用一个loader,你也可以将其配置成一个loader名称字符串,如 loader: 'css-loader'

配置的loaders默认执行顺序是从下往上。如上配置的loader的执行顺序为less-loader -> css-loader -> style-loader。当然了你也可以通过一个enforce字段标明。

2.1.3 enforce

标明loader的执行顺序。enfore有两个值分别为prepost

  • 当loader rule中没有配置enfore时,默认为normal loader(正常loader)
  • 当loader rule中配置enfore: 'pre'时,我们称之为pre loader(前置loader)
  • 当loader rule中配置enfore: 'post'时,我们称之为post loader(后置loader)

在正常情况下,这三种loader的执行顺序为

resource file
pre loader
normal loader
post loader

为什么要强调正常情况下呢?肯定是有非正常情况嘛,下文会提到

上面的配置等同于

module: {
    rules: [
      {
        test: /.less$/,
        use: 'less-loader',
        enforce: 'pre',
      },
      {
        test: /.less$/,
        use: 'css-loader',
      },
      {
        test: /.less$/,
        use: 'style-loader',
        enforce: 'post',
      },
    ],
  },

2.2 loader resolution

webpack是如何找到loader的呢?webpack配置loader的方式有三种:

2.2.1 绝对路径

当我们想配置自己实现的但未发布到npm的loader时该怎么办呢?方法一是写绝对路径标明loader的具体文件位置。比如

const path = require('path');

module.exports = {
  ...
  module: {
    rules: [
      {
        test: /.js$/,
        loader: path.resolve(__dirname, './my-loaders/babel-loader.js'),
      },
    ],
  }
}

我们在loader字段中配置了一个绝对路径,这样webpack就会去该路径找到对应的loader。

2.2.2 resolveLoader.alias

第二种方式是通过resolveLoader.alias来配置loader的别名,比如

const path = require('path');

module.exports = {
  ...
  resolveLoader: {
    alias: {
      'babel-loader': path.resolve(__dirname, 'my-loaders/babel-loader.js'),
    },
  },
  module: {
    rules: [
      {
        test: /.js$/,
        loader: 'babel-loader.js',
      },
    ],
  }
}

当webpack解析到需要使用babel-loader时,会查找resolveLoader.alias对应的绝对路径,从而找到对应的loader对应的js文件进行处理。

2.2.3 resolveLoader.modules

如果我们每当要配置一个loader时都要写绝对路径或者resolveLoader.alias,这是非常繁琐且冗余的,那有什么更好的方式呢?这个时候resolveLoader.modules就登场了。比如

const path = require('path');

module.exports = {
  ...
  resolveLoader: {
    modules: ['my-loaders', 'node_modules'],
  },
  module: {
    rules: [
      {
        test: /.js$/,
        loader: 'babel-loader',
      },
    ],
  }
}

我们将resolveLoader.modules配置为['my-loaders', 'node_modules'],在loader字段中就可以只写loader名字了。原因在于当webpack查找babel-loader时,会先从my-loader文件夹中查找是否存在babel-loader.js这个文件,如果存在就直接使用,如果不存在就接着从node_modules中查找是否已经安装了babel-loader。关于node如何查找模块的更多信息可以参考 node module resolution algorithm

resolveLoader.modules的默认配置是['node_modules'], 这也是为什么当我们安装了一些第三方loader后,可以直接写loader名字的原因。

3. Loader的类型

在上文中我们提到webpack中loader有pre、normal和post三种类型,其实webpack还支持一种特殊的loader,就是inline loader(内联loader),即在引入资源模块时在模块名前面加上loader,比如

import styles from 'style-loader!css-loader!less-loader!./index.less';

如上所示,在引入index.less文件时,在文件名前面加入了三个loader,用 ! 进行分隔,这种方式引入的loader称之为inline loader。

关于inline loader,还有一些特殊配置,可以在inline loader中加入以下前缀来进行一些配置,这样会影响配置中的pre loader、normal loader和post loader。

// 这里的意思是不使用配置文件中配置的任何less文件的loader, 只使用配置的内联loader
import styles from '!!style-loader!css-loader!less-loader!./index.less';
符号变量含义
-!noPreAutoLoaders不要前置和普通 loader
!noAutoLoaders不要普通 loader
!!noPrePostAutoLoaders不要前后置和普通 loader,只要内联 loader

正常loader的执行顺序

resource file
pre loader
normal loader
inline loader
post loader

4. Normal Loader 和 Pitch Loader

上边我们说到webpack loader存在四种类型,这里我们简单说一下何为Normal Loader,何为Pitch Loader

Normal loader本质就是loader函数本身,比如

function loader(source) {
    return source;
}
module.exports = myLoader

当在loader上添加pitch属性,且值也为一个函数时,这个loader就成为Pitch loader

function myLoader(source) {
  return source;
}

myLoader.patch = (remainingRequest, previousRequest, data) => {
  
}

module.exports = myLoader

我们可以理解为loader函数本身就是normal loader,而loader上的pitch属性就是pitch loader

5. Loader的执行顺序

上文我们说到loader有四种类型以及他们的执行顺序,其实loader有两个执行阶段,分别为 normal 阶段和 pitch 阶段。上面我们说的执行顺序其实是normal阶段的执行顺序。

  • 在处理资源文件前,会经历pitch阶段,这个阶段会执行所有的pitch loader
  • pitch阶段结束后读取原始资源文件内容
  • 然后经历normal阶段,这个阶段会执行所有的normal loader
  • 最后将编译好的模块传给webpack compilation

比如我们有loader1、loader2和loader3三个loader用来处理file.js文件,他们的执行顺序如下

webpack Loader核心原理解析

这里需要注意的是,normal阶段的最后一个loader一定要返回一串js代码,否则webpack将无法处理。在这个例子中,loader1函数必须要返回一串js代码。

5.1 Pitch loader 的熔断效果

上文我们说到loader的处理过程存在一个pitch阶段,那么为什么会存在这个阶段呢,其有什么作用呢?这里我们需要重点记住pitch loader的一个重要特性:

当pitch loader存在非undefined的返回值时,会跳过剩下的loader和读取文件资源,直接将返回值传入上一个normal loader中执行。如果是左边第一个pitch loader,则直接将返回值传给webpack。 这个特性被称之为pitch loader的熔断效果

比如当 loader2.pitch 存在非undefined的返回值时,loader的执行顺序为:

webpack Loader核心原理解析

可以看到当执行完loader2.pitch后,直接跳到了loader1执行,忽略了后面的loader3、资源文件读取和loader2 normal。

这里只需要记住pitch loader的特性即可,至于为什么要存在pitch loader,下文会讲。

6. Loader是如何被运行的

6.1 测试loader运行顺序

在loader执行之前,webpack会将所有的loader叠加好(或者说组装),然后通过loader-runner这个库依次执行叠加好的loader。

我们来简单模拟一下nomal阶段loader的叠加顺序。

创建一个webpack-loader项目,文件夹结构如下:

webpack Loader核心原理解析

src/index.js

module.exports = 'this is index.js';

loaders/inline1-loader.js

function loader(source) {
  console.log("inline1");
  return source + "//inline1";
}

module.exports = loader;

loaders/inline1-loader.js

function loader(source) {
  console.log("inline2");
  return source + "//inline2";
}

module.exports = loader;

loaders/normal1-loader.js

function loader(source) {
  console.log("normal1");
  return source + "//normal1";
}
module.exports = loader;

loaders/normal2-loader.js

function loader(source) {
  console.log("normal2");
  return source + "//normal2";
}

module.exports = loader;

loaders/post1-loader.js

function loader(source) {
  console.log("post1");
  return source + "//post1";
}

module.exports = loader;

loaders/post2-loader.js

function loader(source) {
  console.log("post2");
  return source + "//post2";
}

module.exports = loader;

loaders/pre1-loader.js

function loader(source) {
  console.log("pre1");
  return source + "//pre1";
}

module.exports = loader;

loaders/pre2-loader.js

function loader(source) {
  console.log("pre2");
  return source + "//pre2";
}

module.exports = loader;

通过runner.js来测试loader叠加和执行顺序

runnner.js

const { runLoaders } = require('loader-runner');
const path = require('path');
const fs = require('fs');

// 入口文件
const entryFile = path.resolve(__dirname, 'src/index.js');
// 配置inline loader
const request = `inline1-loader!inline2-loader!${entryFile}`;

// 配置文件中的loader
// 是不是pre或post跟loader本身没有关系,和你写在配置文件里的enforce的值有关系
const rules = [
  {
    test: /.js$/,
    use: ['normal1-loader', 'normal2-loader'],
  },
  {
    test: /.js$/,
    enforce: 'pre',
    use: ['pre1-loader', 'pre2-loader'],
  },
  {
    test: /.js$/,
    enforce: 'post',
    use: ['post1-loader', 'post2-loader'],
  },
];

const parts = request.replace(/^-?!+/, '').split('!');
// 真正的模块路径
const resource = parts.pop();
// 所有的inline loader
const inlineLoaders = parts;
// 存放pre loader
const preLoaders = [],
// 存放post loader
const postLoaders = [],
// 存放normal loader      
const normalLoaders = [];

for (let i = 0; i < rules.length; i++) {
  let rule = rules[i];
  if (rule.test.test(resource)) {
    if (rule.enforce === 'post') {
      postLoaders.push(...rule.use);
    } else if (rule.enforce === 'pre') {
      preLoaders.push(...rule.use);
    } else {
      normalLoaders.push(...rule.use);
    }
  }
}

let loaders = [];

// 解析特殊配置
if (request.startsWith('!!')) {
  //noPreAutoLoaders  不要前置和普通 loader
  loaders.push(...inlineLoaders);
} else if (request.startsWith('-!')) {
  //noAutoLoaders 不要前置和普通 loader
  loaders.push(...postLoaders, ...inlineLoaders);
} else if (request.startsWith('!')) {
  // noAutoLoaders 不要普通 loader
  loaders.push(...postLoaders, ...inlineLoaders, ...preLoaders);
} else {
  // 没有特殊配置即全都要
  loaders.push(...postLoaders, ...inlineLoaders, ...normalLoaders, ...preLoaders);
}

// 把loader数组从名称变成绝对路径
loaders = loaders.map(loader => path.resolve(__dirname, 'loaders', loader));
runLoaders(
  {
    resource, //你要加载的资源
    loaders,
    context: { name: "xiao", age: 100 }, // 保存一些状态和值
    readResource: fs.readFile.bind(this), // 使用什么来读取原始文件内容
  },
  (err, result) => {
    console.log(result); //运行的结果
  }
);

可以看到loader的叠加顺序为: post(后置)+inline(内联)+normal(正常)+pre(前置),可以简单记为 厚脸挣钱😅

运行runner.js

webpack Loader核心原理解析

可以看到loader的执行顺序确实是和我们前面说的normal 阶段一样。

webpack Loader核心原理解析

现在修改normal-loader1, 加入pitch函数:

src/normal1-loader.js

function loader(source) {
  console.log('normal1');
  return source + '//normal1';
}
loader.pitch = function () {
  return 'normal1pitch';
};
module.exports = loader;

再次执行runner.js

webpack Loader核心原理解析

可以看到,确实发生了熔断效果

6.2 loader runner简单实现

下面我们来简单实现loader runner,其主要流程如下

webpack Loader核心原理解析

webpack Loader核心原理解析

loader-runner.js

const fs = require('fs');

// 根据loader模块的绝对路径得到loader对象
function createLoaderObject(loader) {
  const normal = require(loader);
  const pitch = normal.pitch;
  return {
    path: loader, // loader的绝对路径
    normal,
    pitch,
    raw: normal.raw, // 决定normal函数的参数是字符串还是Buffer
    data: {}, // 每个loader对象都会有一个自定义data对象
    pitchExecuted: false, // 标识此loader的pitch函数是否已经执行过
    normalExecuted: false, // 标识此loader的normal函数是否已经执行过
  };
}

// 处理资源文件
function processResource(processOptions, loaderContext, pitchingCallback) {
  processOptions.readResource(loaderContext.resource, (err, resourceBuffer) => {
    processOptions.resourceBuffer = resourceBuffer;
    loaderContext.loaderIndex--; // 减1会后会指向最后一个loader
    // 开始迭代执行normal loader
    iterateNormalLoaders(processOptions, loaderContext, [resourceBuffer], pitchingCallback);
  });
}

// 转换参数
function convertArgs(args, raw) {
  if (raw && !Buffer.isBuffer(args[0])) {
    args[0] = Buffer.from(args[0]);
  } else if (!raw && Buffer.isBuffer(args[0])) {
    args[0] = args[0].toString('utf8');
  }
}

// 迭代执行normal loader
function iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback) {
  if (loaderContext.loaderIndex < 0) {
    return pitchingCallback(null, args);
  }
  const currentLoader = loaderContext.loaders[loaderContext.loaderIndex];
  if (currentLoader.normalExecuted) {
    loaderContext.loaderIndex--;
    return iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback);
  }
  let normalFn = currentLoader.normal;
  currentLoader.normalExecuted = true;
  convertArgs(args, currentLoader.raw);
  runSyncOrAsync(normalFn, loaderContext, args, (err, ...returnArgs) => {
    if (err) return pitchingCallback(err);
    return iterateNormalLoaders(processOptions, loaderContext, returnArgs, pitchingCallback);
  });
}

// 迭代执行pitch loader
function iteratePitchingLoaders(processOptions, loaderContext, pitchingCallback) {
  // 说明所有的loader的pitch都已经执行完成
  if (loaderContext.loaderIndex >= loaderContext.loaders.length) {
    // 开始处理资源文件
    return processResource(processOptions, loaderContext, pitchingCallback);
  }
  const currentLoader = loaderContext.loaders[loaderContext.loaderIndex];
  if (currentLoader.pitchExecuted) {
    loaderContext.loaderIndex++;
    return iteratePitchingLoaders(processOptions, loaderContext, pitchingCallback);
  }
  let pitchFn = currentLoader.pitch;
  // 不管pitch函数有没有,都把这个pitchExecuted设置为true
  currentLoader.pitchExecuted = true;
  // 如果pitch函数不存在,则递归iteratePitchingLoaders
  if (!pitchFn) {
    return iteratePitchingLoaders(processOptions, loaderContext, pitchingCallback);
  }
  //如果pitchFn有值 以同步或者异步调用pitchFn方法,以loaderContext为this指针
  runSyncOrAsync(
    pitchFn,
    loaderContext,
    [loaderContext.remainingRequest, loaderContext.previousRequest, loaderContext.data],
    (err, ...args) => {
      // 判断有没有返回值, 如果有返回值,需要掉头执行前一个loader的normal
      if (args.length > 0 && args.some(item => item)) {
        loaderContext.loaderIndex--;
        iterateNormalLoaders(processOptions, loaderContext, args, pitchingCallback);
      } else {
        // 如果没有返回值,则继续执行下一个pitch loader
        return iteratePitchingLoaders(processOptions, loaderContext, pitchingCallback);
      }
    }
  );
}
function runSyncOrAsync(fn, loaderContext, args, runCallback) {
  let isSync = true; // 默认loader的执行是同步的
  let isDone = false; // 表示此loader函数是否已经执行完成
  loaderContext.callback = (err, ...args) => {
    if (isDone) {
      // runCallback 只能调用一次
      throw new Error('async(): The callback was already called.');
    }
    isDone = true;
    runCallback(err, ...args);
  };
  loaderContext.async = () => {
    isSync = false; // 把isSync是否同步执行的标志从同步变成异步
    return loaderContext.callback;
  };
  let result = fn.apply(loaderContext, args);
  if (isSync) {
    // 如果isSync同步的话,由本方法直接调用runCallback,用来执行下一个loader
    isDone = true;
    runCallback(null, result);
  }
}

function runLoaders(options, finalCallback) {
  debugger;
  const { resource, loaders = [], context = {}, readResource = fs.readFile } = options;
  const loaderObjects = loaders.map(createLoaderObject);
  const loaderContext = context; // 会成为loader执行过程中的this指针
  loaderContext.resource = resource; //要加载的资源文件路径
  loaderContext.readResource = readResource; // 读取资源文件内容的方法,默认是fs.readFile
  loaderContext.loaders = loaderObjects;
  loaderContext.loaderIndex = 0; // 当前正在执行的loader的索引
  loaderContext.callback = null; // 调用callback可以让当前的loader执行结束,并且向后续 的loader传递多个值
  loaderContext.async = null; // 是内置方法,可以把loader的执行从同步变成异步

  // 所有的loader加上resouce。
  // 假设当前有loader1、loader2和loader3三个loader用来处理file.js文件
  Object.defineProperty(loaderContext, 'request', {
    get() {
      // loader1!loader2!loader3!file.js
      return loaderContext.loaders
        .map(loader => loader.path)
        .concat(loaderContext.resource)
        .join('!');
    },
  });

  // 从当前的loader下一个开始一直到结束,加上要加载的资源。 假设当前执行到loader2
  Object.defineProperty(loaderContext, 'remainingRequest', {
    get() {
      //loader3!file.js
      return loaderContext.loaders
        .slice(loaderContext.loaderIndex + 1)
        .map(loader => loader.path)
        .concat(loaderContext.resource)
        .join('!');
    },
  });

  // 从当前的loader开始一直到结束 ,加上要加载的资源
  Object.defineProperty(loaderContext, 'currentRequest', {
    get() {
      //loader2!loader3!file.js
      return loaderContext.loaders
        .slice(loaderContext.loaderIndex)
        .map(loader => loader.path)
        .concat(loaderContext.resource)
        .join('!');
    },
  });

  // 从第一个到当前的loader的前一个
  Object.defineProperty(loaderContext, 'previousRequest', {
    get() {
      // loader1
      return loaderContext.loaders
        .slice(0, loaderContext.loaderIndex)
        .map(loader => loader.path)
        .concat(loaderContext.resource)
        .join('!');
    },
  });

  Object.defineProperty(loaderContext, 'data', {
    get() {
      return loaderContext.loaders[loaderContext.loaderIndex].data;
    },
  });

  let processOptions = {
    resourceBuffer: null, // 存放着要加载的模块的原始内容
    readResource, // 读取文件的方法,默认值是fs.readFile
  };
  // 开始从左向右迭代执行loader的pitch
  iteratePitchingLoaders(processOptions, loaderContext, (err, result) => {
    finalCallback(err, {
      result,
      resourceBuffer: processOptions.resourceBuffer,
    });
  });
}
exports.runLoaders = runLoaders;

更多细节请参考 webpack loader-runner

7. 简单实现一些常用loader

接下来我们来实现一些常用loader,注意这里只是简单的实现,实现的是主要功能,一些细节和边界条件不会考虑。

7.1 babel-loader

const babel = require('@babel/core');
const path = require('path');
function loader(source) {
  let options = this.getOptions();
  const { code } = babel.transformSync(source, options);
  return code;
}
module.exports = loader;

在webpack5中,已经在loaderContext中添加了这个getOptions方法,webpack5之前并不存在this.getOptions方法,需要额外通过loader-utils这个包实现获取外部loader配置参数。

7.2 file-loader

function loader(source) {
  let filename = Date.now() + ".png";
  //用于向输出目录里写一个新的文件
  this.emitFile(filename, source);
  return `module.exports = ${JSON.stringify(filename)}`;
}
loader.raw = true;
module.exports = loader;

webpack5以前加载图片等二进制文件需要使用file-loader或者 url-loader, 但是在webpack5中不再需要了。

7.3 url-loader

const path = require('path');
const mime = require('mime');

function loader(content) {
  // content默认格式是字符串
  let options = this.getOptions(this) || {};
  let { limit = 8 * 1024, fallback = 'file-loader' } = options;
  const mimeType = mime.getType(this.resourcePath); // image/jpeg
  if (content.length < limit) {
    let base64Str = `data:${mimeType};base64,${content.toString('base64')}`;
    return `module.exports = ${JSON.stringify(base64Str)}`;
  } else {
    let fileLoader = require(fallback);
    return fileLoader.call(this, content);
  }
}

//如果你不希望webpack帮你把内容转成字符串的的话需要加上loader.raw=true;,这样的话content就是一个二进制的Buffer
loader.raw = true;
module.exports = loader;

7.4 less-loader

const less = require('less');

function loader(lessSource) {
  let cssSource;
  // 如果调用了this.async方法,就会把loader的执行从同步变成异步,只有当你手工调用callback的时候才会认为此loader执行结束
  // const callback = this.async();
  less.render(lessSource, { filename: this.resource }, (err, output) => {
    cssSource = output.css;
    // callback(null, cssSource);
    this.callback(null, cssSource, output.map, output.ast);
  });
  //如果返回值只有一个的话可以用return
  //return cssSource;
  //return `module.exports=${JSON.stringify(cssSource)}`;
  //return `module.exports = "#root{color:red}"`
}
module.exports = loader;
// 在真正的less-loader中返回并不是css文本内容,而也是返回的js

7.5 css-loader

// 一个用来分析css ast的库
const postcss = require('postcss');
const Tokenizer = require('css-selector-tokenizer');

function loader(inputSource) {
  let loaderOptions = this.getOptions(this) || {};
  let callback = this.async();
  
  // postcss 插件
  const cssPlugin = options => {
    return root => {
      if (loaderOptions.import) {
        // 删除所有的 @import语句并且把导入的CSS文件路径添加到options.imports里
        root.walkAtRules(/^import$/i, rule => {
          rule.remove(); //在css脚本里把 @import删除
          options.imports.push(rule.params.slice(1, -1)); // ./index.css
        });
      }
      if (loaderOptions.url) {
        // 遍历语法树,找到里面所有的url
        root.walkDecls(decl => {
          let values = Tokenizer.parseValues(decl.value);
          values.nodes.forEach(node => {
            node.nodes.forEach(item => {
              if (item.type === 'url') {
                // stringifyRequest可以把任意路径标准化为相对路径
                let url = loaderUtils.stringifyRequest(this, item.url);
                item.stringType = "'";
                item.url = '`+require(' + url + ')+`';
              }
            });
          });
          let value = Tokenizer.stringifyValues(values);
          decl.value = value;
        });
      }
    };
  };
  // 将会用它来收集所有的 @import
  let options = { imports: [] };
  let pipeline = postcss([cssPlugin(options)]);

  // 开始编译css
  pipeline.process(inputSource).then(result => {
    let { importLoaders = 0 } = loaderOptions;
    let { loaders, loaderIndex } = this; // 所有的loader数组和当前loader的索引
    let loadersRequest = loaders
      .slice(loaderIndex, loaderIndex + 1 + importLoaders)
      .map(x => x.request)
      .join('!'); // request是loader绝对路径

    // -! 不要前置和普通 loader
    // loader-utils中的stringifyRequest方法,可以将绝对路径转化为相对路径。
    // loader.js=> ./src/loader.js
    let importCss = options.imports
      .map(url => `list.push(...require(` + loaderUtils.stringifyRequest(this, `-!${loadersRequest}!${url}`) + `));`)
      .join('\r\n');
    let script = `
         var list = [];
         list.toString = function(){return this.join('')}
         ${importCss}
         list.push(`${result.css}`);
         module.exports = list.toString();
      `;
    callback(null, script);
  });
}

module.exports = loader;

7.6 style-loader

通常我们在使用style-loader时,都会和css-loader配合使用。通常配置如下

webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  mode: 'development',
  entry: './src/index.js',
  context: process.cwd().replace(/\/g, '/'),
  output: {
    path: path.resolve('dist'),
    filename: 'main.js',
  },
  resolveLoader: {
    modules: ['my-loaders', 'node_modules'],
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
  ],
};


src/index.js

import './index.css'

src/index.css

#root {
  width: 200px;
  height: 200px;
  background-color: red;
}

7.6.1 将style-loader设计为normal loader

function loader(cssSource) {
  console.log(cssSource)
  const script = `
     let style = document.createElement("style");
     style.innerHTML = ${JSON.stringify(css.replace('\n', ''))};
     document.head.appendChild(style);
  `;
  return script; 
}

执行webpack进行编译

npx webpack

然后打开编译好的dist/index.html,但是并没有发现root变红,这是什么原因呢?

样式文件首先会经过css-loader的处理之后才会交给style-loader处理。我们在style-loader中打印出css-loader编译后的结果如下(这里使用的是原生的css-loader):

webpack Loader核心原理解析

如果使用我们自己实现的css-loader,则打印的结果如下:

webpack Loader核心原理解析

其实本质上差不多,cssSource的内容是一个js脚本,我们将js脚本的内容插入到style element中,当然样式是不会生效的。

dist/index.html的内容

webpack Loader核心原理解析

这说明如果我们将style-loader设计为normal loader的话,我们需要执行css-loader返回的js脚本,并且获得它导出的内容才可以得到对应的样式内容。比如:

function loader(cssSource) {
  let module = { exports: {} };
  eval(cssSource);
  const script = `
     const style = document.createElement("style");
     style.innerHTML = ${JSON.stringify(module.exports)};
     document.head.appendChild(style);
  `;
  return script;
}

module.exports = loader;

那么此时我们需要在style-loadernormal阶段实现一系列js的方法才能执行js并读取到css-loader返回的样式内容,这无疑是一种非常糟糕的设计模式。

7.6.2 将style-loader设计为pitch loader

function loader(cssSource) {};
loader.pitch = (remainingRequest) => {
  const script = `
     import style from "!!${remainingRequest}";
     let styleEle = document.createElement('style');
     styleEle.innerHTML = style;
     document.head.appendChild(styleEle);
    `;
  return script;
}
module.exports = loader;

如果我们在style-loader的pitch函数中直接返回值的话,会发生熔断效果,此时的执行顺序如下:

webpack Loader核心原理解析

webpack获取到style-loader pitch的返回值,然后进行解析,webpack发现里面存在import语句,

 import style from "!!${remainingRequest}"

然后就再次使用loader去编译引入的文件,此时remainingRequest的值为css-loader!index.css。为什么我们要加上!!前缀呢?加上这个前缀代表我们不再找配置中的loader,仅仅使用这里的inline loader,这里也就是css-loader normal。如果不加的话又会去找配置文件中的style-loader,又会重新执行其pitch函数,这样就会发生死循环。

最后使用css-loader的normal去获取到真正要插入到style标签的文本,然后生成一个标签并插入页面。

重新打包并打开页面,看到页面可以正常显示样式了!

webpack Loader核心原理解析

8. Pitch loader应用场景总结

通过上述style-loader的实现过程,我们可以发现如果在loader开发的过程中你需要依赖上一个loader,然后上一个loader的normal函数返回的并不是处理后的资源文件内容而是一串js脚本,那么此时相较于normal loader将你的loader编写为一个pitch loader应该是更好的方式。