手写一个 Webpack Loader

1,369 阅读4分钟

手写一个 Webpack Loader

注意,本文不是一篇详细的 Loader 开发教程,只是介绍了开发 Loader 的大致流程,让对 Loader 开发感兴趣的读者有一个大致的了解。详细的教程还请查看 Webpack 官方文档 —— Loader Interface

1. Loader 是什么?

如果你的项目是使用 Webpack 作为打包工具,那么你一定跟 loader 打过交道,一个项目先上来就是安装各种 loader。举个例子:

// webpack.config.js

module.exports = {
  // ...其他配置

  module: {
    rules: [
      {
        test: /\.vue$/,
        use: "vue-loader"
      },
      {
        test: /\.(s[ac]|c)ss$/i,
        use: [
          "style-loader",
          "css-loader",
          "postcss-loader",
          "sass-loader"
        ]
      },
      // ...其他 loader
    ]
  }
}

我们使用 vue-loader 来处理 Vue 文件,使用 css-loadersass-loader 等来处理 CSS,此外还有其他各式各样的 loader

loader 在项目中的使用如此广泛,以致于在面试中我们经常会听到这些问题:

  • loader 是什么?
  • 用过哪些 loader
  • 自己写过 loader 吗?

那么,loader 到底是什么?

A loader is a JavaScript module that exports a function.

上面这句话是来自 Webpack 文档中对于 loader 的解释,本质上,一个 loader 就是一个导出为函数的 JavaScript 模块。

由于 Webpack 内部默认只支持处理 JS 和 JSON 文件,所以如果想要处理其他类型的文件,就必须借助 loader 来处理。从这点上来看,说它是一个文件转换器也不为过。

得益于开源社区,我们日常开发需要用到的 loader 基本上都能找得到,但是如果我们真的碰到需要自己开发 loader 的场景,那就只能自己上了。这里有一份官方文档 Loader Interface,建议大家先去看一遍,我帮大家把一些重要的概念摘录出来。

2. Loader 上下文

在开发 loader 的过程中,我们会频繁地使用到 loader 上下文,你可以使用 this 来访问 Webpack 提供的各种属性和方法,通过 this 可以获取当前 loader 的一些信息。更详细的可以看 The Loader Context

function loader(content, map, meta) {
  console.log(this.mode) // 可能的值为:"production", "development", "none"
}

3. Loader 的执行顺序

在下面这段代码中,loader 的执行顺序是倒过来的,并且上一个 loader 输出会成为下一个 loader 的输入:

// webpack.config.js

module.exports = {
  // ...其他配置

  module: {
    rules: [
      {
        test: /\.(s[ac]|c)ss$/i,
        use: [
          "style-loader",
          "css-loader",
          "postcss-loader",
          "sass-loader"
        ]
      }
    ]
  }
}

上面 loader 的执行顺序为:sass-loader -> postcss-loader -> css-loader -> style-loader

4. Loader 的输入和输出

Loader 的输入

/**
 * @param {string|Buffer} content 源文件的内容
 * @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 数据
 * @param {any} [meta] meta 数据,可以是任何内容
 */
function loader(content, map, meta) {
  
}
  • content:必选,上一个 loader 的输出。对于第一个 loader 而言,只有只一个入参,其输入是资源文件的内容。
  • map:可选,SourceMap 数据。
  • meta:可选,元数据,可以是任何内容,用于 loader 之间传递额外信息,Webpack 不会对其做处理。

Loader 的输出

loader 的返回的结果应该是 String 或者 Buffer 类型(可以被转成 String 类型)。

对于最后一个 loader 而言,其结果代表了模块的 JavaScript 源码,即 JavaScript 代码字符串。这个结果会传给 Webpack Compiler 进行下一步的处理。

举个例子,有以下的 SCSS 文件:

.home {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  .button {
    width: 60px;
  }
}

sass-loader 的输出,将 SCSS 编译成 CSS

.home {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.home .button {
  width: 60px;
}

postcss-loader 的输出,自动添加浏览器前缀:

.home {
    width: 100%;
    display: -webkit-box;
    display: -ms-flexbox;
    display: flex;
    -webkit-box-orient: vertical;
    -webkit-box-direction: normal;
        -ms-flex-direction: column;
            flex-direction: column;
    -webkit-box-align: center;
        -ms-flex-align: center;
            align-items: center;
  }
  .home .button {
    width: 60px;
  }

css-loader 的输出,这个结果会传给 Webpack Compiler 进行下一步的处理:

// Imports
import ___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___ from "../node_modules/css-loader/dist/runtime/noSourceMaps.js";
import ___CSS_LOADER_API_IMPORT___ from "../node_modules/css-loader/dist/runtime/api.js";
var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___);
// Module
___CSS_LOADER_EXPORT___.push([module.id, ".home[data-v-7ba5bd90] {\n  width: 100%;\n  display: -webkit-box;\n  display: -ms-flexbox;\n  display: flex;\n  -webkit-box-orient: vertical;\n  -webkit-box-direction: normal;\n      -ms-flex-direction: column;\n          flex-direction: column;\n  -webkit-box-align: center;\n      -ms-flex-align: center;\n          align-items: center;\n}\n.home .button[data-v-7ba5bd90] {\n  width: 60px;\n}", ""]);        
// Exports
export default ___CSS_LOADER_EXPORT___;

