webpack 之 Loader 详解

对于webpack,一切皆模块。webpack 只能理解 JavaScript 和 JSON 文件,其他类型/后缀的文件都需要经过 loader 处理,将它们转换为js可识别的有效模块 (webpack 天生支持 ECMAScript、CommonJS、资源模块等模块类型)。loader可以做语言翻译(比如将文件从 TypeScript 转换为 JavaScript) 或格式转换(将内联图像转换为 data URL)还有样式编译(允许直接在 JavaScript 模块中 import CSS文件)。

loader 是什么

每个 loader 本质上都是一个导出为函数的 JavaScript 模块。loader runner 会调用此函数,将资源文件或者上一个 loader 产生的结果传进去,经过编译转换把处理结果再输出去(如果后面还有 loader 就传给下一个)。函数中的 this 作为上下文会被 webpack 填充,并且 loader runner 中包含一些实用的方法,比如可以使 loader 调用方式变为异步,或者获取 query 参数。
简言之 loader 就是模块转换器。有点像 Vue 的过滤器。

同步模式

loader 如果返回单个处理结果,可以在直接 return。如果有多个处理结果,则必须调用 this.callback()。this.callback 方法则更灵活,因为它允许传递多个参数,而不仅仅是 content。

module.exports = function (content, map, meta) {
  return someSyncOperation(content);
};
// 需要传递多个参数,用 this.callback
module.exports = function (content, map, meta) {
  this.callback(null, someSyncOperation(content), map, meta);
  return; // 当调用 callback() 函数时,总是返回 undefined
};

异步模式

由于同步计算过于耗时,在 Node.js 这样的单线程环境下进行此操作并不是好的方案,很多 loader 都是异步的。
在异步 loader 中,必须调用 this.async() 来告知 loader runner 等待异步结果,它会返回 this.callback() 回调函数。随后 loader 必须返回 undefined 并且调用该回调函数。

module.exports = function (content, map, meta) {
  var callback = this.async();
  someAsyncOperation(content, function (err, result) {
    if (err) return callback(err);
    callback(null, result, map, meta);
  });
};
// 多个处理结果
module.exports = function (content, map, meta) {
  var callback = this.async();
  someAsyncOperation(content, function (err, result, sourceMaps, meta) {
    if (err) return callback(err);
    callback(null, result, sourceMaps, meta);
  });
};

以下是一个模拟的raw-loader,它用于加载文件的原始内容(utf-8)。比如把一个 import/require() 进来的 txt文件的内容以字符串的形式导出去。这个 loader 虽然在 webpack5 已经弃用了,但我们仍可以参考下自定义 loader 的写法。

// 获取webpack配置options参数的方法,写loader的固定第一步
const { getOptions } = require("loader-utils");
/**
 *
 * @param {string|Buffer} content 传入的源文件的内容
 * @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 数据
 * @param {any} [meta] meta 数据,可以是任何内容
 */
module.exports = function(content, map, meta) {
  // 如果是module.rules配置,返回的是options;如果是内联loadername!语法,返回根据query字符串生成的对象
  const opts = getOptions(this) || {};

  const code = JSON.stringify(content);
  const isESM = typeof opts.esModule !== "undefined" ? options.esModule : true;
  // 根据配置是否开启esModule决定导出语句,直接返回原文件内容
  // esModule: false 就是使用 CommonJS 规范
  const result = `${isESM ? "export default" : "module.exports ="} ${code}`;
  return result;
};

比如我们有一个文本文件 example.txt,调用这个 loader 后,过程相当于:
源文件内容 this is a txt file
被处理成了 js:

// example.js
export default 'this is a txt file'
// 或
module.exports = 'this is a txt file';

然后 webpack 交给 require 去引入:

const source = require('example.js'); 
console.log(source); // this is a txt file

loader 的使用/配置

1. 在config文件配置

module.rules 配置转换规则时,有两个必选属性 test 和 use。
像这样 module: { rules: [{ test: /\.txt$/, use: 'raw-loader' }] }
会告诉 webpack 编译器(compiler) ,当碰到「在 require()/import 语句中被解析为 '.txt' 的路径」时,在你对它打包之前,先用 raw-loader 转换一下(预处理)。

module.exports = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: ['a-loader', 'b-loader', 'c-loader'], // 从右到左,c-loader -> b-loader -> a-loader
      },
      {
        test: /\.css$/, // test属性,规定哪些文件会被转换
        use: [ // use属性,在进行转换时,应用哪些 loader
          { loader: 'style-loader' }, 
          {
            loader: 'css-loader',
            options: {
              modules: true
            }
          },
          { loader: 'sass-loader' } 
        ] // 从下到上,sass-loader -> css-loader -> style-loader
      }
    ],
  },
}
2. 用 webpack-chain 配置

