你不知道的「pitch loader」应用场景

5,486 阅读12分钟

写在前面

大多数前端开发者对于loader可能都清楚它存在两个执行阶段normalpitch阶段,但是大多数同学对于pitch loader的理解仅仅停留在:

  • pitch loader的含义是什么。
  • pitch loader会产生什么样的作用。

一旦上手loader开发,在进行相关loader开发时不清楚pitch loader在真实业务场景会给我们带来什么样的作用,真实的pitch loader的应用场景是什么。

以及在设计一款loader应用时对于将loader逻辑设计在normal阶段还是pitch阶段完全没有概念。

对于如何设计一款loader大多数开发者可能仅仅停留在基础阶段,但是往往正是这些对于细节知识的把控性才正是一个软件开发工程师综合能力的体现。

这里,这篇文章的目的就是带领大家从开源loader项目的设计哲学来窥探pitch loader的真实应用场景,带你真正掌握pitch loader在实际开发中的适用场景。

如果你不了解webpack loader,不用担心。我们会在开头用几张通俗的图片来讲诉何谓pitch loadernormal loader

何谓Loader pitch

首先在开始之前我们会稍微来聊聊简单的前置知识。

对于loader进阶知识,有想了解的朋友可以查看这篇文章:[多角度解析Webpack5之Loader核心原理],涵盖loader基础应用、源码实现、开发企业级loader各个方面的讲解。

loader的种类

Loader可以分为四种

webpackloader分为四个阶段,分别是prenormalinline以及post四种loader,区分它们的依据正如它们的名字:四种loader会按照不同的执行顺序去执行

  1. Pitching 阶段: loader 上的 pitch 方法,按照 后置(post)、行内(inline)、普通(normal)、前置(pre) 的顺序调用。

  2. Normal 阶段: loader 上的 常规方法,按照 前置(pre)、普通(normal)、行内(inline)、后置(post) 的顺序调用。模块源码的转换, 发生在这个阶段

此时如果不清楚什么是normalpitch,没关系你需要单纯的记住这loader分为四种。它们的执行顺序在正常情况下是 前置(pre)、普通(normal)、行内(inline)、后置(post)

指定Loader种类

webpack配置文件中,我们可以通过module对象上的rule.enforce配置项规定这个loader的种类,通过配置文件我们可以配置三种类型的loader

  • enforcepre时,该配置项目内的loader为前置pre loader

  • enforcepost时,该配置项目内的loader为后置post loader

  • enforce什么都不配置时,该配置项目内的loader为默认normal loader

此时有的同学会疑问那么inline loader是不是配置enforce:inline就好了呢?

其实不然,行内inline-loader并不在webpack配置文件中进行配置,它的配置方式在我们的业务代码的模块引入语句中。

import Styles from 'style-loader!css-loader?modules!./styles.css';

比如这里,我们在通过import Styles from './styles'该模块时通过!分割的规则,配置了两个行内loader,分别是style-loadercss-loader

inline loader的执行顺序同样是从右往左,也就是inline-loader执行时会先执行css-loader处理文件,再会执行style-loader处理。

使用行内loader时,可以额外配置一些规则

通过为内联 import 语句添加前缀,可以覆盖 [配置] 中的所有 loader, preLoader 和 postLoader:

  • 使用 ! 前缀,将禁用所有已配置的 normal loader(普通 loader)

    import Styles from '!style-loader!css-loader?modules!./styles.css';
    
  • 使用 !! 前缀,将禁用所有已配置的 loader(preLoader, loader, postLoader)

    import Styles from '!!style-loader!css-loader?modules!./styles.css';
    
  • 使用 -! 前缀,将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoaders

    import Styles from '-!style-loader!css-loader?modules!./styles.css';
    

这里比较简单,就是通过前缀来设置禁用不同种类的loader,不了解的同学可以自己补习补习。

normal loader & pitch loader

上边我们讲过了loader存在四种类型,也简单给大家提过四种loader的执行顺序。这里。我们会详细讲诉四种loader的执行顺序。

简单说说什么是normalpitch

关于normal loader本质上就是loader函数本身。

// loader函数本身 我们称之为loader的normal阶段
function loader(source) {
    // dosomething
    return source
}

