我们在Webpack基础篇介绍了多种loader和plugin以及每种的用途;那么他们两者在webpack内部是如何进行工作的呢?让我们手写一个loader和plugin来看看它内部的原理,以便加深对webpack的理解。
手写loader
我们在在Webpack配置基础篇介绍过,loader是链式传递的,对文件资源从上一个loader传递到下一个,而loader的处理也遵循着从下到上的顺序,我们简单了解一下loader的开发原则:
- 单一原则: 每个Loader只做一件事,简单易用,便于维护;
- 链式调用: Webpack 会按顺序链式调用每个Loader;
- 统一原则: 遵循
Webpack
制定的设计规则和结构,输入与输出均为字符串,各个Loader
完全独立,即插即用;
- 无状态原则:在转换不同模块时,不应该在loader中保留状态;
因此我们就来尝试写一个less-loader
和style-loader
,将less文件
处理后通过style标签的方式渲染到页面上去。
同步loader
loader默认导出一个函数,接受匹配到的文件资源字符串和SourceMap,我们可以修改文件内容字符串后再返回给下一个loader进行处理,因此最简单的一个loader如下:
| module.exports = function(source, map){ return source }
|
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
导出的loader函数
不能使用箭头函数,很多loader内部的属性和方法都需要通过this
进行调用,比如this.cacheable()
来进行缓存、this.sourceMap
判断是否需要生成sourceMap等。
我们在项目中创建一个loader文件夹,用来存放我们自己写的loader,然后新建我们自己的style-loader:
| function loader(source, map) { let style = ` let style = document.createElement('style'); style.innerHTML = ${JSON.stringify(source)}; document.head.appendChild(style) `; return style; } module.exports = loader;
|
这里的source
就可以看做是处理后的css文件字符串,我们把它通过style标签的形式插入到head中;同时我们也发现最后返回的是一个JS代码的字符串,webpack最后会将返回的字符串打包进模块中。
异步loader
上面的style-loader
都是同步操作,我们在处理source时,有时候会进行异步操作,一种方法是通过async/await,阻塞操作执行;另一种方法可以通过loader本身提供的回调函数callback
。
| const less = require("less"); function loader(source) { const callback = this.async(); less.render(source, function (err, res) { let { css } = res; callback(null, css); }); } module.exports = loader;
|
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
callback的详细传参方法如下:
| callback({ error: Error | Null, content: String | Buffer, sourceMap?: SourceMap, abstractSyntaxTree?: AST })
|
有些时候,除了将原内容转换返回之外,还需要返回原内容对应的Source Map,比如我们转换less和scss代码,以及babel-loader转换ES6代码,为了方便调试,需要将Source Map也一起随着内容返回。
| const less = require("less"); function loader(source) { const callback = this.async(); less.render(source,{sourceMap: {}}, function (err, res) { let { css, map } = res; callback(null, css, map); }); } module.exports = loader;
|
这样我们在下一个loader就能接收到less-loader返回的sourceMap了,但是需要注意的是:
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
Source Map生成很耗时,通常在开发环境下才会生成Source Map,其它环境下不用生成。Webpack为loader提供了this.sourceMap
这个属性来告诉loader当前构建环境用户是否需要生成Source Map。
加载本地loader
loader文件准备好了之后,我们需要将它们加载到webpack配置中去;在基础篇中,我们加载第三方的loader只需要安装后在loader属性中写loader名称即可,现在加载本地loader需要把loader的路径配置上。
| module.exports = { module: { rules: [{ test: /\.less/, use: [ { loader: './loader/style-loader.js', }, { loader: path.resolve(__dirname, "loader", "less-loader"), }, ], }] } }
|
我们可以在loader中配置本地loader的相对路径或者绝对路径,但是这样写起来比较繁琐,我们可以利用webpack提供的resolveLoader
属性,来告诉webpack应该去哪里解析本地loader。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| module.exports = { module: { rules: [{ test: /\.less/, use: [ { loader: 'style-loader', }, { loader: 'less-loader', }, ], }] }, resolveLoader:{ modules: [path.resolve(__dirname, 'loader'), 'node_modules'] } }
|
这样webpack会先去loader文件夹下找loader,没有找到才去node_modules;因此我们写的loader尽量不要和第三方loader重名,否则会导致第三方loader被覆盖加载。
处理参数
我们在配置loader时,经常会给loader传递参数进行配置,一般是通过options属性来传递的,也有像url-loader
通过字符串来传参:
| { test: /\.(jpg|png|gif|bmp|jpeg)$/, use: 'url-loader?limt=1024&name=[hash:8].[ext]' }
|
webpack也提供了query属性
来获取传参;但是query属性
很不稳定,如果像上面的通过字符串来传参,query就返回字符串格式,通过options方式就会返回对象格式,这样不利于我们处理。因此我们借助一个官方的包loader-utils
帮助处理,它还提供了很多有用的工具。
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
| const { getOptions, parseQuery, stringifyRequest, } = require("loader-utils");
module.exports = function (source, map) { const options = getOptions(this); parseQuery("?param1=foo") stringifyRequest(this, "test/lib/index.js") }
|
常用的就是getOptions
将处理后的参数返回出来,它内部的实现逻辑也非常的简单,也是根据query属性
进行处理,如果是字符串的话调用parseQuery
方法进行解析,源码如下:
| 'use strict'; const parseQuery = require('./parseQuery'); function getOptions(loaderContext) { const query = loaderContext.query; if (typeof query === 'string' && query !== '') { return parseQuery(loaderContext.query); } if (!query || typeof query !== 'object') { return {}; } return query; } module.exports = getOptions;
|
获取到参数后,我们还需要对获取到的options
参数进行完整性校验,避免有些参数漏传,如果一个个判断校验比较繁琐,这就用到另一个官方包schema-utils
:
| const { getOptions } = require("loader-utils"); const { validate } = require("schema-utils"); const schema = require("./schema.json"); module.exports = function (source, map) { const options = getOptions(this); const configuration = { name: "Loader Name"}; validate(schema, options, configuration); }
|
validate
函数并没有返回值,打印返回值发现是`undefined,因为如果参数不通过的话直接会抛出
ValidationError异常,直接进程中断;这里引入了一个
schema.json,就是我们对
options``中参数进行校验的一个json格式的对应表:
| { "type": "object", "properties": { "source": { "type": "boolean" }, "name": { "type": "string" }, }, "additionalProperties": false }
|
properties
中的健名就是我们需要检验的options
中的字段名称,additionalProperties
代表了是否允许options
中还有其他额外的属性。
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
less-loader源码分析
写完我们自己简单的less-loader
,让我们来看一下官方的less-loader
源码到底是怎么样的,这里贴上部分源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| import less from 'less'; import { getOptions } from 'loader-utils'; import { validate } from 'schema-utils'; import schema from './options.json'; async function lessLoader(source) { const options = getOptions(this); validate(schema, options, { name: 'Less Loader', baseDataPath: 'options', }); const callback = this.async(); const lessOptions = getLessOptions(this, options); const useSourceMap = typeof options.sourceMap === 'boolean' ? options.sourceMap : this.sourceMap; if (useSourceMap) { lessOptions.sourceMap = { outputSourceFiles: true, }; } let data = source; let result; try { result = await less.render(data, lessOptions); } catch (error) { } const { css, imports } = result; let map = typeof result.map === 'string' ? JSON.parse(result.map) : result.map; callback(null, css, map); } export default lessLoader;
|
可以看到官方的less-loader和我们写的简单的loader本质上都是调用less.render
函数,对文件资源字符串进行处理,然后将处理好后的字符串和sourceMap通过callback返回。
loader依赖
在loader中,我们有时候也会使用到外部的资源文件,我们需要在loader对这些资源文件进行声明;这些声明信息主要用于使得缓存loader失效,以及在观察模式(watch mode)下重新编译。
我们尝试写一个banner-loader
,在每个js文件资源后面加上我们自定义的注释内容;如果传了filename
,就从文件中获取预设好的banner内容,首先我们预设两个banner的txt:
| //loader/banner1.txt /* build from banner1 */
//loader/banner2.txt /* build from banner2 */
|
然后在我们的banner-loader中根据参数来进行判断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| const fs = require("fs"); const path = require("path"); const { getOptions } = require("loader-utils");
module.exports = function (source) { const options = getOptions(this); if (options.filename) { let txt = ""; if (options.filename == "banner1") { this.addDependency(path.resolve(__dirname, "./banner1.txt")); txt = fs.readFileSync(path.resolve(__dirname, "./banner1.txt")); } else if (options.filename == "banner2") { this.addDependency(path.resolve(__dirname, "./banner1.txt")); txt = fs.readFileSync(path.resolve(__dirname, "./banner1.txt")); } return source + txt; } else if (options.text) { return source + `/* ${options.text} */`; } else { return source; } };
|
这里使用了this.addDependency
的API将当前处理的文件添加到文件依赖中(并不是项目的package.json)。如果在观察模式下,依赖的text文件发生了变化,那么打包生成的文件内容也随之变化。
如果不添加this.addDependency
的话项目并不会报错,只是在观察模式下,如果依赖的文件发生了变化生成的bundle文件并不能及时更新。
缓存加速
在有些情况下,loader处理需要大量的计算非常耗性能(比如babel-loader),如果每次构建都重新执行相同的转换操作每次构建都会非常慢。
因此webpack默认会将loader的处理结果标记为可缓存,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时,它的输出结果必然是相同的;如果不想让webpack缓存该loader,可以禁用缓存:
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
| module.exports = function(source) { this.cacheable(false); return source; };
|
手写loader所有代码均在webpackdemo19
手写plugin
在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过Webpack提供的API改变输出结果。和手写loader一样,我们先来写一个简单的plugin:
| class MyPlugin { constructor() { console.log("Plugin被创建了"); } apply (compiler) {} } module.exports = MyPlugin;
|
plugin的本质是类;我们在定义plugin时,其实是在定义一个类;定义好plugin后就可以在webpack配置中使用这个插件:
| const MyPlugin = require('./plugins/MyPlugin') module.exports = { plugins: [ new MyPlugin() ], }
|
这样我们的插件就在webpack中生效了;这时有些童鞋可能会想起来,我们在使用HtmlWebpackPlugin
或者CleanWebpackPlugin
等一些官方插件时,可以通过实例化插件传入参数;那么这里我们是否也能通过这种方式给我们的插件传参呢?
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
| class MyPlugin { constructor(options) { console.log("Plugin被创建了"); console.log(options); this.options = options; } apply (compiler) {} }
module.exports = { plugins: [ new MyPlugin({ title: 'MyPlugin' }) ], }
|
我们在构建插件时就能通过options
获取配置信息,对插件做一些初始化的工作。在构造函数中我们发现多了一个apply
函数,它会在webpack运行时被调用,并且注入compiler
对象;其工作流程如下:
- webpack启动,执行new myPlugin(options),初始化插件并获取实例
- 初始化complier对象,调用myPlugin.apply(complier)给插件传入complier对象
- 插件实例获取complier,通过complier监听webpack广播的事件,通过complier对象操作webpack
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
我们可以通过apply
函数中注入的compiler
对象进行注册事件:
| class MyPlugin { apply(compiler) { compiler.hooks.done.tap("MyPlugin", (compilation) => { console.log("compilation done"); }); } }
|
compiler不仅有同步的钩子,通过tap函数来注册,还有异步的钩子,通过tapAsync
和tapPromise
来注册:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class MyPlugin { apply(compiler) { compiler.hooks.run.tapAsync("MyPlugin", (compilation, callback) => { setTimeout(()=>{ console.log("compilation run"); callback() }, 1000) }); compiler.hooks.emit.tapPromise("MyPlugin", (compilation) => { return new Promise((resolve, reject) => { setTimeout(()=>{ console.log("compilation emit"); resolve(); }, 1000) }); }); } }
|
这里又有一个compilation
对象,它和上面提到的compiler
对象都是Plugin和webpack之间的桥梁:
compiler
对象包含了 Webpack 环境所有的的配置信息。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。
compilation
对象包含了当前的模块资源、编译生成资源、变化的文件等。当运行webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
compiler和compilation的区别在于:
- compiler代表了整个webpack从启动到关闭的生命周期,而compilation只是代表了一次新的编译过程
- compiler和compilation暴露出许多钩子,我们可以根据实际需求的场景进行自定义处理
手写FileListPlugin
了解了compiler和compilation的区别,我们就来尝试一个简单的示例插件,在打包目录生成一个filelist.md
文件,文件的内容是将所有构建生成文件展示在一个列表中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| class FileListPlugin { apply(compiler){ compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback)=>{ var filelist = 'In this build:\n\n'; for (var filename in compilation.assets) { filelist += '- ' + filename + '\n'; } compilation.assets['filelist.md'] = { source: function() { return filelist; }, size: function() { return filelist.length; } }; callback(); }) } } module.exports = FileListPlugin
|
我们这里用到了assets
对象,它是所有构建文件的一个输出对象,打印出来大概长这样:
| { 'main.bundle.js': { source: [Function: source], size: [Function: size] }, 'index.html': { source: [Function: source], size: [Function: size] } }
|
我们手动加入一个filelist.md
文件的输出;打包后我们在dist文件夹中会发现多了这个文件:
| In this build:
- main.bundle.js - index.html
|
这个插件就完成了我们的预期任务了。
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
参考
webpack loader从入门到精通全解析