5. 如何返回 Loader 的处理结果

loader 有同步 loader 和异步 loader

同步 Loader

对于同步 loader 而言,可以使用 returnthis.callback() 来返回转换后的结果:

function loader(content, map, meta) {
  return syncFn(content)
}

又或者:

function loader(content, map, meta) {
  this.callback(null, syncFn(content), map, meta)
  return // 当调用 `this.callback()` 函数时,总是返回 undefined
}

异步 Loader

对于异步 loader 而言,使用 this.async() 来获取 callback() 函数,然后再返回结果的时候调用:

async function loader(content, map, meta) {
  const callback = this.async()
    
  let result
  try {
    result = await asyncFn(content)
  } catch (error) {
    callback(error)
  }
  
  callback(null, result, map, meta)
}

不管是同步 Loader 还是异步 Loader,其回调函数 callback() 都使用 Error-First 风格,即第一个参数为错误信息,如果没有错误,则设置为 null。如果你写过 Node.js,那么应该很熟悉这种风格。

了解以上这些信息,你就可以自己动手实现一个 loader 了。

6. 自己实现一个 Loader

Demo 源码在这里,你也可以下载下来自己折腾:webpack-learning

首先,新建目录 custom-loader,入口文件为 index.js,用于存放我们的自定义 loader

webpack-loader-implementing_01.png

// custom-loader/index.js

function loader(content, map, meta) {
  const logger = this.getLogger()
  
  logger.info('[custom-loader] running...')
    
  this.callback(null, content, map, meta)
    
  return
}

module.exports = loader

接着,引入 custom-loader

// webpack.config.js

module.exports = {
  // ...其他配置
  
  module: {
    rules: [
      {
        test: /\.(s[ac]|c)ss$/i,
        use: [
          "style-loader",
          "css-loader",
          "postcss-loader",
          "sass-loader",
          "custom-loader"
        ]
      }
    ]
  }
}

按照我们前面得到的信息,这些 loader 的执行顺序为:custom-loader -> sass-loader -> ...

然后,执行 yarn dev,这时候会报错,说找不到 custom-loader

webpack-loader-implementing_02.png

Module not found: Error: Can't resolve 'custom-loader' in xxx

一般情况下,我们都是通过 NPM/Yarn 来安装 loader 的:

# npm
npm install sass-loader --save-dev

#yarn
yarn add sass-loader -D

如果要使用本地的 loader,我们可以使用 NPM Link;又或者配置 Webpack 的 resolveLoader 属性,告诉 Webpack 去哪里找到这个 loader

// webpack.config.js

module.exports = {
  // ...其他配置
  
  module: {
    rules: [
      {
        test: /\.(s[ac]|c)ss$/i,
        use: [
          "style-loader",
          "css-loader",
          "postcss-loader",
          "sass-loader",
          "custom-loader"
        ]
      }
    ]
  },
  
  // `custom-loader`
  resolveLoader: {
    alias: {
      "custom-loader": path.resolve(__dirname, "./custom-loader/index.js")
    }
  }
}

再次运行 yarn dev,如无意外,可以看到成功运行的信息:

webpack-loader-implementing_03.png

[custom-loader] running...

下面我们以一个 Vue demo 为例,来简单实现一个 loader

// App.vue

<template>
  <div class="home">
    <img class="img" src="./assets/logo.png" alt="logo" />
    <h1 class="title">Hello, {{ msg }}!</h1>
    <button class="button" @click="toggle">toggle</button>
    <div>{{ greeting }}</div>
  </div>
</template>

<script setup>
import { ref } from "vue";

const msg = ref("Vue.js");
const greeting = ref("");

function toggle() {
  msg.value = msg.value === "Vue.js" ? "Webpack" : "Vue.js";
}
</script>

<style lang="scss" scoped>
.home {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  .button {
    width: 60px;
  }
}
</style>

示例截图:

webpack-loader-implementing_04.png

注意此时的 title,只有默认样式。接下来,我们改造一下 custom-loader

// custom-loader/index.js

function loader(content, map, meta) {
  const logger = this.getLogger()

  logger.info('[custom-loader] running...')

  logger.info('input content:', content)
  
  content = `
    .home {
      width: 100%;
      display: flex;
      flex-direction: column;
      align-items: center;
      .title {
        width: 360px;
        padding: 5px 0;
        text-align: center;
        color: #ffffff;
        background-color: #1e90ff;
      }
      .button {
        width: 60px;
      }
    }
  `

  this.callback(null, content, map, meta)
    
  logger.info('[custom-loader] done.')

  return
}

module.exports = loader

在上面的例子中,我们打印了输入的内容,同时又修改了输入,增加了 title 的样式,运行 yarn dev 看一下效果:

webpack-loader-implementing_05.png

示例截图:

webpack-loader-implementing_06.png

注意此时 title 的样式,在 App.vue 中,我们并没有设置 title 的样式,但在页面中它却有了自定义样式,这是因为我们在 custom-loader 中手动添加了。

