likes
comments
collection
share

从零开始的Webpack原理剖析(五)——loader

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

前言

上篇文章,我们在学习webpack工作流程前的时候,有介绍过loaderplugin是如何去写,并且也简单介绍了其工作的原理,那么接下来的几篇文章,我们将详细的讲解下loaderplugin,然后再写几个复杂的案例,来加深对loaderplugin的理解。

loader的执行顺序

我们在之前的文章中也有提到过,loader的执行顺序是从下到上,从右到左,其实这句话并不是非常的严谨, 在loader的执行过程中,其实是分2个阶段的,比如下边这张图所示。我们可以大致看一下,在后文会详细讲解这2个阶段。 从零开始的Webpack原理剖析(五)——loader

另外,loader其实配置的不同,分为4种,前3种是大家常见的,就是根据webpack.config.js文件里,rulesenforce的不同配置项,分为:1、不配置:默认loaderenforce: pre:前置loader,即优先执行;enforce: post:后置loader,即最后执行,那么还有一种,叫做行内loader,这个大家几乎没见到过,它也不是写在配置文件中的,而是写在具体的loader里边,写法为:

// 用 ! 进行不同loader的分隔
let inline_loader = `inline-loader1!inline-loader2!${entryFile}`;

那么,简单了解了以上概念,我们便继续往下看吧,等看完所有内容,再回过头来看上边的流程图,便能完全理解了。

loader如何被执行

使用loader-runner运行loader

在上篇手写一个简易的webpack文章中,我们是在拿到webpack.config.js配置文件中的module.rules的配置,然后找到loader的位置,直接循环遍历执行相应的loader。但其实这只是为了理解核心概念,才写的简易逻辑;那么在webpackloader到底是如何执行的呢,其实,是有一个专门的库,来执行这一个个loader的,从名字上我们也能够明白这个库是干啥的,这个库的名字叫:loader-runner说了这么多,我们先去用一下这个loader-runner库,看看怎么让loader执行起来。

同样,我们为了演示整个过程,要先初始化新的目录和新建文件:

// step1: 创建文件夹和文件
webpack-loader
    |——src
       |——index.js
    |——loaders
       |——pre-loader1.js
       |——pre-loader2.js
       |——normal-loader1.js
       |——normal-loader2.js
       |——inline-loader1.js
       |——inline-loader2.js
       |——post-loader1.js
       |——post-loader2.js
    |——runner.js
    |——package.json
/* step2: 把每个xxx-loader.js里边都写上如下所示的代码,只是console.log中打印的内容和注释添加的内容
不同,这里只是以pre-loader1.js为例,其他内容自行手动添加(比如normal-loader1.js中就
console.log('normal1执行'),注释就写// normal1,其他文件类似) */
// pre-loader1.js
function loader(input) {
  console.log('pre1执行')
  return input + '// pre1'
}
module.exports = loader
// step3: 安装依赖 
npm install webpack webpack-cli -D

做好准备工作后,我们就要开始在runner.js中配置一下loader,然后再用loader-runner把配置的这些loader运行一下吧~

// runner.js
const { runLoaders } = require('loader-runner')
const path = require('path')
const fs = require('fs')
const entryFile = path.resolve(__dirname, 'src/index.js')

// 配置行内loader
let inlineLoader = `inline-loader1!inline-loader2!${entryFile}`
// 我们这边的rules配置成和webpack.config.js中一样的写法
let rules = [
  {
    test: /\.js$/,
    use: ['normal-loader1', 'normal-loader2']
  },
  {
    test: /\.js$/,
    enforce: 'post',
    use: ['post-loader1', 'post-loader2']
  },
  {
    test: /\.js$/,
    enforce: 'pre',
    use: ['pre-loader1', 'pre-loader2']
  }
]