end() 方法的作用是向上移动一级,移回更高的上下文环境,就可以继续链式调用上级API的方法。比如这里是到.use() 层级继续链式调用

// 从下到上,vue-loader -> cache-loader
webpackConfig.module
  .rule('vue')
    .test(/\.vue$/)
    .use('cache-loader')
      .loader('cache-loader')
      .options({...})
      .end()
    .use('vue-loader')
      .loader('vue-loader')
      .options(Object.assign({...})
3. 内联方式(inline)(官方不推荐使用)

在 import 语句中显式指定 loader。
使用 ! 将资源中的 loader 分开。每个部分都会相对于当前目录解析。对于解析相同的文件类型,inline loader优先级高于config配置文件中的loader。这个方法不用熟记(官方都说了不应该使用orz),了解即可。
通过为内联 import 语句添加前缀,可以覆盖 配置 中的所有 loader, preLoader 和 postLoader:

// 从右到左,css-loader -> style-loader
import Styles from 'style-loader!css-loader?modules!./styles.css'
// 相当于
rules: [{
    test: /^styles\.css$/, 
    use: [
      { loader: 'style-loader' }, 
      {
        loader: 'css-loader',
        options: {
          modules: true
        }
      },
    ] 
}]

loader 的执行顺序

每个 Loader 的功能都应是单一而专注的,这样不仅便于维护,还能让它们在更多场景中被串联应用。因此 Loader 通常是组合使用的。链式调用一组 loader 时 (无论是模块规则配置还是内联方式),它们会按照相反的顺序执行。即从右到左(或从下到上),依次将前一个 loader 转换后的结果传递给下一个 loader。直到最后一个 loader 返回 webpack 所期望的 JavaScript。有点像 Promise 的 then。
loader 可以用 String 或 Buffer 的形式传递它的处理结果,complier 会把它们在 loader 之间相互转换。最终结果也就是最后一个 loader 会返回一或两个值:第一个是代表模块的 JavaScript 源码的 String 或者 Buffer(这个结果会交给 webpack 的 require,因此一定是一段可执行的 node 模块的 JS 脚本[用字符串存储的]);第二个是可选的 SourceMap (格式为 JSON 对象)。

一组 loader 的执行有两个阶段:Pitching 阶段 和 Normal 阶段,类似于js中的事件捕获、冒泡
webpack 的 loader-runner 会按正序(从左到右) require 每个 loader,把这个 loader 的模块导出函数 和 pitch函数都存到 loaderContext 对象上,然后执行该 loader 的 pitch 方法(如果有的话);如果一组 loader 的 pitch 都没有返回值,就开始 Normal阶段反向(从右到左)执行 loader 的导出函数,依次进行模块源码的转换,直到拿到最后的处理结果;但是当 Pitching 阶段某个 loader 的 pitch 有返回值,那么就会跳过剩余未读取的 loader,直接进入执行 loader 的环节。从前一个 require 的 loader 开始执行,pitch 的返回值即是传入的第一个参数。除了 pitch 有返回的那个 loader,倒序执行已经 require 的每个 loader。
原理可参考:浅析 webpack 打包流程(原理) 二 之【执行 loader 阶段,初始化模块 module,并用 loader 倒序转译】部分

module: {
  rules: [{ test:/\.vue$/, use: ['a-loader', 'b-loader', 'c-loader'] }]
},

根据以上配置,*.vue文件在 loader 处理阶段将经历以下步骤:

|- a-loader `pitch` 方法
  |- b-loader `pitch` 方法
    |- c-loader `pitch` 方法
      |- 以模块依赖的形式即 import/require() 获取资源内容
    |- c-loader normal 执行
  |- b-loader normal 执行
|- a-loader normal 执行

如果 b-loader 的 pitch 方法有返回值,直接跳过 c-loader 进入 loader 执行阶段,并且 b-loader 也不会执行。整个过程就会变成这样:

|- a-loader `pitch` 方法
  |- b-loader `pitch` 方法 (有返回结果,则跳过后面未 require 的 loader,直接进入 loader 执行阶段)
|- a-loader normal 执行 (传入参数是 b-loader pitch 的返回值)

图解更清晰:

loader 可以利用 pitch 阶段来做什么?

Pitch 方法是什么:每个 loader 可以挂载一个 pitch 函数,该函数主要用于利用 modulerequest 来提前做一些拦截处理的工作(后面会举例说明),并不实际处理模块内容。
事实上很多 loader 并未定义 pitch,一般定义了 pitch 就是某些情况要返回东西。
详情请看 Pitching Loader

当一组 loader 被链式调用,像上面的例子,正常情况只有最后一个c-loader能获得资源文件(起始 loader 只有一个入参:资源文件的内容),b-loader拿到的是c-loader处理结果,中间如果再多几个 loader 也是如此,只能拿到上一个传来的值,处理好再传递给下一个。直到第一个a-loader返回最终结果。
尽管 loaders 常被串联使用,但它们的功能仍旧是单一并独立的,且只关心自己的输入和输出。就像工厂流水线,一个区域的工人/机器只干一种类型的活。所以合理搭配并配置正确的顺序才能得到我们想要的结果。

它只想要 request 后面的 元数据(调用 loader时传入的第三个参数 metadata)。
但有时候我们需要把两个用来做最后处理的 loader 串起来,比如 style-loader 和 css-loader。
但 style-loader 并不需要 css-loader 的结果,它只需要 request 后的元数据。

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

如果按正常流程走,style-loader 只能拿到 css-loader 转换的结果,一个包含可动态执行函数的js字符串(含有模块导出代码,类似module.exports语句)。因此 style-loader 在 pitch 函数里返回了包含类似require(!!css-loader!./*.css)的js字符串。简化如下:

const path = require('path');
const loaderUtils = require('loader-utils');
// style-loader 导出了一个空函数
module.exports = function () {
}

// style-loader 的 pitch 方法
module.exports.pitch = function(request){
  var result = [
    'var content=require(' + loaderUtils.stringifyRequest(this),'!!' + request)+')',
    'require' + loaderUtils.stringifyRequest(this,'!'+ path.join(__dirname,"add-style.js")) + ')(content)',
    'if(content.locals) module.exports = content.locals'
  ]
  return result.join(';');
}

当 webpack 执行这个结果时,就会内联调用require()语句中的loader进行处理。!!前缀可以禁用 webpack 配置中的所有 loader,因此不会再重复递归调用 style-loader,现在只会用 css-loader 处理。

除此之外,传递给 pitch 方法的 第三个参数(data),在主函数执行阶段也会暴露在 this.data 之下,可用于在循环时捕获并共享前面的信息。比如cache-loader利用 pitch 进行缓存读取,如果存在缓存就跳过后面 loader 的编译。

// cache-loader 主函数
module.exports = function (...args) {
  const callback = this.async();
  const { data } = this; // 拿到 pitch 方法传递的 data
  const toDepDetails = (dep, mapCallback) => {
    FS.stat(dep, (err, stats) => {
      const mtime = stats.mtime.getTime();
      if (mtime / 1000 >= Math.floor(data.startTime / 1000)) {
        cache = false;
      }
    });
  };
  writeFn(data.cacheKey, {
      remainingRequest: pathWithCacheContext(options.cacheContext, data.remainingRequest),
      dependencies: deps,
      contextDependencies: contextDeps,
      result: args
    }, () => {
      // ignore errors here
      callback(null, ...args);
    });
};

// cache-loader 的 pitch 方法
module.exports.pitch = function (remainingRequest, prevRequest, dataInput) {
  const data = dataInput;
  data.remainingRequest = remainingRequest;
  data.cacheKey = cacheKeyFn(options, data.remainingRequest);
  // 根据 cacheKey 的标识获取对应的缓存文件内容
  readFn(data.cacheKey, (readErr, cacheData) => {
    // 遍历所有依赖文件路径
    async.each(cacheData.dependencies.concat(cacheData.contextDependencies), (dep, eachCallback) => { 
     // ...
      FS.stat(contextDep.path, (statErr, stats) => {
        const compStats = stats;
        const compDep = contextDep;
         // 对比当前文件最新的 mtime 和缓存当中记录的 mtime 是否一致
          if (compareFn(compStats, compDep) !== true) {
            eachCallback(true);
            return;
          }
          eachCallback();
        });
      },
      (err) => {
        if (err) {
          data.startTime = Date.now(); // 这个时间能被主函数执行时获取
          callback();
          return;
        }
        // ...
        callback(null, ...cacheData.result);
      }
    );
  })
};

cache-loader 的 pitch 方法会根据生成的 cacheKey 去查找 node_modules/.cache 目录缓存的 json 文件。如果缓存文件中记录的所有依赖以及这个文件本身都没发生变化,那么就直接读取缓存中的内容并返回,同时跳过后面 loader 的执行。一旦依赖或者这个文件发生变化,那么就正常走后面 loader 的 pitch 方法,以及执行 loader 的流程。

手动指定 loader 的执行顺序

用内联方式为引入模块指定 loader 时,在 import 语句添加前缀可以覆盖 配置 中的所有 loader, preLoader 和 postLoader,从而影响到 pitch 和执行的顺序。
比如使用 -! 前缀,将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoaders。

// 在 /src/file.js 文件中
require('-!./loader1?xyz!loader2!./resource?rrr');
// 或
import Styles from '-!style-loader!css-loader?modules!./styles.css';

那 preLoader、postLoader 是什么意思呢?
其实 prepost 都是我们用 Rule.enforce 配置项指定的loader执行类型(可选值),分别表示 优先处理 和 最后处理。不指定即指按正常顺序处理,即默认都是 normal loader。还有就是我们上面讲的内联 loader,也就是“行内 loader”,即 loader 被应用在 import/require 行内。

Normal 阶段是按 前置(pre)、普通(normal)、行内(inline)、后置(post) 的顺序调用loader的,Pitching 阶段则相反。

如果不加 enforce 属性,用以下顺序配置loader,匹配到css文件时的(单指Normal阶段)默认执行顺序是 style-loader -> css-loader -> sass-loader(从下往上), 而我们按照自己的需要标上优先和后置顺序后,结果就符合预期了:sass-loader -> css-loader -> style-loader

module: {
    rules: [
      {
        test: /\.css$/, 
        loader: : 'sass-loader', 
        enforce: 'pre', // 指定为前置类型
      },
      {
        test: /\.css$/,  
        loader: : 'css-loader',  // 没指定enforce,为普通类型
      },
      {
        test: /\.css$/, 
        loader: : 'style-loader', 
        enforce: 'post', // 指定为后置类型
      }
    ]
  },

再看 webpack-chain 的实现:
根据文档API和实测,webpack-chain 的 enforce 配置和上面的有很大的区别。.enforce(preOrPost)传的值是指先执行上面的loader(pre) 或下面的loader (post),不要把这里的prepost跟前置loader和后置loader混淆了。

// 速记格式解读
config.module
  .rule(name)
    .test(test)
    .pre() // 指代 .use(loader-name-pre)
    .post() // 指代另一个 .use(loader-name-post)
    .enforce(preOrPost) // 值为'pre' 则表示先执行上面的loader,即loader-name-pre;值为'post' 则先执行下方的loader,loader-name-post

感觉这个API不是很合理😂

// 本来从下到上,现在 vue-loader -> cache-loader
config.module
  .rule('vue')
    .test(/\.vue$/)
    .use('vue-loader') // 在前面表示 pre
      .loader(require.resolve('vue-loader'))
      .end()
    .use('cache-loader') // 在后面表示 post
      .loader(require.resolve('cache-loader'))
      .end()  
    .enforce('pre') // 表示先执行 vue-loader

使用嵌套rules来跳出多余的预处理规则

可以使用属性 rulesoneOf 来指定嵌套规则,比较常用且是很推荐的做法。
Rule.oneOf 规则数组:只使用 oneOf 数组中第一个匹配到的规则。

常规写法比如给某类格式的文件用 module.rules 配置了3个loader,执行时就会按 pre-> normal -> post 的顺序都去处理一遍。而 oneOf 可以用 resourceQuery 属性来查询与资源请求字符串的查询部分(即从?开始)相匹配的 Condition。看下例:
foo.css?inline 只会经过 url-loader 处理,而 foo.css?external 只会经过 file-loader 处理。

  module: {
    rules: [
      {
        test: /\\.css$/,
        oneOf: [
          {
            resourceQuery: /inline/, // foo.css?inline
            use: 'url-loader',
          },
          {
            resourceQuery: /external/, // foo.css?external
            use: 'file-loader',
          },
        ],
      },
    ],
  }

webpack-chain 版本:

config.module
  .rule('css')
    .oneOf('inline')
      .resourceQuery(/inline/)
      .use('url')
        .loader('url-loader')
        .end()
      .end()
    .oneOf('external')
      .resourceQuery(/external/)
      .use('file')
        .loader('file-loader')

常用loader

文件
  • url-loaderfile-loader 类似,但当文件 size 小于设置的 limit 值,会返回 data URL
  • file-loader 将文件保存至输出文件夹中并返回 URL (默认是是绝对路径,可以 outputPath 和 publicPath 通过配置成相对路径)
语法转换
样式
  • style-loader 将样式模块导出的内容以往 <head> 中注入多个 <style> 的形式,添加到 DOM 中
  • css-loader 加载 CSS 文件并解析 @import 的 CSS 文件,将 url() 处理成 require() 请求,最终返回 CSS 代码
  • less-loader 加载并编译 LESS 文件
  • sass-loader 加载并编译 SASS/SCSS 文件
  • postcss-loader 使用 PostCSS 加载并转换 CSS/SSS 文件
  • stylus-loader 加载并编译 Stylus 文件
框架

参考:
Writing a loader
webpack使用笔记(四)loader原理与实现
手把手教你撸一个 Webpack Loader
Webpack中Loader的pitch方法

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容