就这样,一个自定义 loader 就完成了,是不是很容易?你也来动手试一试吧。

7. 再进一步,支持 options

一个 loader,支持 options 是很常见的事情吧,就像这样:

// webpack.config.js

module.exports = {
  // ...其他配置
  
  module: {
    rules: [
      {
        test: /\.(s[ac]|c)ss$/i,
        use: [
          "style-loader",
          "css-loader",
          "postcss-loader",
          {
            loader: "sass-loader",
            options: {
              sourceMap: true
            }
          }
        ]
      }
    ]
  }
}

要实现这样的功能也很简单,我们可以在 loader 内通过 this.getOptions() 拿到用户传入的 options:

// custom-loader/index.js

function loader(content, map, meta) {
  const logger = this.getLogger()
  const options = this.getOptions()

  logger.info('[custom-loader] running...')
    
  // 拿到用户传入的 options
  logger.info('options:', options)
  
  logger.info('input content:', content)
  
  content = `
    .home {
      width: 100%;
      display: flex;
      flex-direction: column;
      align-items: center;
      .title {
        width: 360px;
        padding: 5px 0;
        text-align: center;
        color: #ffffff;
        background-color: #1e90ff;
      }
      .button {
        width: 60px;
      }
    }
  `
  
  this.callback(null, content, map, meta)
  
  logger.info('[custom-loader] done.')
  
  return
}

module.exports = loader

然后,在使用的时候自定义 options:

// webpack.config.js

module.exports = {
  // ...其他配置
  
  module: {
    rules: [
      {
        test: /\.(s[ac]|c)ss$/i,
        use: [
          "style-loader",
          "css-loader",
          "postcss-loader",
          "sass-loader",
          "custom-loader",
          {
            loader: "custom-loader",
            // 自定义 options
            options: {
              value: "str",
              count: 2
            }
          }
        ]
      }
    ]
  }
}

webpack-loader-implementing_07.png

8. 如何校验 options?

看到这个问题,你可能会想这样来实现:

// custom-loader/index.js

function loader(content, map, meta) {
  const options = this.getOptions()

  const { value, count } = options

  if (typeof value !== 'string') {
    this.callback(new Error("`value` 必须为 string 类型"))
  }

  if (typeof count !== 'number') {
    this.callback(new Error("`count` 必须为 number 类型"))
  }

  // ...其他代码
  
  return
}

module.exports = loader

在 options 少的时候,这样做是没问题的,但是如果有多个 option 的话,这样校验很麻烦,并且也不够灵活,每次修改 option 类型就需要修改代码。如果能够把校验 options 和 loader 的代码实现分开来就好了。

实际上,this.getOptions(schema) 支持传入 JSON Schema 来校验用户传入的 options,JSON Schema 可以对属性的类型、是否必须进行配置。

配置 JSON Schema

// custom-loader/options.json

{
  "title": "Custom Loader options",
  "type": "object",
  "properties": {
    "value": {
      "description": "`value` 必须为 string 类型",
      "type": "string"
    },
    "count": {
      "description": "`count` 必须为 number 类型",
      "type": "number"
    }
  },
    
  // `value` 为必须
  "required": [
    "value"
  ]
}

修改一下 custom-loader

// custom-loader/index.js

// 引入 JSON Schema
const schema = require("./options.json")

function loader(content, map, meta) {
  const logger = this.getLogger()

  // 在获取 options 的时候,传入 JSON Schema,Webpack 会自动帮我们校验
  const options = this.getOptions(schema)
  
  // ...其他代码
  
  return
}

module.exports = loader

使用的时候,不传 value

// webpack.config.js

module.exports = {
  // ...其他配置
  
  module: {
    rules: [
      {
        test: /\.(s[ac]|c)ss$/i,
        use: [
          "style-loader",
          "css-loader",
          "postcss-loader",
          "sass-loader",
          "custom-loader",
          {
            loader: "custom-loader",
            // 自定义 options
            options: {
              // value: "str",
              count: 2
            }
          }
        ]
      }
    ]
  }
}

再次运行 yarn dev,这时候会报错,提示我们缺少 value

webpack-loader-implementing_08.png

另外,你也可以使用 schema-utils 来校验,用法也差不多。

9. 总结

对于自定义 loader 的实现,总结起来就三点:

  • 输入,上一个 loader 产生的结果(起始 loader 的输入是资源文件的内容)
  • 输出,输出的结果应该是 StringBuffer 类型
  • 实现,将输入变成输出的过程

想进一步了解的可以看一下 sass-loader 的源码,代码量不多,也很容易看懂。

另外,loader 的职责应该是单一的,比如 sass-loader 就只负责将 Sass/SCSS 文件编译成 CSS。至于编译成 CSS 后的事,是将其进行压缩,亦或是添加浏览器前缀,就不是 sass-loader 要做的了。

10. 写在最后

如果这篇文章能对你哪怕有一丁点帮助的话,我会感到非常开心。然而由于能力有限,如果文中有错误的话,还请多多指教,我在此先行谢过。

最后,希望看完这篇文章的大家都能在不远的未来变得更加牛逼!

参考资料