// 处理行内loader
inlineLoader = inlineLoader.split('!')
// 因为最后一项是入口文件,所以要pop弹出
inlineLoader.pop()
// 声明其他三种类型的loader
let preLoaders = [], postLoaders = [], normalLoaders = []
// 循环遍历rules,来将不同类型的loaderpush到相应数组
for (let i = 0; i < rules.length; i++) {
  let rule = rules[i]
  if (rule.test.test(entryFile)) {
    if (rule.enforce === 'pre') {
      preLoaders.push(...rule.use)
    }else if (rule.enforce === 'post') {
      postLoaders.push(...rule.use)
    }else {
      normalLoaders.push(...rule.use)
    }
  }
}
// 要按照顺序,将各种数组放进loaders数组中
let loaders = [
  ...postLoaders,
  ...inlineLoader,
  ...normalLoaders,
  ...preLoaders
]
// 根据数组名称,解析成绝对路径,不然runLoaders就找不到相应的loader
let resolveLoader = loader => path.resolve(__dirname, 'loaders', loader)
loaders = loaders.map(resolveLoader)
runLoaders({
  resource: entryFile, // 需要处理的资源文件(入口文件)
  loaders, // 使用了哪些loader进行处理
  context: { name: '柠檬soda水', age: 666}, // 保存的一些信息
  readResource: fs.readFile // 读文件的方法
}, (err, result) => {
  console.log('报错信息:', err)
  console.log('转换后的文件内容:', result.result[0].toString('utf8')) // 转换后的结果
  console.log('转换前文件内容:', result.resourceBuffer?.toString('utf8')) // 转换前的结果
})

我们执行node runner.js,查看结果,发现每个loader都能按照顺序执行,文件也被成功的处理了。

从零开始的Webpack原理剖析(五)——loader

有的小伙伴可能来问了,那个行内的loader是咋用呢?为啥要有感叹号连接?首先那个感叹号是特殊的语法,只能在webpack作为打包工具的项目中使用,当然不止有一个感叹号的写法,还有另外2种,那么根据不同的行内loader的配置,可以选择不使用哪些前置或者后置或者普通的loader,如果比较难以理解也没关系,后边会有具体的应用场景,到时候再来详细解释:

符号含义解释
-!不要前置和普通loader
!不要普通loader
!!不要前后置和不同loader,只要内联loader

有的小伙伴可能又要继续问了,loader的类型,感觉pre normal post这三种就够了啊,那个行内的loader是干嘛用的呢?当然有它独特的作用了,别急,我们在后文中自己实现一些常见loader的时候,就会讲到。

pitch阶段

在前文我们提到过,loader在执行的时候,其实不是从右向左执行的,而是先从左向右执行pitch阶段,我们之前写的loader代码,相当于只写了normal阶段的代码,而没有写pitch阶段的代码,所以看上去loader顺序像是从右向左来执行的。如果在某个loaderpitch阶段有了返回值,那就相当于在它以及它右边的loader已经执行完毕了,说直白点,它和它右边的loader,就会不走了。啥意思呢?我们还是以刚才的代码为例,我们将pre-loader1.js文件中的代码做下改动:

// pre-loader1.js
// 这个函数为normal阶段执行的代码
function loader(input) {
  console.log('pre1执行')
  return input + '// pre1'
}
// 添加pitch阶段的代码,实际上也是一个函数
loader.pitch = () => {
  return 'pre1的pitch阶段'
}
module.exports = loader

我们再次执行node runner.js查看结果如下:

从零开始的Webpack原理剖析(五)——loader

神奇的事情发生了,馆擦汗结果可以发现:pre-loader1.js和pre-loader2.jsnormal阶段代码直接没有被执行,观察转换后的文件内容可以发现,被执行的是pre-loader1.jspitch阶段返回的代码。是不是有所顿悟了呢?

接下来我们还原pre-loader1.js中的代码,在pre-loader2.js中的代码中添加pitch阶段,如下:

// pre-loader2.js
function loader(input) {
  console.log('pre2执行')
  return input + '// pre2'
}
loader.pitch = () => {
  return 'pre2的pitch阶段'
}
module.exports = loader

再次执行node runner.js,发现此时结果又有所不同:

从零开始的Webpack原理剖析(五)——loader

此时pre-loader1.jsnormal阶段的代码正常执行了,但是因为pre-loader2.js中添加了pitch阶段,所以pre-loader2.js文件中的normal阶段没有被执行,直接返回的是pitch阶段里边的逻辑。诶,此时此刻,再回过头来看文章开头的那张图,是不是就能看懂了呢?

babel-loader编写

我们之前写的loader要么就是打印了一些信息,要么就是加了下注释,看起来并没有什么卵用和实际意义,当然,这都是为了辅助理解loader;那么接下来,我们要实现一些常见的loader,再次加深对loader如何处理代码全过程的理解。首先,来实现babel-loader

我们创建一个新的干净的目录,来实现我们接下来用到的一些插件:

// 目录结构
webpack-babel-loader
  |——loader
     |——babel-loader.js
  |——src
     |——index.js
  |——webpack.config.js
  |——package.json
// 安装相应的依赖
npm init -y // 初始化package.json文件,并配置 build命令为webpack
npm install webpack webpack-cli @babel/core @babel/preset-env babel-loader -D
// webpack.config.js文件中的配置
const path = require('path')
module.exports = {
  entry: './src/index.js',
  mode: 'development',
  devtool: false,
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'main.js'
  },
  module: {
    rules: [
      {
        test: /.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
            plugins: []
          }
        }
      }
    ]
  }
}
// index.js
let test = () => console.log(333)
test()

babel不熟悉的小伙伴,可以看下我之前写的这篇文章: 小白都能听懂的最新版Babel配置探索——保姆级教学,本篇文章不再详细解释babel配置和相应包的作用。

老规矩,配置完后,我们先尝试运行下,看看有没有配错的地方,执行npm run build命令,发现生成打包文件dist/main.js,里边内容也成功被转译成了ES5语法:

/******/ (() => { // webpackBootstrap
var __webpack_exports__ = {};
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
var test = function test() {
  return console.log(333);
};
test();
/******/ })()
;

配置的没问题,那么我们便开始写自己的babel-loader了,那么如何在webpack中使用自己的loader呢?发包到npm上固然是可以,但是毕竟要单独建个项目,单独写loader,未免有些麻烦,其实我们可以在项目中写自己的loader,然后在配置里边修改下路径就好了,怎么做呢?具体有以下三种方法:

// webpack.config.js文件中的配置
// 方法1:直接给loader指定绝对路径
const path = require('path')
module: {
  rules: [
    {
      test: /\.js$/,
      loader: path.resolve(__dirname, 'loader/babel-loader.js'),
      exclude: /node_modules/
    }
  ]
}
// 方法2:配置resolveLoader的别名与其绝对路径,然后在rules中直接使用别名
const path = require('path')
resolveLoader: {
  alias: {
    'babel-loader': path.resolve(__dirname, 'loader/babel-loader.js')
  }
},
module: {
  rules: [
    {
      test: /\.js$/,
      use: 'babel-loader',
      exclude: /node_modules/
    }
  ]
}
// 方法3:配置resolveLoader的模块,通过修改loader查找的目录(默认在node_modules文件中查找)
const path = require('path')
resolveLoader: {
  // 优先使用我们自定义的loader目录中的loader
  modules: [path.resolve(__dirname, 'loader'), path.resolve(__dirname, 'node_modules')]
},
module: {
  rules: [
    {
      test: /\.js$/,
      use: 'babel-loader',
      exclude: /node_modules/
    }
  ]
}

配置好我们自己的babel-loader路径之后,我们真正开始在babel-loader.js文件中实现的逻辑了。