关于pitch loader就是normal loader上的一个pitch属性,它同样是一个函数:

// pitch loader是normal上的一个属性
loader.pitch = function (remainingRequest,previousRequest,data) {
    // ...
}

简单来说这就是pitch loadernormal loader

我们将loaderpitch属性称为loaderpitch loader

自然而然,我们将loader函数本身称为noram loader

关于pitch loadernormal loader的参数和返回值代表的含义,如果你目前还不是很清楚。强烈建议你首先去阅读[多角度解析Webpack5之Loader核心原理]

执行阶段

上边照顾了一下基础薄弱的同学,稍微聊了聊pitchnormal的基础内容,那么这两个阶段的作用分别是什么呢?

这我们用一张图来看一下对应的执行顺序:

image.png

图中我们有8个loader,它们分别存在对应的种类,webpack中对于一次文件的引入首先会进入loader处理文件的阶段,loader处理完成才会交给webpack进行编译。

通过上图我们可以看到:

  • 首先在一次webpack中引入一次资源(无论是通过import还是require),首先会进入loader处理阶段。

  • loader处理阶段首先会左从往右经过pitch loader的函数调用,一层一层处理。它的处理顺序是:postinlinenormalpre

  • pitch阶段全部处理完成后,这一步才会读取引入的资源文件内容

  • 将读取到的资源文件内容交给noraml-loader函数,一层一层传递处理。它的执行顺序是:prenormalinlinepost

  • 最终将loader处理后的资源返回给webpack进行编译处理。

pitch loader的熔断效果

上边我们说到webpack编译资源时首先经过loader的处理,会经过两个阶段分别是pitchnormal阶段。

这里关于为什么会存在pitch阶段,pitch阶段究竟有什么用。我会在接下里在实践中和你好好讨论这一点,首先这里我们需要清楚pitch阶段的一个重要特性:

pitch loader中如果存在非undefeind返回值的话,那么上述图中的整个loader chain会发生熔断效果

你可以会疑惑什么是熔断效果,来看看这张图:

image.png

假设我们在inline-loaderpitch阶段返回了一个一个字符串19Qingfeng,那么此时loader的执行会打破原有的顺序。

它会立马掉头将pitch函数的返回值去执行前置的noraml loader

这里有两点需要额外说明:

  1. pitch阶段返回的非undefeind的值会造成loader打破原有顺序掉头执行,这就叫做熔断效果。

  2. 正常执行时是会读取资源文件的内容交给normal loader去处理,但是pitch存在返回值时发生熔断并不会读取文件内容了。此时pitch函数返回的值会交给将要执行的normal loader

这里你仅需要了解pitch阶段所谓熔断代表的含义,接下来我会带你深入它的应用场景。

从开源Loader应用源码分析

祝贺可以看到这里的小伙伴,接下来我们就来探索一下绝大多数开发者“知其然而不知其所以然”的地方--何时应该将Loader设计为pitch loader

style-loader源码思路出发

这里我们先来看看style-loader的源代码:

你可以大致看下这张图片,没有必要深究它。相信我,快速划过即可。

code.png

Emm...它的代码的确又臭又长是吧哈哈!

这里我并不会带你去阅读这段代码,因为阅读它的完整源码对于文章中想表述的内容没有多大帮助。

但是这里我会告诉你这段“又臭又长”的代码究竟在做什么事情::

  • 首先,这个loader的所有逻辑都是设计在pitch阶段进行执行,它的normal函数就是一个空函数。

  • 其次,style-loader做的事情很简单:它获得对应的样式文件内容,然后通过在页面创建style节点。将样式内容赋给style节点然后将节点加入head标签即可。

这样看来是不是很简单,先来抛开你心中对于pitch的疑惑。忘掉它!让我们来动手实现一下它。

实现style-loader核心逻辑

Tip: 真实style-loader源码中无非是对于一些边界情况的兼容处理,比如判断你用esm还是cjs等等之类。

这里我想和你强调的是源码流程,毕竟一个style-loader完整实现我相信对于大家来说稍微费点神都可以看明白。

function styleLoader(source) {
  const script = `
    const styleEl = document.createElement('style')
    styleEl.innerHTML = ${JSON.stringify(source)}
    document.head.appendChild(styleEl)
  `;
  return script;
}

