手把手教你撸一个 Webpack Loader

文:小 boy(沪江网校Web前端工程师)css

本文原创,转载请注明做者及出处html

webpack

常常逛 webpack 官网的同窗应该会很眼熟上面的图。正如它宣传的同样,webpack 能把左侧各类类型的文件(webpack 把它们叫做「模块」)统一打包为右边被通用浏览器支持的文件。webpack 就像是魔术师的帽子,放进去一条丝巾,变出来一只白鸽。那这个「魔术」的过程是如何实现的呢?今天咱们从 webpack 的核心概念之一 —— loader 来寻找答案,并着手实现这个「魔术」。看完本文,你能够:前端

  • 知道 webpack loader 的做用和原理。
  • 本身开发贴合业务需求的 loader。

什么是 Loader ?

在撸一个 loader 前,咱们须要先知道它究竟是什么。本质上来讲,loader 就是一个 node 模块,这很符合 webpack 中「万物皆模块」的思路。既然是 node 模块,那就必定会导出点什么。在 webpack 的定义中,loader 导出一个函数,loader 会在转换源模块(resource)的时候调用该函数。在这个函数内部,咱们能够经过传入 this 上下文给 Loader API 来使用它们。回顾一下头图左边的那些模块,他们就是所谓的源模块,会被 loader 转化为右边的通用文件,所以咱们也能够归纳一下 loader 的功能:把源模块转换成通用模块。node

Loader 怎么用 ?

知道它的强大功能之后,咱们要怎么使用 loader 呢?webpack

1. 配置 webpack config 文件

既然 loader 是 webpack 模块,若是咱们要使其生效,确定离不开配置。我这里收集了三种配置方法,任你挑选。git

单个 loader 的配置

增长 config.module.rules 数组中的规则对象(rule object)。github

let webpackConfig = {
    //...
    module: {
        rules: [{
            test: /\.js$/,
            use: [{
                //这里写 loader 的路径
                loader: path.resolve(__dirname, 'loaders/a-loader.js'), 
                options: {/* ... */}
            }]
        }]
    }
}

多个 loader 的配置

增长 config.module.rules 数组中的规则对象以及 config.resolveLoaderweb

let webpackConfig = {
    //...
    module: {
        rules: [{
            test: /\.js$/,
            use: [{
                //这里写 loader 名便可
                loader: 'a-loader', 
                options: {/* ... */}
            }, {
                loader: 'b-loader', 
                options: {/* ... */}
            }]
        }]
    },
    resolveLoader: {
        // 告诉 webpack 该去那个目录下找 loader 模块
        modules: ['node_modules', path.resolve(__dirname, 'loaders')]
    }
}

其余配置

也能够经过 npm link 链接到你的项目里,这个方式相似 node CLI 工具开发,非 loader 模块专用,本文就很少讨论了。npm

2. 简单上手

配置完成后,当你在 webpack 项目中引入模块时,匹配到 rule (例如上面的 /\.js$/)就会启用对应的 loader (例如上面的 a-loader 和 b-loader)。这时,假设咱们是 a-loader 的开发者,a-loader 会导出一个函数,这个函数接受的惟一参数是一个包含源文件内容的字符串。咱们暂且称它为「source」。json

接着咱们在函数中处理 source 的转化,最终返回处理好的值。固然返回值的数量和返回方式依据 a-loader 的需求来定。通常状况下能够经过 return 返回一个值,也就是转化后的值。若是须要返回多个参数,则须调用 this.callback(err, values...) 来返回。在异步 loader 中你能够经过抛错来处理异常状况。Webpack 建议咱们返回 1 至 2 个参数,第一个参数是转化后的 source,能够是 string 或 buffer。第二个参数可选,是用来看成 SourceMap 的对象。

3. 进阶使用