// babel-loader.js
const core = require('@babel/core')
function BabelLoader(resource) {
  let options = this.getOptions()
  // 使用@babel/core进行转换ast并处理
  const { code } = core.transformSync(resource, options)
  // 返回处理后的结果
  return code
}
module.exports = BabelLoader

loader中的this是指loaderContext上下文对象,里边包含了很多属性和方法,比如拿到我们webpack.config.js配置中的rulesoptions用到的就是getOptions方法,查看具体的可以在webpack官方文档的API模块中看到。

没错,babel-loader就是这么简单,当然我们为了好理解,也只是写了核心的逻辑,至此,我们可以顺便分析一下babel-loader工作的整个流程:

babel-loader只提供了一个转换的函数,并不知道要做什么,需要做事情的是@babel/core将我们传入的源码转化成ast,但是它并不知道如何转化ES语法,所以还需要使用babel的一系列的插件或预设对ast进行转化,得到结果之后@babel/core再将处理过后的ast转化成源代码,进行返回。

less-loader编写

我们继续新建一些文件,为了能更好的看到展示效果:

// step1: 安装依赖包
npm install html-webpack-plugin less -D
/* step2: 在loader文件夹中新建两个文件:less-loader.js和style-loader.js,在src目录下初始化
index.html文件,在body中添加<div id="app">我是标题</div>*/
// step3: 在src目录下新建index.less文件,然后在index.js中 import './index.less':
// index.less文件内容:
@color: green;
#app {
  color: @color
}
// step4: webpack.config.js中增加配置项
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  entry: './src/index.js',
  mode: 'development',
  devtool: false,
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'main.js'
  },
  resolveLoader: {
    alias: {
      'babel-loader': path.resolve(__dirname, 'loader/babel-loader.js'),
      'style-loader': path.resolve(__dirname, 'loader/style-loader.js'),
      'less-loader': path.resolve(__dirname, 'loader/less-loader.js')
    }
  },
  module: {
    rules: [
      {
        test: /.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
            plugins: []
          }
        }
      }, {
        test: /\.less$/,
        use: [
          'style-loader',
          'less-loader'
        ]
      }
    ]
  },
  plugins: [new HtmlWebpackPlugin({
    template: path.resolve(__dirname, 'src/index.html')
  })]
}

新建完文件后,我们便可以写less-loaderstyle-loader了,可能有小伙伴要问了,为啥没有css-loader呢?其实css-loader主要作用就是处理@importurl()语法的,我们这里只演示最简单的案例,所以就不写css-loader了,代码如下所示:

// less-loader.js
const less = require('less')
function loader(source) {
  const callback = this.async()
  less.render(source, { filename: this.resource, compress: true }, (err, output) => {
    callback(err, output.css)
  })
}
module.exports = loader

this.async()是什么意思呢?其实它返回的就是callback函数,我们只需要知道它的用法就是用在异步函数的回调函数中,这样我们自定义的loader才能返回异步处理的结果。其实less-loader的作用就是找到.less结尾的文件,然后调用lessrender方法,将其转化成css并返回给下一个loader。那么,我们继续写style-loader,其实原理更简单,就是生成一个style标签,插入到html文件的头部即可。

// style-loader.js
function loader(source) {
  let script = `
      let style = document.createElement("style");
      style.innerHTML = ${JSON.stringify(source)};
    document.head.appendChild(style);
    `;
  return script;
}
module.exports = loader;

写完两个loader之后,我们执行npm run build命令进行打包,然后在浏览器中打开打包结果的dist/index.html文件,发现样式被加载进来,并且成功生效了。

从零开始的Webpack原理剖析(五)——loader

稍稍进行改造一下

如果,less-loader中返回的不是一个css模块,而是一个CommonJs导出的模块呢?那这就是个js代码了,事实上css-loader就是通过module.exports的方式进行导入的,我们这里为了演示方便,同样忽略css-loader,直接在less-loader通过module.exports进行导出:

