webpack 之 loader

268 阅读3分钟

本文代码:github.com/supersoup/j…

一、为啥要用 loader?

配置过 webpack 的小伙伴都记得,webpack 繁琐的格式配置,其中一项就是不同文件后缀需要配置一堆 loader。那么,为什么我们要使用 loader 呢?

因为 webpack 只认识 js, json 这两种文件。

webpack 在编译过程中,当读取了一个 js 文件的内容:

  1. 会将它转换 ast 语法树,然后分析其中 cjs 或 esm 格式的依赖
  2. 然后再去 fs.readFile 这个依赖的内容(buffer)
  3. 将读取到的 buffer 通过 toString 方法转化成代码字符串

如果这个代码字符串是合法的 js 或 json,则按照深度优先的策略,递归,重复上面的动作,直到穷尽依赖。如果不合法,就会报错。

那我们 loader 的作用,就是把对应的,原本不是合法 js 文件的内容,转化为合法的 js 代码字符串。

历史上 loader 的雏形

起初,要使用一个带有图片,样式等资源的组件,是非常繁琐的:

  • 要按照依赖顺序要求引入 js 之外
  • 按照需要在头部引入 css 文件
  • 在 html 文件中粘贴一大段 html 代码
  • 还需要正确引入图片的地址(好在背景图一般是在 css 中靠相对路径来维护,不需要使用者考虑)

自从 js 模块化方案的兴起,开发人员也开始对 js 管理资源有了诉求:

对于一个组件来说,我们希望只考虑在需要的时候一行代码引入它,就可以使用了。至于它依赖了哪些 js、css、html、image,都由这个组件自己维护。

比如 require.js 的时代,我们创建一个符合 AMD 规范的 DatePicker 组件,它大概会这样:

define([
   'jquery',   // 引入第三方依赖的模块,需要配置 alias
   './Picker', // 相对路径,引入和自己同目录的 Picker.js 文件
   'text!./index.ejs', // 将 index.ejs 作为字符串引入,供下方调用
   'css!./index.css',  // 将 index.css 作为 <style> 内容,自动插入 <head> 中
], function($, Picker, html) {
   return function init(options) {
      const picker = new Picker(html)
      $(document).on(options.type, options.className, function(event) {
         picker.show(event.target)
      })
      $(document).on('click', function() {
         picker.hide()
      })
   }
})

注意其中的 text!./index.ejscss!./index.css。它很像 webpack 的内联式 loader 对不对?其实它们起的作用也是类似的。

在 require.js 时代,就有这样的使用方式。而到了 webpack 的时代,loader 的使用就更普遍。

二、一个简单的例子:raw-loader

比如我们看下面一个简单的例子,在 js 文件中引用 ejs 作为模板:

在 webpack 中的使用

index.js:

import html from './hello.ejs'
console.log(html)

hello.ejs:

<div><%= a %></div>

如果直接使用 webpack 打包会报错:

image.png

我们为 webpack.config.js 加上 raw-loader 的配置:

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

查看构建后的产物:

((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  "use strict";
  __webpack_require__.r(__webpack_exports__);
  __webpack_require__.d(__webpack_exports__, {
    "default": () => (__WEBPACK_DEFAULT_EXPORT__)
  });
  const __WEBPACK_DEFAULT_EXPORT__ = ("<div><%= a %></div>");
})

直接看上面的构件产物,可能觉得很复杂。因为 raw-loader 处理过后,webpack 继续对它进行了打包编译。

loader 究竟做了什么?

其实,我们更想探究 loader 单独做了什么事情。这里,我们可以使用 loader-runner 这个库。webpack 底层是调用 loader-runner 来执行 loaders 对资源的转化。

我们可以直接使用这个库,来看到这一“单元步骤”所做的工作:

编写一个 useLoaderRunner.js :

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

runLoaders({
   resource: path.resolve(__dirname, './src/hello.ejs'),
   loaders: ['raw-loader'],
   readResource: fs.readFile.bind(fs)
}, (err, result) => {
   console.log(result.result[0])
})

这个字符串的结果是:

image.png

原来,raw-loader 就是把原始文件内容读成字符串,然后给她包裹 export default "",它就变成了 webpack 所认识的 js 代码了。

三、项目中 loader 使用常见场景

脚本语法转换

主要是 babel-loader 和 ts-loader。

这两者其实都有较多的配置工作,所以更推荐使用 .babelrc 和 tsconfig.json 把它们的配置抽离出去。

然后配置兼容不同后缀:

{
   test: /\.jsx?$/,
   exclude: /(node_modules|bower_components)/,
   use: ['babel-loader'],
},
{
   test: /\.tsx?/,
   exclude: /(node_modules|bower_components)/,
   use: ['babel-loader', 'ts-loader'],
}

有几个性能优化的点:

  • exclude 排除匹配,表示 node_modules 里这个后缀的文件,不需要经过 babel-loader
  • babel-loader 相较 babel 本身,多了 cacheDirectory 配置,可以提高效率

样式转化,插入或提取

主要有 css-loader, style-loader, MiniCssExtractPlugin.loader 以及 样式的语法转换:less-loader, sass-loader, postcss-loader 等

比如:

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

样式需要注意的点:

  • 如果需要把 style 文件提取出来,则使用 MiniCssExtractPlugin.loader 代替 style-loader
  • css module 默认的匹配是 /\.module\.\w+$/i