一般咱们处理一类源文件的时候,单一的 loader是不够用的(loader 的设计原则咱们稍后讲到)。通常咱们会将多个 loader 串联使用,相似工厂流水线,一个位置的工人(或机器)只干一种类型的活。既然是串联,那确定有顺序的问题,webpack 规定 use 数组中 loader 的执行顺序是从最后一个到第一个,它们符合下面这些规则:

  • 顺序最后的 loader 第一个被调用,它拿到的参数是 source 的内容
  • 顺序第一的 loader 最后被调用, webpack 指望它返回 JS 代码,source map 如前面所说是可选的返回值。
  • 夹在中间的 loader 被链式调用,他们拿到上个 loader 的返回值,为下一个 loader 提供输入。

咱们举个例子:

webpack.config.js

{
        test: /\.js/,
        use: [
            'bar-loader',
            'mid-loader',
            'foo-loader'
        ]
    }

在上面的配置中:

  • loader 的调用顺序是 foo-loader -> mid-loader -> bar-loader。
  • foo-loader 拿到 source,处理后把 JS 代码传递给 mid,mid 拿到 foo 处理过的 “source” ,再处理以后给 bar,bar 处理完后再交给 webpack。
  • bar-loader 最终把返回值和 source map 传给 webpack。

用正确的姿式开发 Loader

了解了基本模式后,咱们先不急着开发。所谓磨刀不误砍柴工,咱们先看看开发一个 loader 须要注意些什么,这样能够少走弯路,提升开发质量。下面是 webpack 提供的几点指南,它们按重要程度排序,注意其中有些点只适用特定状况。

1.单一职责

一个 loader 只作一件事,这样不只可让 loader 的维护变得简单,还能让 loader 以不一样的串联方式组合出符合场景需求的搭配。

2.链式组合

这一点是第一点的延伸。好好利用 loader 的链式组合的特型,能够收获意想不到的效果。具体来讲,写一个能一次干 5 件事情的 loader ,不如细分红 5 个只能干一件事情的 loader,也许其中几个能用在其余你暂时还没想到的场景。下面咱们来举个例子。

假设如今咱们要实现经过 loader 的配置和 query 参数来渲染模版的功能。咱们在 “apply-loader” 里面实现这个功能,它负责编译源模版,最终输出一个导出 HTML 字符串的模块。根据链式组合的规则,咱们能够结合另外两个开源 loader:

  • jade-loader 把模版源文件转化为导出一个函数的模块。
  • apply-loader 把 loader options 传给上面的函数并执行,返回 HTML 文本。
  • html-loader 接收 HTMl 文本文件,转化为可被引用的 JS 模块。

事实上串联组合中的 loader 并不必定要返回 JS 代码。只要下游的 loader 能有效处理上游 loader 的输出,那么上游的 loader 能够返回任意类型的模块。

3.模块化

保证 loader 是模块化的。loader 生成模块须要遵循和普通模块同样的设计原则。

4.无状态

在屡次模块的转化之间,咱们不该该在 loader 中保留状态。每一个 loader 运行时应该确保与其余编译好的模块保持独立,一样也应该与前几个 loader 对相同模块的编译结果保持独立。

5.使用 Loader 实用工具

请好好利用 loader-utils 包,它提供了不少有用的工具,最经常使用的一个就是获取传入 loader 的 options。除了 loader-utils 以外包还有 schema-utils 包,咱们能够用 schema-utils 提供的工具,获取用于校验 options 的 JSON Schema 常量,从而校验 loader options。下面给出的例子简要地结合了上面提到的两个工具包:

import { getOptions } from 'loader-utils';
import { validateOptions } from 'schema-utils';

const schema = {
  type: object,
  properties: {
    test: {
      type: string
    }
  }
}

export default function(source) {
    const options = getOptions(this);

    validateOptions(schema, options, 'Example Loader');

    // 在这里写转换 source 的逻辑 ...
    return `export default ${ JSON.stringify(source) }`;
};

loader 的依赖

若是咱们在 loader 中用到了外部资源(也就是从文件系统中读取的资源),咱们必须声明这些外部资源的信息。这些信息用于在监控模式(watch mode)下验证可缓存的 loder 以及从新编译。下面这个例子简要地说明了怎么使用 addDependency 方法来作到上面说的事情。
loader.js:

import path from 'path';

export default function(source) {
    var callback = this.async();
    var headerPath = path.resolve('header.js');

    this.addDependency(headerPath);

    fs.readFile(headerPath, 'utf-8', function(err, header) {
        if(err) return callback(err);
        //这里的 callback 至关于异步版的 return
        callback(null, header + "\n" + source);
    });
};

模块依赖

不一样的模块会以不一样的形式指定依赖。好比在 CSS 中咱们使用 @importurl(...) 声明来完成指定,而咱们应该让模块系统解析这些依赖。

如何让模块系统解析不一样声明方式的依赖呢?下面有两种方法:

  • 把不一样的依赖声明统一转化为 require 声明。
  • 经过 this.resolve 函数来解析路径。

对于第一种方式,有一个很好的例子就是 css-loader。它把 @import 声明转化为 require 样式表文件,把 url(...) 声明转化为 require 被引用文件。

而对于第二种方式,则须要参考一下 less-loader。因为要追踪 less 中的变量和 mixin,咱们须要把全部的 .less 文件一次编译完毕,因此不能把每一个 @import 转为 require。所以,less-loader 用自定义路径解析逻辑拓展了 less 编译器。这种方式运用了咱们刚才提到的第二种方式 —— this.resolve 经过 webpack 来解析依赖。

若是某种语言只支持相对路径(例如 url(file) 指向 ./file)。你能够用 ~ 将相对路径指向某个已经安装好的目录(例如 node_modules)下,所以,拿 url 举例,它看起来会变成这样:url(~some-library/image.jpg)

代码公用

避免在多个 loader 里面初始化一样的代码,请把这些共用代码提取到一个运行时文件里,而后经过 require 把它引进每一个 loader。

绝对路径

不要在 loader 模块里写绝对路径,由于当项目根路径变了,这些路径会干扰 webpack 计算 hash(把 module 的路径转化为 module 的引用 id)。loader-utils 里有一个 stringifyRequest 方法,它能够把绝对路径转化为相对路径。

同伴依赖

若是你开发的 loader 只是简单包装另一个包,那么你应该在 package.json 中将这个包设为同伴依赖(peerDependency)。这可让应用开发者知道该指定哪一个具体的版本。
举个例子,以下所示 sass-loadernode-sass 指定为同伴依赖:

"peerDependencies": {
  "node-sass": "^4.0.0"
}

Talk is cheep

以上咱们已经为砍柴磨好了刀,接下来,咱们动手开发一个 loader。

若是咱们要在项目开发中引用模版文件,那么压缩 html 是十分常见的需求。分解以上需求,解析模版、压缩模版其实能够拆分给两给 loader 来作(单一职责),前者较为复杂,咱们就引入开源包 html-loader,然后者,咱们就拿来练手。首先,咱们给它取个响亮的名字 —— html-minify-loader

接下来,按照以前介绍的步骤,首先,咱们应该配置 webpack.config.js ,让 webpack 能识别咱们的 loader。固然,最最开始,咱们要建立 loader 的 文件 —— src/loaders/html-minify-loader.js

因而,咱们在配置文件中这样处理:
webpack.config.js

module: {
    rules: [{
        test: /\.html$/,
        use: ['html-loader', 'html-minify-loader'] // 处理顺序 html-minify-loader => html-loader => webpack
    }]
},
resolveLoader: {
    // 由于 html-loader 是开源 npm 包,因此这里要添加 'node_modules' 目录
    modules: [path.join(__dirname, './src/loaders'), 'node_modules']
}

接下来,咱们提供示例 html 和 js 来测试 loader:

src/example.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    
</body>
</html>

src/app.js

var html = require('./expamle.html');
console.log(html);

好了,如今咱们着手处理 src/loaders/html-minify-loader.js。前面咱们说过,loader 也是一个 node 模块,它导出一个函数,该函数的参数是 require 的源模块,处理 source 后把返回值交给下一个 loader。因此它的 “模版” 应该是这样的:

module.exports = function (source) {
    // 处理 source ...
    return handledSource;
}

module.exports = function (source) {
    // 处理 source ...
    this.callback(null, handledSource)
    return handledSource;
}

注意:若是是处理顺序排在最后一个的 loader,那么它的返回值将最终交给 webpack 的 require,换句话说,它必定是一段可执行的 JS 脚本 (用字符串来存储),更准确来讲,是一个 node 模块的 JS 脚本,咱们来看下面的例子。

// 处理顺序排在最后的 loader
module.exports = function (source) {
    // 这个 loader 的功能是把源模块转化为字符串交给 require 的调用方
    return 'module.exports = ' + JSON.stringify(source);
}

整个过程至关于这个 loader 把源文件

这里是 source 模块

转化为

// example.js
module.exports = '这里是 source 模块';

而后交给 require 调用方:

// applySomeModule.js
var source = require('example.js'); 

console.log(source); // 这里是 source 模块

而咱们本次串联的两个 loader 中,解析 html 、转化为 JS 执行脚本的任务已经交给 html-loader 了,咱们来处理 html 压缩问题。

做为普通 node 模块的 loader 能够垂手可得地引用第三方库。咱们使用 minimize 这个库来完成核心的压缩功能:

// src/loaders/html-minify-loader.js

var Minimize = require('minimize');

module.exports = function(source) {
    var minimize = new Minimize();
    return minimize.parse(source);
};

固然, minimize 库支持一系列的压缩参数,好比 comments 参数指定是否须要保留注释。咱们确定不能在 loader 里写死这些配置。那么 loader-utils 就该发挥做用了:

// src/loaders/html-minify-loader.js
var loaderUtils = require('loader-utils');
var Minimize = require('minimize');

module.exports = function(source) {
    var options = loaderUtils.getOptions(this) || {}; //这里拿到 webpack.config.js 的 loader 配置
    var minimize = new Minimize(options);
    return minimize.parse(source);
};

这样,咱们能够在 webpack.config.js 中设置压缩后是否须要保留注释:

module: {
        rules: [{
            test: /\.html$/,
            use: ['html-loader', {
                loader: 'html-minify-loader',
                options: {
                    comments: false
                }
            }] 
        }]
    },
    resolveLoader: {
        // 由于 html-loader 是开源 npm 包,因此这里要添加 'node_modules' 目录
        modules: [path.join(__dirname, './src/loaders'), 'node_modules']
    }

固然,你还能够把咱们的 loader 写成异步的方式,这样不会阻塞其余编译进度:

var Minimize = require('minimize');
var loaderUtils = require('loader-utils');

module.exports = function(source) {
    var callback = this.async();
    if (this.cacheable) {
        this.cacheable();
    }
    var opts = loaderUtils.getOptions(this) || {};
    var minimize = new Minimize(opts);
    minimize.parse(source, callback);
};

你能够在这个仓库查看相关代码,npm start 之后能够去 http://localhost:9000 打开控制台查看 loader 处理后的内容。

总结

到这里,对于「如何开发一个 loader」,我相信你已经有了本身的答案。总结一下,一个 loader 在咱们项目中 work 须要经历如下步骤:

  • 建立 loader 的目录及模块文件
  • 在 webpack 中配置 rule 及 loader 的解析路径,而且要注意 loader 的顺序,这样在 require 指定类型文件时,咱们能让处理流通过指定 laoder。
  • 遵循原则设计和开发 loader。

最后,Talk is cheep,赶忙动手撸一个 loader 耍耍吧~

参考

Writing a loader

推荐: 翻译项目Master的自述:

1. 干货|人人都是翻译项目的Master

2. iKcamp出品微信小程序教学共5章16小节汇总(含视频)

3. 开始免费连载啦~每周2更共11堂iKcamp课|基于Koa2搭建Node.js实战项目教学(含视频)| 课程大纲介绍

相关文章
相关标签/搜索