原理
主要介绍webpack的执行流程,loader和plugin的原理
loader
用来编译其他类型的文件
种类
pre: 前置loadernormal: 普通loaderinline: 行内loaderpost: 后置loader
四种loader执行顺序: pre> normal>inline>post
定义
内联方式
行内loader通过在import时配置
import style form `style-loader!css-loader?module./main.css`
!: 分隔各个loader
?: 传递的参数
除此之外,添加不同的前缀可以跳过其他类型的loader
import style form `!style-loader!css-loader?module./main.css`
!: 跳过normal loader
-!: 跳过normal pre loader
!!: 跳过ore naomal post loader
配置方式
通过enforce可以在webpack配置文件中定义pre,normal,post三种loader,不配置enforce默认为normal
module: {
rules: [
{
test: /.js$/,
loader: 'loader1',
enforce: 'pre'
}
]
}
开发loader
loader其实就是一个函数,接收test匹配到的文件内容,函数的返回值就是处理后的文件内容,webpack打包时会将匹配到的loader自动执行
module.exports = function(content,map,meta){
return content
}
参数:
content: 文件内容
map: sourceMap
meta: 其他loader传递的参数
loader有四种,分别在不同场景下使用: 同步loader,异步 loader,raw loader,pitch loader
同步loader
只能执行同步任务的loader
//适合资源文件只用这个loader处理
module.exports = function(content,map,meta){
return content
}
// 适合资源文件需要多个loader处理,可以将错误信息,文件内容,sourceMap,meta传递给下一个loader
module.exports = function(content,map,meta){
this.callback(err,content,map,meta)
}
异步loader
可以执行异步任务的loader,通过webpak提供的异步apithis.async实现
module.exports = function(content,map,mate){
const callBack = this.async()
setTimeout(() => {
callBack(err,content,map,meta)
},1000)
}
raw loader
主要用来处理图片字体图标等资源,和同步异步loader的区别在于:
- raw-loader的content参数是一个buffer数据流
- 导出时需要将
raw属性指定为true
module.exports = function(content,map,meta){
return content
}
module.export.raw = true
pitch loader
和同步异步loader的区别在于:
- 到处是需要暴露一个
pitch方法
module.exports = function(content,map,meta){
return content
}
module.export.pitch = function(remainingRequest){
return //return会影响loader和其他pitch的执行顺序
}
参数:
remainingRequest: 接下来要处理的loder的文件地址和资源文件地址字符串
执行时机:
假如有三个loader和对应的三个pitch:
use:[1,2,3]
pitch:[p1,p2,p3]
loader和pitch执行顺序为: p1,p2,p3,3,2,1
如果某个pitch中return了,那么之后的pitch和loader都不会执行,转而执行上一个loadera,如果没有,则执行结束
如: p2中有return,完整执行顺序为: p1,p2,1
loader API
在loader中可以通过this访问一些内置方法: API列表
下面是一些常用API:
| 名称 | 描述 | 用法 |
|---|---|---|
| this.async | 表示这个loader是一个异步回调 | const callBack = this.async |
| this.callback | 同步或者异步调用的并返回多个结果的函数 | this.callBack(this.callback( err,content, sourceMap?,meta?) |
| this.emitFile | 产生一个文件 | this.emitFile(name,content,sourceMap) |
| this.utils.contextify | 返回一个相对路径 | this.utils.contextify(this.context, this.resourcePath) |
| this.utils.absolutify | 返回一个绝对路径 | this.utils.absolutify(this.context, this.resourcePath) |
| this.getOptions(schema) | 获取loader配置中的options | schema是该options的验证规则,不符合验证规则的options会失败 |
schema
一个JSON,用来规定loader的options结构: options类型,以及内部应有什么属性,属性是什么类型等
标准的JSON格式:
schema.json:
{
"type":"object", // options是一个对象
"properties": { // 描述对该对象应该有什么属性
"key":{
"type": 'key的类型是什么',
}
},
"addittionalProperties":false // 是否允许追加属性
}
实战
官方提供的loader工具函数库:github,包含各种工具函数,可以直接使用
手写banner-loader
banner-loader是一个自动给文件添加作者的loader,主要初步熟悉以下loader的编写和api的用法
banner-loader.js:
const schema = require("./schema.json");
module.exports = function (content, map, meta) {
const options = this.getOptions(schema);
const header = `
/* author: ${options.author} */
`;
return header + content;
};
options的验证规则./schema.json:
{
"type": "object", // 一个对象
"properties": {
"author": { // 拥有author属性
"type": "string" // 该属性值为type
}
},
"addittionalProperties": false // 不支持其他添加其他字段
}
手写babel-loader
实现一个建议版本的babel-loader:
const babel = require("@babel/core");
const schema = require("./schema.json");
module.exports = function (content) {
const callback = this.async();
const options = this.getOptions(schema);
babel.transform(content, options, function (err, result) {
if (err) {
callback(err);
} else {
callback(null, result.code);
}
});
};
./schema.json:
{
"type": "object",
"properties": {
"presets": {
"type": "array"
}
},
"addittionalProperties": false
}
手写file-loader
file-loader用来处理图片字体等资源,可以查看开发模式下图片的打包结果,:
可以看到图片的输出是: module.exports = "文件名",由于这些资源都是原封不同的输出,因此我们不需要对内容做什么操作,只需要输出对应的文件和代码就可以了
- 获取文件内容hash值
- 创建文件
- 返回拼接好的代码
借助loader-utils中interpolateName来获取文件hash名:
npm i loader-utils -D
const loaderUtils = require("loader-utils");
module.exports = function (content) {
const interpolatedName = loaderUtils.interpolateName(
this,
"[contenthash:10].[ext][query]",
{ content }
);
this.emitFile(interpolatedName, content);
return `module.exports=${interpolatedName}`;
};
module.exports.raw = true;
手写style-loader
style-loader功能: 动态创建style标签,将css-loader处理的js运行结果,插入style标签中,最后将style标签插入页面的head标签中
根据这个功能描述,很容易想到style-loader可以这样写:
module.exports = function(content) {
const script = `
const style = document.createElement('style`)
style.innerHTML = content
const head = document.querySelector("head")
head.append(style)
`
return script
}
构建js,loader返回js,js在浏览器中执行,动态插入style标签
css内容需要经过css-loader处理其中的资源引入等,而css-loader返回的内容是一段js,需要将js执行才可以拿到解析后的css,上述步骤执行后会在style标签中插如js,并不能达到如期效果.
因此,应该先拿到css-loader输出的js,然后将js执行,最后将js执行结果放进style标签中,
拿到css-loader返回的js代码容易,但怎么执行这一段js代码,并拿到执行结果.整个打包过程中唯一可以执行js的方式便是返回js代码让浏览器执行了,那么如果能在构建出执行css-loader并拿到结果的js代码,就能直接将内容插入style标签中了,
那么现在的问题就是
- 如何在js中运行css-loader
- 什么时机做这些事情
这两个问题分别可以通过loader内联配置,pitch来实现:
- 内联配置需要知道loader的路径,在pitch中参数便是之后执行的loader和资源文件的地址
- 将绝对路径转换成相对路径
- 以内联的方式,引用
css-loader - 动态构建
js
module.exports = function (content) {};
module.exports.pitch = function (remainingRequest) {
// remainingRequest接下来要执行的loder路径(inline形式),这里将路径转换成相对路径,以便在js中能正常解析
const path = remainingRequest
.split("!")
.map((path) => this.utils.contextify(this.context, path))
.join("!");
const script = `
import style from "!!${path}" // 使用!!阻止之后的loader执行,这里已经将css-loader执行过了,之后的就不要在执行了
const oStyle = document.createElement("style");
oStyle.innerHTML = style;
const head = document.querySelector("head");
head.append(oStyle);
`;
return script;
};
plugin
插件就是在webpack执行过程中,特地给阶段触发的函数,用来增强webpack的功能
webpack执行机制
webpack执行过程就像一条流水线,按部就班的执行各个步骤,触发一系列钩子(hook).插件就是注册在钩子中的事件,随着钩子的触发自动运行,webpack执行时会创建两种对象compiler,compilation,包含了webpack所存在的钩子,官网钩子
compiler对象
首次启动时创建,整个打包流程只会创建一个,包含了完整的webpack配置信息
常用属性:
compiler.options: 本次启动时的所有配置文件compiler.inputFileSystem/outputFileSystem: 可以进行文件操作hooks:用来注册compiler对象不同种类的hook
compilation对象
每一次构建资源的时候都会创建,可以访问所有模块和依赖,一个compilation对象会对依赖图中所有模块,进行编译,在编译阶段模块会被加载,封存,优化,分块,哈希和重新创建
常用属性:
complation.modules: 可以访问所有模块,打包的每一个文件都是一个模块complation.chunks: 多个module组成的代码块complation.assets: 可以访问本次打包生成的所有文件结果complation.hooks: 用来注册complation对象不同种类的hook
hook的类型
注册hook时,有三种方法,分别对应不同类型的hook,这里先总结一下:
tap: 注册同步钩子,也可以注册异步钩子,但异步钩子内必须是同步代码tapAsync:以回调方式注册钩子tapPromise: 以Promise方式注册钩子
webpack声明周期简图
开始打包时,webpack首先创建compiler对象,然后开始执行compiler对象的各个钩子.处理资源时,创建compilation对象,并执行其钩子,compilation的钩子执行完毕,如果还有资源,则会重新创建一个compilation对象.直到资源处理完毕,继续执行compiler对象的钩子
plugin的构造和执行
每个插件都是一个类,必须拥有两个方法contructor,apply,使用时引入然后new调用即可.
class TestPlugin {
contructor() {
console.log("插件的contructor");
}
apply(compiler) {
console.log(compiler, "插件的apply方法");
}
}
module.exports = TestPlugin;
插件内部执行流程:
webpack读取配置文件,new调用插件,执行插件的contructor方法webpack创建compiler对象- 遍历所有的plugins,执行插件的
apply方法 - 继续执行剩下的编译流程,触发各个
hook
注册hook
hook都是在compiler,compilation这两个对象上注册的,格式化语法为:
compiler.hooks.someHook.tap('MyPlugin', (params) => {
/* ... */
});
tap: 取决于钩子的种类,异步代码的用tapAsync和tapPromise
回调函数的参数,每个钩子不尽相同,开发时看文档即可
钩子执行顺序示例
class HookPlugin {
constructor() {}
apply(compiler) {
// 通过compiler和compilation对象注册钩子,语法为compiler.hooks.钩子名称.钩子类型(插件名,回调函数)
compiler.hooks.environment.tap("HookPlugin", () => {
console.log("这时注册在enviroment中的钩子");
});
// emit可注册多个事件,一个一个串行执行
compiler.hooks.emit.tapAsync("HookPlugin", (compilation, callback) => {
setTimeout(() => {
console.log("这是emit中的异步钩子,第一个");
callback();
}, 1000);
});
compiler.hooks.emit.tapAsync("HookPlugin", (compilation, callback) => {
setTimeout(() => {
console.log("这是emit中的异步钩子,第二个");
callback();
}, 1000);
});
// make也可以注册多个事件,所有事件并行执行,
compiler.hooks.make.tapPromise("HookPlugin", (compilation) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("这是make中的钩子 第一个");
resolve();
}, 2000);
compilation.hooks.buildModule.tap("HookPlugin", () => {
console.log("这是compilation的钩子,第一个");
});
});
});
compiler.hooks.make.tapPromise("HookPlugin", (compilation) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("这是make中的钩子,第二个");
resolve();
}, 1000);
});
});
}
}
module.exports = HookPlugin;
//执行结果为:
这时注册在enviroment中的钩子
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是make中的钩子,第二个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是make中的钩子 第一个
这是emit中的异步钩子,第一个
这是emit中的异步钩子,第二个
node调试方式
有时想要查看compiler和compilation对象的内容,在命令行很不方便,可以采用调试,然后浏览器控制台查看
-
添加指令
package.json: "debug": "node --inspect-brk ./node_modules/webpack-cli/bin/cli.js" --inspect: 调试 -brk: 文件第一行加一个断点,方便进入 ./node_modules/webpack-cli/bin/cli.js: 要运行调试的文件 -
在代码中需要的地方添加
debugger; -
运行指令:
npm run debug -
打开浏览器控制台(随便一个页面),左上角可以看到一个
node图标,点击即可进入调试页面
实战
banner-webpack-plugin
通过插件实现loader部分写的banner-loader,自动为文件加上作者信息
开始之前有两个点需要明确一下:
- 首先需要确定在哪个钩子中加作者信息:在文件输出前加上作者信息,这样可以避免压缩等操作将作者信息干掉,输出文件之前执行的钩子就是
emit - 第二步需要确定如何拿到打包后的文件,
emit回调函数的参数compilation的assets属性保存的就是对应的资源文件
那么实现逻辑可以是这样:
emit钩子中注册事件,拿到compilation对象- 构建
作者信息字符换 - 通过
compilation.assets获取到文件资源 - 拼接
class BannerPlugin {
constructor(options = {}) {
this.name = options.name;
}
apply(compiler) {
compiler.hooks.emit.tapAsync("BannerPlugin", (compilation, callback) => {
debugger;
const code = `
/*
*author:${this.name}
*/
`;
const keys = Object.keys(compilation.assets).filter((item) =>
item.includes(".js")
);
keys.forEach((key) => {
// 获取这个bandle的内容
let source = compilation.assets[key].source();
source = code + source;
//重写这个文件的source方法和size方法
compilation.assets[key] = {
source() {
return source;
},
size() {
return source.length;
},
};
});
callback();
});
}
}
module.exports = BannerPlugin;
clean-webpack-plugin
实现output配置的clean:true功能,打包时自动删除之前的打包文件
思路:
- 执行时机的选择: 在文件即将输出前,才清空之前的旧文件,防止打包中途出错,却提前把旧文件删除了
- 获取到打包输出路径,可以通过
compiler的options属性,该属性存放配置文件的所有内容 - 通过
compiler.outputFileSystem对旧文件夹进行删除
class CleanPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync("CleanPlugin", (compilation, callback) => {
const path = compiler.options.output.path;
const fs = compiler.outputFileSystem;
fs.rmdirSync(path, { recursive: true });
callback();
});
}
}
module.exports = CleanPlugin;
analyze-webpack-plugin
一个可以分析打包后文件大小的插件,将文件大小情况输出到md文档中
- 确定执行时机,在打包输出之前执行
// 分析打包后资源大小情况,输出md文档
class AnalyzePlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync("AnalyzePlugin", (compilation, callback) => {
let text = `|资源名称|资源大小|
|---|---|`;
const assets = compilation.assets;
Object.keys(assets).forEach((key) => {
console.log(key, assets[key].size());
text += `\n|${key}:| ${(assets[key].size() / 1024).toFixed(2)}kb|`;
});
// 在输出资源中增加一个资源文件
assets["analyze.md"] = {
source() {
return text;
},
size() {
return text.length;
},
};
callback();
});
}
}
module.exports = AnalyzePlugin;
inline-webpack-plugin
打包后的文件中,可能存在非常小的文件,小到不值得一个单独请求去请求,这时候我们希望将这个文件转换成内联的代码,从而减少请求,这个插件就是做这个功能的.
以runtime文件(存储文件依赖的映射)为例:
当前的分包方式:
optimization: {
splitChunks: {
chunks: "all",
},
runtimeChunk: {
name: (entrypoint) => `runtime~${entrypoint.name}.js`,
},
},
当前的打包后runtime文件的引入方式为:
<script defer src="js/runtime~main.js.js"></script>
<script defer src="js/main.js"></script>
希望的方式是:
<script>
直接放置js/runtime~main.js.js的代码
</script>
<script defer src="js/main.js"></script>
实现分析:
可以看出最后就是要修改打包后的html文件,需要取出runtime文件的内容,拼接出内联script然后放入html中.这个html文件是由html-webpack-plugin生成的,查看这个插件的文档,可以发现这个插件内部实现了自定义的几个钩子:beforeAssetTagGeneration(创建标签前触发) => alterAssetTags(创建标签后触发) => alterAssetTagGroups(将属于head,body的标签分组后触发) => afterTemplateExecution(在编译模板后触发) => beforeEmit(在提交输出前触发) => afterEmit在提交输出后触发,有了alterAssetTagGroups这个钩子,就可以拿到分好组的标签,从而对标签进行更改了,文档中也提供了使用示例,可以直接使用.
步骤分析:
- 插件的触发时机应该在输出文件前的
emit hook中触发,这时候文件已经编译完成了,可以拿到分包之后的文件 - 触发后,借助
html-webpack-plugin的钩子,拿到分好组的标签 - 遍历标签,找到需要修改的标签,进行修改,修改成内联
script - 两个标签分组都要遍历,因为不知道这个文件会放入
head中还是body中 - 最后在资源中删除已经内联过的
script文件
const HtmlWebpackPlugin = require("safe-require")("html-webpack-plugin");
class InlinePlugin {
constructor(fileRegx) {
this.fileRegx = fileRegx; // 通过传参来指定要将哪些文件转换为内联script
}
// 注册钩子
apply(compiler) {
compiler.hooks.compilation.tap("InlinePlugin", (compilation) => {
// 注册html-webpack-plugin钩子
const hooks = HtmlWebpackPlugin.getHooks(compilation);
hooks.alterAssetTagGroups.tap("InlinePlugin", (assets) => {
// 获取head和body组并修改
assets.headTags = this.getInlineText(
assets.headTags,
compilation.assets
);
assets.bodyTags = this.getInlineText(
assets.bodyTags,
compilation.assets
);
});
// 删除转换好的文件
hooks.afterEmit.tap("InlinePlugin", () => {
Object.keys(compilation.assets).forEach((key) => {
if (this.fileRegx.test(key)) {
delete compilation.assets[key];
}
});
});
});
}
// 获取标签内容,并将组中元素转换成这种形式
// {
// tagName: "script",
// innerText: assets[filePath].source(),
// closeTag: true,
// };
getInlineText(tags, assets) {
return tags.map((tag) => {
const isScript = (tag.tagName = "script");
const filePath = tag.attributes?.src;
const isTargetFile = this.fileRegx.test(filePath);
if (isScript && filePath && isTargetFile) {
return {
tagName: tag.tagName,
innerHTML: assets[filePath].source(),
closeTag: true,
};
}
return tag;
});
}
}
module.exports = InlinePlugin;