文件转换

webpack v4 的版本,使用的是:

file-loader:把依赖的文件输出到目标地址,并更换 js 和 css 中的路径 url-loader: 把图片内容转 base64 数据,替换 js 和 css 的路径使用 raw-loader:把 txt 的内容直接返回。

webpack v5 之后,改为配置 Rule.typeasset/resource, asset/inline, asset/source。比如:

// 输出到目录,并改名,等同于 file-loader
{
   test: /.png$/,
   type: 'asset/resource',
   generator: {
      filename: 'asset/[name].[hash][ext]',
   },
},

// 转 base 64,等同于 url-loader
{
   test: /.webp$/,
   type: 'asset/inline',
},

// 会把txt内容直接返回, 等同于 raw-loader
{
   test: /.txt$/,
   type: 'asset/source',
}

四、loader 的执行次序

四种 loader

上面的例子,我们配置的都属于普通 loader

实际上,每一条 rule 有一个 enforce 属性,可以设置为 prepost

另外,loader 除了写在配置文件里面,还可以用内联的写法。

所以总共我们有四种 loader,分别是:

  • post 后置
  • inline 内联/行内,不推荐在源代码中编写
  • normal 普通/正常
  • pre 前置

四种 loader 分别是四个数组,这四个数组按上面的顺序,从左往右拼成一个大的执行数组。

注意:webpack v4 的时候还有 cli 可以配置 loader,v5 已经废除。

内联 loader 的前缀

webpack 的官方文档明确表示:

不应使用内联 loader 和 ! 前缀,因为它是非标准的。它们可能会被 loader 生成代码使用

是什么意思呢?原来,内联 loader 是可以添加前缀来忽略部分 loader 组的。

  • 所有普通 loader 可以通过在请求中加上 ! 前缀来忽略(覆盖)。
  • 所有普通和前置 loader 可以通过在请求中加上 -! 前缀来忽略(覆盖)。
  • 所有普通,后置和前置 loader 可以通过在请求中加上 !! 前缀来忽略(覆盖)。

为什么有这个机制呢?为了让生成的代码使用内联 loader,而生成的代码应该能选择性地屏蔽用户配置的部分。

loader 执行的两个阶段

loader 本身是一个函数,它们是从右到左被调用:

  • 最右侧的 loader 应该是接收一个参数,源文件的内容字符串。
  • 最左侧的 loader 应该返回 webpack 所期望的 js 或 json 代码字符串。
  • 中间的 loader 形成调用链条传递,可以是同步,也可以是异步

但是有些情况,loader 只关心请求后面的元数据,而不关心右侧 loader 执行的结果。这个时候如果还要等右侧 loader 一个个执行,就凭白浪费了性能。所以 loader 还提供了一个 pitch 方法,挂载到 loader 函数上。即:

function loader1() {}
loader1.pitch = function() {}
module.exports = loader1

最终整合之后的调用方式是:

  • 首先根据 enforce 和内联前缀,整合成一个实际调用的最终 loader 执行数组
  • 然后从左往右调用 pitch 方法,如果有返回就传递给左侧 loader 本身执行
  • pitch 阶段完毕,如果都没有返回,则读取文件内容,把它传递给最右侧 loader 作为参数
  • loader 本身从右往左执行,前一个的返回值作为后一个的参数,全部执行完传递给 webpack 进行下一步操作

这个流程代表性的 loader 就是 style-loader。

五、自定义 loader

有时候我们会自己编写 loader 并使用。使用肯定会把它发布为一个 npm 公有包或者私有包,然后直接调用。但是调试阶段如果也这样就很麻烦。

调试的方式

  • npm link 本地链接
  • 把之前的 module.rules[].use.loader'xxx-loader' 改为某个 loader 的绝对路径 path.resolve('./my-loader.js')
  • 配置 resolveLoader

编写自定义 loader

我们接下来编写 2 个最简单的 loader:

它们的功能是给 .mjs 结尾的文件,添加一行 console.log

两个 loader 分别以同步和异步的方式来实现。

同步 loader

plugins/custom1-loader.js

function custom1Loader(code) {
   return `console.log('custom2Loader')\n${code}`
}

module.exports = custom1Loader

同步的 loader 接收上一步的参数,如果它是最右侧,那么 code 代表源文件的内容。

我们做的事情就是拼一个字符串,然后返回。

异步 loader

plugins/custom2-loader.js

function custom2Loader(code) {
   const callback = this.async();
   setTimeout(function () {
      callback(null, `console.log('custom1Loader')\n${code}`)
   }, 100)
}

module.exports = custom2Loader

异步 loader 一般想象的返回 promise 的想法不同。

它首先调用 this.async 得到一个 callback 方法。 当异步执行完毕需要返回结果时,需要传两个参数,第一个参数表示错误,如果有就会报错。第二个参数才是传给下一个 loader 或者 webpack 下一步的值。

调用

配置 wepback.config.js

module: {
   rules: [
      {
         test: /.mjs$/,
         use: [
            path.resolve(__dirname, 'plugins/custom2-loader.js'),
            path.resolve(__dirname, 'plugins/custom1-loader.js'),
         ]
      }
   ]
}

然后我们可以在构建的结果中看到这个模块最上面出现了:

console.log('custom1Loader')
console.log('custom2Loader')
// 模块的原始内容...