非常简单吧,这里我们通过上边所说的核心思路实现了一个style-loader的功能,最终导出了一个script脚本。

webpack解析到关于require(*.css)文件时,会交给style-loader去处理,最终将返回的script打包成一个module

在通过require(*.css)执行后页面就会添加对应的style节点了。

有兴趣的同学可以自己搭建一个webpack环境验证下,将css结尾的文件交给我们自己写的style-loader去处理即可。

细心的同学可能发现了,这里我们将style-loader的逻辑放在了normal阶段,而源码中放在了pitch阶段。

那么是不是放在normal阶段也可以呢? 接下来,让我们来换一种写法。

style-loader设计成为normal loader

通常我们在使用style-loader处理我们的css样式文件时,都会配合css-loader去一起处理css文件中的引入语句。

样式文件首先会经过css-loader的处理之后才会交给style-loader处理。

这里,让我们使用我们自己style-loader在配合css-loader来处理一下:

yarn add -D css-loader
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  devtool: false,
  mode: 'development',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
  },
  resolveLoader: {
    modules: [path.resolve(__dirname, './loaders'), 'node_modules'],
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [new HtmlWebpackPlugin()],
};

同时我的目录下会存在这样三个业务文件:

image.png

  • src/index.js: 本次打包的入口文件。
// 它做的事情很简单 引入index.css
const styles = require('./index.css');
  • src/index.css: 被入口js文件引入的样式文件。
// index.css 中定义了body的背景色
// 以及通过@import 语句引入了 ./require.css
@import url('./require.css');
body {
  color: red;
}
  • src/require.css:被index.css引入的样式文件。
div {
  color: blue;
}

这是我自己的webpack配置文件,使用了我们刚刚实现的style-loader以及原本的css-loader去处理文件样式文件。

再次运行打包打开生成的html页面:

image.png

我们可以看到body上我们设置的color:red丢失了。

其实本质上出现这个问题的原因是css-loadernormal阶段会将样式文件处理成为js脚本并且返回给style-loadernormal函数中

我们可以在自己的style-loader中打印一下:

function styleLoader(source) {
  console.log(source, 'source');
  const script = `
    const styleEl = document.createElement('style')
    styleEl.innerHTML = ${JSON.stringify(source)}
    document.head.appendChild(styleEl)
  `;
  return script;
}

image.png

source的内容是一个js脚本,我们将js脚本的内容插入到styleEl中去,当然是任何样式也不会生效。

image.png

这是打包后生成htmlstyle节点的内容。

这也就意味着,如果我们将style-loader设计为normal loader的话,我们需要执行上一个loader返回的js脚本,并且获得它导出的内容才可以得到对应的样式内容。

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

style-loader设计成为pitch loader

那么,我们尝试按照源码的思路设计成为pitch loader呢?

这样又会有什么好处呢? 让我们先来分析一下。

首先如果说我们在style-loaderpitch阶段直接返回值的话,那么会发生熔断效应。

上边我们说到过,如果发生熔断效果那么此时会立马掉头执行normal loader,因为style-loader是第一个执行的过程,相当于:

image.png

那么为什么要这么做呢?

我们可以在style-loaderpitch阶段通过require语句引入css-loader处理文件后返回的js脚本,得到导出的结果。然后重新组装逻辑返回给webpack即可。

这样做的好处是,之前我们在normal阶段需要处理的执行css-loader返回的js语句完全不需要自己实现js执行的逻辑。完全交给webpack去执行了。

也许大多数同学仍然不是很明白这是什么意思,没关系。我先来带你实现一下它的基本内容:

function styleLoader(source) {}

// pitch阶段
styleLoader.pitch = function (remainingRequest, previousRequest, data) {
  const script = `
  import style from "!!${remainingRequest}"

    const styleEl = document.createElement('style')
    styleEl.innerHTML = style
    document.head.appendChild(styleEl)
  `;
  return script;
};

module.exports = styleLoader

这里我将style-loader的处理放在了pitch阶段进行处理。

pitch阶段的remainingRequest表示剩余还未处理loader的绝对路径以"!"拼接(包含资源路径)的字符串。

