前言
Vue项目开发一直使用的脚手架,对Webpack这个黑匣子知之甚少,碰到问题总是一头雾水,所以趁着Webpack5.0发布不久,较完整地学习了一遍。本篇文章总结一下学习成果。整体大纲如下图,本文为进阶篇,基础篇请按传送门Webpack5.0学习总结-基础篇。
窥探 webpack 原理
如何开发一个 loader
loader 本质上是一个函数,它的作用就是将匹配到的源文件内容做一些处理然后输出。当某个规则使用了多个loader处理时,就会按照从下往上的顺序依次执行,后一步拿到的都是前一步处理完成的内容。可以理解为链式调用。所以开发loader时,最要关心的就是它的输入与输出。
下面就用实例分步介绍开发一个loader的过程
- 在webpack配置文件中引入自己编写的loader,并在某个规则中使用。
- 编写自定义loader。
- 对比loader使用前后,bundle文件(main.js)的差异,验证loader效果。
首先明确下编写的这个loader想要实现什么功能。本示例中,简单实现删除js注释的功能,以此来介绍loader编写流程。
一、 配置文件中引入loader
在webpack.config.js中引入loader,这里说明一下resolveLoader,它的作用是配置loader的查找路径,若未配置resolveLoader,rules中的loader参数,需要填写完整的loader文件路径。
// webpack.config.js
const path = require("path");
module.exports = {
mode: "none", //mode设置为none,不启用任何默认配置,防止Webpack自动处理干扰loader效果。
/* 解析loader的规则 */
resolveLoader: {
// loader查找路径,默认是node_modules,所以我们平常写loader(如babel-loader)时实际都会去node_modules里找
modules: ["node_modules", path.resolve(__dirname, "loaders")], // 增加查找路径。顺序是从前往后
},
module: {
rules: [
{
test: /\.js$/,
// 因为配置了resolveLoader,在loaders文件夹下找到了myLoader
loader: "myLoader",
options:{
oneLine: true, // 是否删除单行注释
multiline: true, // 是否删除多行注释
}
}
]
},
}
复制代码
二、 编写自定义loader
// myLoader.js
module.exports = function (source) {
// Webpack5.0开始,不在需要使用工具获取option了
// 获取到webpack.config.js中配置的options
let options = this.getOptions();
let result = source;
// 默认单行和多行注释都删除
const defaultOption = {
oneLine: true,
multiline: true,
}
options = Object.assign({}, defaultOption, options);
if (options.oneLine) {
// 去除单行注释
result = result.replace(/\/\/.*/g, "")
}
if (options.multiline) {
// 去除多行注释
result = result.replace(/\/\*.*?\*\//g, "")
}
// loader必须要有输出,否则Webpack构建报错
return result
}
复制代码
三、 对比打包输出的bundle,验证loader效果。
为了让对比更清晰简洁,源代码index.js中的内容非常简单。
- 源代码
// index.js
/* 增加多行注释,用于测试 */
const x = 100;
let y = x; // 行内单行测试
// 单行注释测试
console.log(y);
复制代码
- 未使用loader时的输出文件,可以看到源代码中的注释都保留着。
// main.js
/******/ (function() { // webpackBootstrap
var __webpack_exports__ = {};
/* 增加多行注释,用于测试 */
const x = 100;
let y = x; // 行内单行测试
// 单行注释测试
console.log(y);
/******/ })()
;
复制代码
- 使用loader时的输出文件,很明显源代码中的注释都被删除了,loader生效。
// main.js
/******/ (function() { // webpackBootstrap
var __webpack_exports__ = {};
const x = 100;
let y = x;
console.log(y);
/******/ })()
;
复制代码
以上就是编写一个loader的基本过程,还有几点补充说明下:
- options参数校验:可以使用三方库schema-utils对options设置的参数进行校验。
- 同步和异步:loader分为同步loader和异步loader,上文写的是同步loader。而有些场景下可能需要使用异步loader。如下所示:
module.exports = function (source) {
// 生成一个异步回调函数。
const callback = this.async();
setTimeout(() => {
// 回调函数的第一个参数是错误信息,第二个参数为输出结果,第三个参数是source-map
callback(null, source);
}, 1000);
};
复制代码
- 在开发一个loader时,要尽量使它的职责单一。即一个loader只做一个任务。这样可以使loader更容易维护并且可以在更多的场景下复用。
如何开发一个插件
Webpack的打包过程就像一个产品的流水线,按部就班地执行一个又一个环节。而插件就是在这条流水线各个阶段插入的额外功能,Webpack以此来扩展自身的功能。
在实例介绍之前,需要先简单了解下插件是如何在Webpack打包的不同阶段准确插入其中的。它使用的是 Tapable 工具类,compiler和compilation类都扩展自Tapable类。
Tapable简介
Tapable 用法个人理解类似发布订阅模式,不同插件可以订阅同一个事件,当Webpack执行到该事件时,分发给各个注册的插件。Tapable提供的钩子类型很多,总体可以分为同步和异步,它们的注册方式不同。同步钩子通过tap注册,异步钩子通过tapAsync或tapPromise,两者的区别在于前者使用回调函数,后者使用Promise。
Tapable本身还细分很多类型,比如Bail类型的钩子,可以终止此类注册事件的调用(某个Bail钩子注册的事件中有return,就不再执行其他注册事件),具体的这里不再展开。下面通过读取文件的例子具体看一下Tapable钩子的用法
const { SyncHook, AsyncSeriesHook } = require("tapable");
const fs = require("fs");
// 钩子存放容器
const hooks = {
beforeRead: new SyncHook(["param"]), // 同步钩子,数组代表注册时,回调函数的参数。
afterRead: new AsyncSeriesHook(["param"]) // 异步顺序执行钩子
}
// 订阅beforeRead
hooks.beforeRead.tap("name", (param) => {
console.log(param, "beforeRead执行触发回调");
})
// 订阅afterRead
hooks.afterRead.tapAsync("name", (param, callback) => {
console.log(param, "afterRead执行触发回调");
setTimeout(() => {
// 回调执行完毕
callback()
}, 1000);
})
// 读取文件前调用beforeRead,注册事件按照注册顺序同步执行
hooks.beforeRead.call("开始读取")
fs.readFile("package.json", ((err, data) => {
if (err) {
throw new Error(err)
}
// 读取文件后执行afterRead钩子
hooks.afterRead.callAsync(data, () => {
// 所有注册事件执行完后调用,类似Promise.all
console.log("afterRead end~");
})
}))
复制代码
在读取文件的两个阶段,执行相应钩子,执行时广播通知到所有注册事件。执行完后再继续下面的步骤。
自定义插件编写
插件本质上是一个构造函数,它的原型上必须有一个apply方法。在Webpack初始化compiler对象之后会调用插件实例的apply方法,传入compiler对象。然后插件就可以在compiler上注册想要注册的钩子,Webpack会在执行到对应阶段时触发注册事件。下面用两个简单的插件实例演示这个过程。
插件一:删除输出文件夹内的文件
模仿CleanWebpackPlugin插件,但是不删除文件夹,因为Node只能删除空文件夹,需要使用递归才能完整实现CleanWebpackPlugin的功能,这里重点演示插件编写流程,所以就简化为只删除文件。
// RmFilePlugin.js
const path = require("path");
const fs = require("fs");
class RmFilePlugin {
constructor(options = {}) {
// 插件的options
this.options = options;
}
// Webpack会自动调用插件的apply方法,并给这个方法传入compiler参数
apply(compiler) {
// 拿到webpack的所有配置
const webpackOptions = compiler.options;
// context为Webpack的执行环境(执行文件夹路径)
const { context } = webpackOptions
// 在compiler对象的beforeRun钩子上注册事件
compiler.hooks.beforeRun.tap("RmFilePlugin", (compiler) => {
// 获取打包输出路径
const outputPath = webpackOptions.output.path || path.resolve(context, "dist");
const fileList = fs.readdirSync(outputPath, { withFileTypes: true });
fileList.forEach(item => {
// 只删除文件,不对文件夹做递归删除,简化逻辑
if (item.isFile()) {
const delPath = path.resolve(outputPath, item.name)
fs.unlinkSync(delPath);
}
})
});
}
};
// 导出 Plugin
module.exports = RmFilePlugin;
复制代码
这个例子很简单,只用到了compiler对象,在实际开发插件的过程中,大多数情况下还需要使用compilation对象,那么它和compiler有什么不同?
- 个人理解,compiler 代表了Webpack从启动到关闭的整个完整生命周期,它上面的钩子是基于 Webpack 运行自身的,比如打包环境是否准备好,是否开始编译了等。而compilation专注于编译阶段,它的钩子存在于编译的各个细节中,如模块被加载(load)、优化(optimize)、 分块(chunk)等。
下面这个例子就用到了compilation对象
插件二:删除js注释
这个插件的功能在上文loader中实现过,在plugin里又实现一遍,是想说明loader能做到的事plugin都能做到,并且plugin可以做的更彻底。
// DelCommentPlugin.js
const { sources } = require('webpack');
class DelCommentPlugin {
constructor(options) {
this.options = options
}
apply(compiler) {
// compilation 创建之后执行注册事件
compiler.hooks.compilation.tap("DelCommentPlugin", (compilation) => {
// 处理asset
compilation.hooks.processAssets.tap(
{
name: 'DelCommentPlugin', //插件名称
//要对asset做哪种类型的处理,这里的填值代表的是对asset 进行了基础预处理
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_PRE_PROCESS,
},
(assets) => {
for (const name in assets) {
// 只对js资产做处理
if (name.endsWith(".js")) {
if (Object.hasOwnProperty.call(assets, name)) {
const asset = compilation.getAsset(name); // 通过asset名称获取到asset
const contents = asset.source.source(); // 获取到asset的内容
const result = contents.replace(/\/\/.*/g, "").replace(/\/\*.*?\*\//g, "");//删除注释
// 更新asset的内容
compilation.updateAsset(
name,
new sources.RawSource(result)
);
}
}
}
}
);
})
}
}
module.exports = DelCommentPlugin
复制代码
跟loader一样,对比一下使用了这个插件后的输出。
// main.js
(function() {
var __webpack_exports__ = {};
const x = 100;
let y = x;
console.log(y);
})()
;
复制代码
很明显,删除注释没有问题,并且可以看到,它把main.js文件内的注释都删除了,而loader只能删除源代码中的注释。plugin却可以直接改变最终输出的bundle内容。
手写一个简易 Webpack
Webpack是一个Node应用,所以本质上它就是在Node环境上跑了一段(一大大大段)js代码,看上去就像这样。
// built.js
const myWebpack = require("../lib/myWebpack");
// 引入自定义配置
const config = require("../config/webpack.config.js");
const compiler = myWebpack(config);
// 开始webpack打包
compiler.run();
复制代码
向myWebpack函数里传入配置config,然后构造一个compiler对象,执行它的run方法。run方法重点做两个事情,一是根据入口文件找出并记录所有依赖,二是用字符串组装最后输出的boundle函数,这个函数的主要功能就是根据依赖关系实现require和export功能。下面就按照这两步分析下代码:
根据入口文件分析出依赖关系表
// myWebpack.js
const fs = require("fs");
const path = require("path");
const babelParser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAstSync } = require("@babel/core")
// Compiler构造函数
class Compiler {
constructor(options = {}) {
this.options = options; // 获得webpack配置
this.entry = this.options.entry || "./src/index.js" // 获取入口文件,不存在则使用默认值
this.entryDir = path.dirname(this.entry)
this.depsGraph = {}; //依赖关系表,第一步的产出
}
// 启动webpack打包
async run() {
const { entry, entryDir } = this
// 从入口文件开始获取模块信息
this.getModuleInfo(entry, entryDir);
console.log(this.depsGraph);
// 获取到模块信息后生成构建内容,第二步的内容,先注释。
// this.outputBuild()
}
// 根据文件路径获取模块信息
getModuleInfo(modulePath, dirname) {
const { depsGraph } = this
/*
利用fs模块和文件路径可以读取到文件内容,然后根据文件内容(import和export)又可以分析出模块之间的依赖关系。
自己去做这步是没有任何问题的。只是这里为了方便,就利用babelParser库生成一个抽象的模型ast(抽象语法树)。
ast将我们的代码抽象出来,方便我们操作。
*/
const ast = getAst(modulePath);
// 利用ast和traverse库获得该模块的依赖。原理就是分析了代码中的"import"语句。
const deps = getDeps(ast, dirname);
// 利用ast和babel/core将源代通过babel编码输出。如果不用ast也可以直接使用babel/core的transform方法将源代码转码
const code = getParseCode(ast)
// depsGraph保存的模块信息就是code源代码和它的依赖关系
depsGraph[modulePath] = {
deps,
code
}
// 如果该模块存在依赖deps,就通过递归继续找出它下面的依赖,这样循环就找出了入口文件开始的所有依赖。
if (Object.keys(deps).length) {
for (const key in deps) {
if (Object.hasOwnProperty.call(deps, key)) {
// 递归获取模块信息
this.getModuleInfo(deps[key], dirname)
}
}
}
}
}
// getModuleInfo中用到的三个工具函数
// 根据文件路径获取抽象语法树
const getAst = (modulePath) => {
const file = fs.readFileSync(modulePath, "utf-8");
// 2. 将其解析成ast抽象语法树
const ast = babelParser.parse(file, {
sourceType: "module", // 要解析的是 es6 module(默认为commonJs)
});
return ast
};
// 根据抽象语法树ast获取依赖关系
const getDeps = (ast, dirname) => {
// 该模块依赖合集
const dependSet = {
}
// 利用traverse这个库收集依赖,自己收集也可以,不管是抽象语法树还是源代码中都是可以拿到依赖关系的。现成的库比较方便
traverse(ast, {
// 内部遍历ast中的program.body,判断里面语句类型
// 如果type为ImportDeclaration 就会触发当前函数
ImportDeclaration({ node }) {
const relativePath = node.source.value //import文件的相对路径
const absolutePath = path.resolve(dirname, relativePath)
dependSet[relativePath] = absolutePath // 依赖中记录文件的绝对路径
}
})
return dependSet
};
// 根据抽象语法树,获取最终输出代码
const getParseCode = (ast) => {
// 编译代码,将现代浏览器不能识别的语法进行编译处理
// @babel/core可以直接将ast抽象语法树编译成兼容代码
/* 编译完成,可输出 */
const { code } = transformFromAstSync(ast, null, {
presets: ["@babel/preset-env"]
})
return code
}
// 该模块要输出的myWebpack函数
const myWebpack = (config) => {
return new Compiler(config);
};
module.exports = myWebpack;
复制代码
如果现在运行一下上面的built.js,就会打印出依赖关系表,它大概长这样。
depsGraph = {
'./src/index.js': {
deps: {
'./add.js': 'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\add.js',
'./sub.js': 'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\sub.js'
},
code: '"use strict";\n' +
'\n' +
'var _add = _interopRequireDefault(require("./add.js"));\n' +
'\n' +
'var _sub = _interopRequireDefault(require("./sub.js"));\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
'\n' +
'console.log((0, _add.default)(1, 2));\n' +
'console.log((0, _sub.default)(3, 1));'
},
'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\add.js': {
deps: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.default = _default;\n' +
'\n' +
'function _default(x, y) {\n' +
' return x + y;\n' +
'}'
},
'E:\\study\\JavaScript\\webpack\\bWebpack\\principle\\myWebpack2\\src\\sub.js': {
deps: {},
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.default = _default;\n' +
'\n' +
'function _default(x, y) {\n' +
' return x - y;\n' +
'}'
}
}
复制代码
第二步要做的事,就是根据依赖关闭表,输出最后的bundle文件。
组装输出函数
如果直接用字符串组装输出函数,可能会有点不好理解。所以先在一个js中实现想要输出的函数。这个函数以依赖关系表为参数,内部实现require和export函数,因为babel转码输出后的代码中使用的就是CommonJs规则。
(function (depsGraph) {
// 为了加载入口文件
function require(module) {
// 定义模块内部的require函数
function localRequire(relativePath) {
// 为了找到要引入模块的绝对路径,通过require加载
return require(depsGraph[module].deps[relativePath])
};
// 定义暴露对象
var exports = {};
/*
模块内部要自定义localRequire,而不是直接用require函数,原因是使用babell转化后的code,require传参时使用的是
相对路径,而我们内部依赖表中,是根据绝对路径找到code,所以要实现一层转化
*/
(function (require, exports, code) {
// code是字符串,用eval执行
eval(code)
})(localRequire, exports, depsGraph[module].code);
// 作为require函数的返回值返回出去
// 后面的require函数能得到暴露的内容
return exports;
}
// 加载入口文件
require("./src/index.js")
})(depsGraph);
复制代码
这个就是最后要输出的bundle,如果把第一步中获取到的依赖关系表拿过来,直接执行这个函数,就可以和执行源代码取得同样的效果。最后要做的就是在myWebpack.js中用字符串拼装出这个函数。下面是myWebpack.js中的完整代码。
myWebpack完整源代码
const fs = require("fs");
const path = require("path");
const babelParser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAstSync } = require("@babel/core")
const myWebpack = (config) => {
return new Compiler(config);
};
// Compiler构造函数
class Compiler {
constructor(options = {}) {
this.options = options; // 获得webpack配置
this.entry = this.options.entry || "./src/index.js" // 获取入口文件,不存在则使用默认值
this.entryDir = path.dirname(this.entry)
this.depsGraph = {}; //依赖关系表,第一步的产出
}
// 启动webpack打包
async run() {
const { entry, entryDir } = this
// 从入口文件开始获取模块信息
this.getModuleInfo(entry, entryDir);
// 获取到模块信息后生成构建内容
this.outputBuild()
}
// 根据文件路径获取模块信息
getModuleInfo(modulePath, dirname) {
const { depsGraph } = this
const ast = getAst(modulePath);
const deps = getDeps(ast, dirname);
const code = getParseCode(ast)
// depsGraph保存的模块信息就是code源代码和它的依赖关系
depsGraph[modulePath] = {
deps,
code
}
// 如果该模块存在依赖deps,就通过递归继续找出它下面的依赖,这样循环就找出了入口文件开始的所有依赖。
if (Object.keys(deps).length) {
for (const key in deps) {
if (Object.hasOwnProperty.call(deps, key)) {
// 递归获取模块信息
this.getModuleInfo(deps[key], dirname)
}
}
}
}
// 最后一步,利用fs输出js文件
outputBuild() {
const build = `(function (depsGraph) {
function require(module) {
function localRequire(relativePath) {
// 为了找到要引入模块的绝对路径,通过require加载
return require(depsGraph[module].deps[relativePath])
};
// 定义暴露对象
var exports = {};
(function (require, exports, code) {
// code是字符串,要eval执行
eval(code)
})(localRequire, exports, depsGraph[module].code);
return exports;
}
require("${this.options.entry}")
})((${JSON.stringify(this.depsGraph)}))`;
let outputPath = path.resolve(this.options.output.path, this.options.output.filename)
fs.writeFileSync(outputPath, build, "utf-8")
}
}
// 根据文件路径获取抽象语法树
const getAst = (modulePath) => {
// 1.读取入口文件内容
/* 第二个参数如果不写,默认返回Buffer数据,如果写了utf-8解码,则返回字符串数据 */
// 注意:从这个入口文件读取可以看出来,node针对的所有相对路径,都是根据运行环境来的,在这里就是package.json目录,
// 即myWebpack目录
const module = fs.readFileSync(modulePath, "utf-8");
// 2. 将其解析成ast抽象语法树
const ast = babelParser.parse(module, {
sourceType: "module", // 要解析的是 es6 module(默认为commonJs)
});
return ast
};
// 根据抽象语法树ast获取依赖关系
const getDeps = (ast, dirname) => {
// 依赖合集
const dependSet = {
}
// 利用traverse这个库收集依赖,自己收集其实也可以,不管是抽象语法树还是import源代码中都是可以拿到依赖关系的。现成的库比较方便
traverse(ast, {
// 内部遍历ast中的program.body,判断里面语句类型
// 如果type:ImportDeclaration 就会触发当前函数
ImportDeclaration({ node }) {
// 模块相对路径"./add.js"
const relativePath = node.source.value
const absolutePath = path.resolve(dirname, relativePath)
dependSet[relativePath] = absolutePath
}
})
return dependSet
};
// 根据抽象语法树,获取最终输出代码
const getParseCode = (ast) => {
// 编译代码,将现代浏览器不能识别的语法进行编译处理
// @babel/core可以直接将ast抽象语法树编译成兼容代码
/* 编译完成,可输出 */
const { code } = transformFromAstSync(ast, null, {
presets: ["@babel/preset-env"]
})
return code
}
module.exports = myWebpack;
复制代码
结语
完整的Webpack需要编写的代码十分庞大,上文只是其最简单的整体架构。但即使如此,仍然感觉到比做基础篇的总结难度大一些,可能也会出现一些错误,欢迎大家指正!