webpack loader的实现

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

知道 webpack loader 的作用和原理。

自己开发贴合业务需求的 loader。

什么是 Loader ?

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

Loader 怎么用 ?

知道它的强大功能以后,我们要怎么使用 loader 呢?

1. 配置 webpack config 文件

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

单个 loader 的配置

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

let webpackConfig = {

//...

module: {

rules: [{

test: /\.js$/,

use: [{

//这里写 loader 的路径

loader: path.resolve(__dirname, 'loaders/a-loader.js'),

options: {/* ... */}

}]

}]

}

}

多个 loader 的配置

增加 config.module.rules 数组中的规则对象以及 config.resolveLoader。

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 模块专用,本文就不多讨论了。

2. 简单上手

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

接着我们在函数中处理 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 中我们使用 @import 和 url(...) 声明来完成指定,而我们应该让模块系统解析这些依赖。

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

把不同的依赖声明统一转化为 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-loader 将 node-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:

Document

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);

};

总结

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

创建 loader 的目录及模块文件

在 webpack 中配置 rule 及 loader 的解析路径,并且要注意 loader 的顺序,这样在 require 指定类型文件时,我们能让处理流经过指定 laoder。

遵循原则设计和开发 loader。

原文链接:webpack loader的实现

更多面试题:

React高阶组件的使用-HOC

Vue常见面试题

常见react框架面试题之总结

前端安全之CSRF-第二部分

前端安全之XSS攻击和防范-第一部分

如何写一个babel-babel源码分析

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345