这里我们通过在style-loaderpitch阶段直接返回js脚本:

image.png

此时webpack会将style-loader返回的js脚本进行编译。

将本次返回的脚本编译称为一个module,同时会递归编译本次返回的js脚本,监测到它存在模块引入语句import/require进行递归编译

此时style-loader中返回的module中包含这样一句代码:

 import style from "!!${remainingRequest}"

我们在normal loader阶段棘手的关于css-loader返回值是一个js脚本的问题通过import语句我们交给了webpack去编译。

webpack会将本次import style from "!!${remainingRequest}"重新编译称为另一个module,当我们运行编译后的代码时候:

  • 首先分析const styles = require('./index.css');style-loader pitch处理./index.css并且返回一个脚本。

webpack会将返回的js脚本编译称为一个module,同时分析这个module中的依赖语句进行递归编译。

  • 由于style-loader pitch阶段返回的脚本中存在import语句,那么此时webpack就会递归编译import语句的路径模块。

webpack递归编译style-loader返回脚本中的import语句时,我们在编译完成就会通过import style from "!!${remainingRequest}"style-loader pitch返回的脚本阶段获得css-loader返回的js脚本并执行它,获取到它的导出内容

  • 这里有一点需要强调的是:我们在使用import语句时使用了 !!(双感叹号) 拼接remainingRequest,表示对于本次引入仅仅有inline loader生效。否则会造成死循环。

此时重新打包代码,我们来看看页面的展示效果:

image.png

此时打开生成的页面,你会发现我们的样式又重新生效了。

让我们再来捎带看一眼打包后js代码吧:

image.png

可以清晰的看到./src/index.css被编译称为了一个module,它的内容就是我们style-loader pitch阶段返回的内容。

同时在这个模块的你内部,你发现通过__webpack_require__另一个module,本质上它就是import style from "!!${remainingRequest}"这句话编译后的结果。

image.png

这是import style from "!!${remainingRequest}"语句中${remainingRequest}模块编译后的模块代码,这里只是一个部分截图。

我们只需要看到的确对应的remainingRequest也同时被编译成为了一个module

其实这就是style-loader为什么要实现pitch阶段来进行逻辑处理内容,你说normal不可以吗?

如果一定要用normal的话的确可以,但是我们需要处理太多的import/require从而实现模块引入,这无疑是一种非常糟糕的设计模式。

如果关于webpack打包编译流程有兴趣的同学,可以查看这篇Webapck5核心打包原理全流程解析--300行代码带你实现webpack核心原理。

真实Pitch应用场景总结

通过上述的style-loader的例子,当我们希望将左侧的loader并联使用的时候使用pitch方式无疑是最佳的设计方式。

通过pitch loaderimport someThing from !!${remainingRequest}剩余loader,从而实现上一个loader的返回值是js脚本,将脚本交给webpack去编译执行,这就是pitch loader的实际应用场景。

简单来说,如果在loader开发中你的需要依赖loader其他loader,但此时上一个loadernormal函数返回的并不是处理后的资源文件内容而是一段js脚本,那么将你的loader逻辑设计在pitch阶段无疑是一种更好的方式。

需要额外注意的是需要额外将 remainingRequest 绝对路径处理成为相对 process.cwd(loaderContext.context) 的路径,这是因为 webpack 中的模块生成机制生成的模块ID(路径)都是相对于process.cwd生成的。所以需要保证 require(import) 到对应的模块 ID 所以需要处理为相对路径。

写在文章结尾

在文章的最后,希望和大家稍微来谈一谈为什么我会单独拉出来一个 不常用的pitch loader 来进行长篇大论。

首先感谢每一位可以看到结尾的同学,从我个人角度恰恰是觉得正是这些对于细节的把控性才是一个高级软件工程师必备的素质条件。

在大多数人仅仅停留概念和基础含义时,而你可以轻车熟路的在不同的应用场景下考虑到最佳的应用设计方式,虽然有时用到这种能力的地方的确不是很多。

但是在我看来对于知识深度的把控能力和应用理解能力正是决定一名软件开发者天花板高度的内在体现~

最后的最后,希望大家通过文章可以真正了解loader pitch阶段设计的含义以及何时你应该去考虑使用pitch来设计你的loader