let less = require('less')
function loader(source) {
  let callback = this.async()
  less.render(source, { filename: this.resource, compress: true }, (err, output) => {
    callback(err, `module.exports = ${JSON.stringify(output.css)}`) // 这里不再导出css模块了,而是通过module.exports进行导出
  })
}
module.exports = loader

如果这时候,我们再执行npm run build,然后查看页面,会发现样式不能正常生效了,打开控制台我们可以发现,我们style-loader执行过后的结果,是module.exports导出的结果,并不是css样式表,那怎么才能让样式生效呢?

从零开始的Webpack原理剖析(五)——loader

有了前几篇文章的基础,我们可能突然就闪过一个思路:我们用require()进行调用执行,取里边的内容,再插入到<script>标签中,不就可以了么?有了想法我们便可以来写代码了,接下来就会用到前边所说的pitch阶段和行内loader

const path = require('path')
// 因为loader中存在pitch函数,所以不再走normal阶段了,所有的逻辑都在pitch阶段即pitch函数中处理
function loader() {}
loader.pitch = function(remainingRequest) {
  // remainingRequest代表着还有多少个loader没有走到,返回的是loader和文件的绝对路径,并用!进行了分隔
  // 比如此处的remainingRequest即为:xxx/xxx/xxx/less-loader.js!xxx/xxx/xxx/index.less
  // 1.因为我们require中传入的参数是相对路径,所以我们首先要将remainingRequest的路径转化为用!分隔的相对路径
  // 2.this.context为当前文件所在的上下文路径,次数为src文件夹的绝对路径
  let request = remainingRequest.split('!').map(absolutePath => ('./' + path.relative(this.context, absolutePath))).join('!')
  // 3.前边拼接!! 为什么前边要拼接两个感叹号呢,就是上文我们提到过的,表示只使用行内loader,不使用webpack.config.js中配置的loader
  request = '!!' + request // 此时的request为:!!./../loader/less-loader.js!./index.less
  // 4.用行内loader,利用require进行调用,即require('!!./../loader/less-loader.js!./index.less'),注意,加感叹号是上文提到的webpack中loader-runner支持的语法
  let script = `
    const css = require(${JSON.stringify(request)})
    const style = document.createElement('style')
    style.innerHTML = css
    document.head.appendChild(style)
  `
  return script
}
module.exports = loader;

我们可以从代码中看到,最后less-loader依旧被执行了,但是是通过行内loader的方式进行调用的,所以小小总结一下这里loader的执行顺序:

  • 首先style-loader中因为有pitch函数,所以直接走到了pitch阶段,normal阶段和其余在webpack.config.js配置的loader,统统都不再执行了。
  • style-loaderpitch阶段中,又调用了行内loader,即require('!!./../loader/less-loader.js!./index.less')!!代表只执行行内loader,不再执行webpack.config.js中配置的loader了,否则会造成loader调用的死循环。require()执行后,会按照顺序,用less-loader处理index.less文件,把结果进行了返回。

其实我们只要了解了核心的概念,其他的事情,基本就是查询相关API了,至此,简版的style-loader便写完了。

总结

通过上边的两个loader小案例,我们可以发现,loader的作用其实就是提供一个转换的函数,真正的转译是要依靠其他的核心包或插件的,正是所谓的分工明确,各有不同。其实在学习写loader之前,一直感觉是一个高深莫测的事情,但经过一段时间的接触和学习,已经不是当初被问到就会胆战心惊的状态了,那么文中所写的loader也都是用最少的代码,来解释最核心的概念,方便我们每个初学者都能理解,而不是看到陌生的代码就直接溜溜球🪀了。还是那句话,就算我们工作中很少能够接触到这方面的代码,至少在面试造火箭的时候,我们不再方了,在项目中使用别人写的loader的时候,也会比较有底气,能够知道它是怎么执行的,而不是仅仅停留在会配的阶段。