前面两个小节已经系统介绍了webpack
中两个核心概念:plugin
和loader
.
今天将plugin
和loader
串联起来,从0
手写一个基于插件体系搭建的程序架构,完成一个mini
模仿版的webpack
.
目标需求:
js
的模块打包plugin
体系,允许接入开发者自定义plugin
loader
体系,允许接入开发者自定义loader
mini-webpack
有了plugin
和loader
的赋能,开发者就可以自己编写插件直接对打包的中间过程进行干预.除了实现最基本的js
编译,我们还能添加对图片和css
的处理能力.
依据执行流程,我们首先开发mini-webpack
的插件体系,让它拥有接入插件的能力.
插件体系搭建完后,html
模板、css
以及图片想引入到项目中都可以通过自定义plugin
和loader
解决.最后再将所有依赖文件编译打包生成dist
目录输出(源代码贴在了尾部).
项目根目录下创建一份配置文件webpack.config.js
文件(代码如下).
入口地址是src/js
目录下的index.js
文件,最后打包生成的js
代码放置在dist/bundle.js
.
webpack.config.js
还增加了对css
以及图片的编译,分别加载相关的loader
进行处理.
在plugins
部分,配置了处理html
的plugin
.最终mini-webpack
会在dist
目录下生成一个index.html
,并将打包编译后的js
路径插入到index.html
.
// webpack.config.js
module.exports = {
entry:path.join(__dirname,"./src/js/index.js"),
output:{
path:path.join(__dirname,"/dist"),
filename:"bundle.js"
},
module:{
rules:[{
test:/\.js/,
use:[babelLoader]
},{
test:/\.css/,
use:[htmlLoader]
},
{
test:/\.(jpg|png|gif)/,
use:[{
loader:fileLoader,
options:{
outputPath:"./image"
}
}]
}
]
},
plugins:[
new HtmlWebpackPlugin({
template:path.join(__dirname,"./src/index.html"),
filename:"index.html"
})
]
}
配置文件编写完,项目下创建mini-webpack
的入口文件mini-webpack.js
(代码如下)
入口文件的代码很简单,将webpack.config.js
传入Compiler
类,生成compiler
对象.compiler
对象执行run
方法,便启动了编译流程.
// mini-webpack.js
const options = require("../webpack.config");
const Compiler = require("./Compiler");
const compiler = new Compiler(options);
compiler.run();// 启动webpack编译
compiler
是mini-webpack
的支柱引擎,它内部使用tapable
定义了4
个关键的生命周期函数如下(tapable
上一小节介绍过不再赘述).
initialize
: 初始化完毕时触发compile
: 编译前触发emit
: 生成打包文件之前触发done
: 构建完毕触发Compiler.js
的构造函数定义了4
个生命周期钩子,紧接着调用bindHook
函数开始绑定plugin
.
开发者编写的配置文件webpack.config.js
中有一栏plugins
专门定义插件,而下面bindHook
函数正是取出配置文件plugins
的每一项,将每一款插件都接入mini-webpack
体系使用.
// Compiler.js
const { SyncHook } = require('tapable');
class Compiler {
constructor(options){
this.options= options;
this.hooks = {
initialize:new SyncHook(["arg"]),
compile:new SyncHook(["arg"]),
emit:new SyncHook(["arg"]),
done:new SyncHook(["arg"])
}
this.outPutDir = this.options.output.path; // 输出目录
this.bindHook();
}
/**
* 绑定hook事件
*/
bindHook(){
const { plugins } = this.options;
plugins.forEach((plugin)=>{ // 接入插件
plugin.apply.call(plugin,this);
})
this.hooks.initialize.call(this.options); // 触发initialize钩子,初始化完毕
}
...
}
compiler
实例创建后,调用run
方法就会启动代码编译任务(代码如下).
run
方法代码不多,但几乎囊括了整个构建环节.按照执行顺序分析如下:
run
方法首先触发compile
钩子下定义的插件,此时的时间节点处于代码编译之前
随后complilation
对象被创建出来,complilation
对象是真正负责做事情的,它通过调用buildModule
函数执行代码编译
buildModule
函数执行完后,它会将所有要打包的代码封装到一个数据对象返回给this.assets
有了this.assets
数据,文件生成就好办了.this.emit
函数里面使用nodejs
提供的文件api
,依据this.assets
提供的数据生成了对应的文件目录.
class Compiler {
...
async run(){
this.hooks.compile.call(this.options); // 开始构建之前触发的方法
this.complilation = new Complilation(this.options,this); // 生成构建对象
this.assets = await this.complilation.buildModule(); //开始执行编译
this.hooks.emit.call(this.assets); // 输出静态资源到文件目录之前执行
this.emit(); // 生成打包文件
this.hooks.done.call(); // 编译构建完成
}
}
从run
方法运行的流程可以看出来,最关键的一环就是complilation.buildModule()
返回this.assets
,通过this.assets
就能生成最终的文件目录.
那this.assets
的数据结构是什么样的呢(形似如下)?
this.assets = {
'bundle.js': 'const name = "hello world";console.log(name);'
}
this.assets
数据结构很好理解,key
对应着文件名,value
对应着文件的代码内容.
照此分析,上述结构最终就会在dist
目录下创建一个bundle.js
文件,并把右侧代码内容填充到文件里.
依次类推,那如果生成的this.assets
数据结构如下,dist
目录就会生成两个文件,一个是bundle.js
,另一个是index.html
,并且index.html
里会插入bundle.js
的脚本路径.
this.assets = {
'bundle.js': 'const name = "hello world";console.log(name);',
'index.html': '\n' +
' \n' +
' \n' +
' \n' +
' Document \n' +
'\n' +
'\n' +
' \n' +
' hello world\n' +
' \n' +
'\n' +
'',
}
综上所述,我们最终只要能动态控制this.assets
的数据结构,也就能决定最后打包生成的文件内容.那么不管js
、css
、html
还是图片(图片可以序列化成二进制数据)都能打包生成.
现在结合前面的讲的内容,我们编写一个插件plugin
,使mini-webpack
打包后生成一个index.html
文件,并将js
路径注入进去.
配置文件引入插件HtmlWebpackPlugin
,并使用new
关键字构建一个实例对象.
// webpack.config.js
module.exports = {
...
plugins:[
new HtmlWebpackPlugin({
template:path.join(__dirname,"./src/index.html"), // html页面模板
filename:"index.html" // 文件名
})
]
}
HtmlWebpackPlugin
代码如下,它其实就是一个导出来的类.
类中有一个核心方法apply
,上面介绍compiler
接入插件时就是通过调用插件的apply
方法实现的.
apply
方法内部会监听compiler
的emit
钩子,上面介绍过emit
事件会在生成打包文件之前触发.
那么在emit
事件对应的时间节点,this.assets
已经被complilation.buildModule()
构建完成返回了,我们此时是可以拿到this.assets
数据的.
apply
方法内既然能拿到this.assets
,接下来根据传入的配置获取html
的模板字符串,并借助jsdom
提供的api
,将脚本路径插入到html
中,最后赋值给compiler.assets
就完成目标了.
如此通过一个插件的作用,this.assets
里面就会增加一个index.html
属性,最终打包文件也会生成相应的html
文件.
// html-webpack-plugin.js
const path = require("path");
const fs = require("fs");
const { JSDOM } = require("jsdom");
class HtmlWebpackPlugin {
constructor(options){
this.template = options.template;
this.filename = options.filename || "index.html";
}
apply(compiler){
compiler.hooks.emit.tap("insertHtml",()=>{
const { filename } = compiler.options.output;
const js_url = `./${filename}`;
const code = fs.readFileSync(this.template).toString();
const dom = new JSDOM(code);
const body = dom.window.document.querySelector("body");
body.innerHTML = body.innerHTML + ``;
compiler.assets[this.filename] = dom.serialize();
})
}
}
module.exports = HtmlWebpackPlugin;
complilation
实例是真正负责做事的,它调用buildModule
方法对代码进行编译构建,最终返回this.assets
.
complilation.buildModule
执行后,它首先通过配置项webpack.config.js
获取项目的入口文件地址.
complilation
要做的第一件事情是根据webpack.config.js
中rules
的配置,拿到专门处理.js
为后缀的loader
,对下面js
文件进行处理.
// index.js
const { add } = require("./other");
require("../css/global.css");
const img_url = require("../img/1.png");
console.log(add(1,1));
一般而言对js
文件处理最多的要求是将es6
语法转换成es5
,complilation
通过调用loader
就能让上面的es6
语法转换成es5
.
现在来看loader
是如何将es6
语法转换成es5
的(代码如下).
// webpack.config.js
module.exports = {
module:{
rules:[{
test:/\.js/,
use:[babelLoader] // 所有js文件都要被babelLoader处理一遍
}
}
}
-----------------------------------------------------
// babelLoader.js
const parser = require("@babel/parser");
const { transformFromAstSync } = require("@babel/core");
// 将es6 语法转换成es5
module.exports = function(content){
const ast = parser.parse(content,{
sourceType:"module"
})
const { code } = transformFromAstSync(ast,null,{
presets: ["@babel/preset-env"]
})
return code;
}
babelLoader
是一个导出的函数,content
是传入的源代码.
函数内首先使用babel
相关工具将源码转换成ast
语法树,再设置presets
就能使ast
转换成es5
代码返回.
经过了babelLoader
的处理,入口文件index.js
的源码变成了下面这个样子.
// index.js
var _require = require("./other.js")
require("../css/global.css");
var img_url = require("../img/1.png");
console.log(_require.add(1,1));
通过观察,所有const
关键词都变成了var
,代码确实转换成了es5
.
但是这个代码直接丢到浏览器上运行肯定会报错,因为浏览器不知道require
是什么,它也无法帮你智能的引入css
和图片.
complilation
实例第二步开始扫描上面index.js
里面的require
语法.如果发现是以.js
为后缀的文件,先忽略.
但发现是其他后缀名的文件,它就会在webpack.config.js
中寻找相应的loader
进行处理.比如上面的代码使用处理css
和图片的loader
加工后,代码被转换成下面的样子.
require("../css/global.css")
会被替换成一段函数,当浏览器最终执行这个函数时会将global.css
的样式添加到页面文档的头部.
图片require("../img/1.png")
则替换成一个图片路径,图片的名称被转化成了一段hash
值.
var _require = require("./other.js")
(function(){
var tag = document.createElement("STYLE"); // 创建style标签
tag.innerHTML = "body { color : red;}"; // global.css的样式内容
var head = document.getElementsByTagName("Head")[0]; // 寻找head标签
head.appendChild(tag);
})();
var img_url = "image/0e3c014db8b376a43cbf7cca8291a036a357b2937f4b6dfb03864d0ea2c9bf11.png";
console.log(_require.add(1,1));
上面对css
和图片的处理都可以通过loader
来实现,接下里看一下loader
是如何处理的.
htmlLoader
专门处理css
格式的文件,执行后返回一个字符串函数.
函数内创建style
标签,再将css
文件的内容插入进去,最后放入文档的head
标签下.
// htmlLoader.js
module.exports = function(content){
return `(function(){
var tag = document.createElement("STYLE"); // 创建style标签
tag.innerHTML = ${JSON.stringify(content)};
var head = document.getElementsByTagName("Head")[0]; // 寻找head标签
head.appendChild(tag);
})()`;
}
fileLoader
可以用来处理图片或字体,它会根据文件内容生成一个hash
名称.并在compiler
下emit
钩子绑定一个事件函数.
当mini-webpack
准备打包生成文件之前,emit
钩子函数将被触发.
它会在this.assets
下添加图片,并将图片的相对路径作为返回值.最终dist
目录下也会生成相应的图片.
// fileLoader.js
const path = require("path");
const sha256 = require("sha256");
module.exports = function(){
const ext = path.extname(this.filename); // 获取图片后缀名
const hash = sha256(this.raw); // this.row是文件的二进制数据,生成图片的hash名称
const outputPath = this.query.outputPath || "./"; // 用户配置的目录
const img_relative = path.join(outputPath,`${hash}${ext}`); // 拼接出打包后图片的相对路径
this.context.hooks.emit.tap("imgResolve",()=>{
this.context.assets[img_relative] = this.raw; // 将图片放到dist文件夹中
})
return JSON.stringify(img_relative.replace(/\\/g,"/")); // 相对路径直接返回
}
complilation
实例通过扫描入口文件index.js
所有require
语法,从而对代码的中的css
和图片进行代码转换,这个扫描过程是如何实现的呢?
扫描require
语法通过babel
提供的工具可以轻松做到,首先将代码转化成ast
语法树,再根据语法树的数据特征寻找到代码中所有require
语法,如果发现了css
文件或者图片就可以进行后续的处理.
const traverse = require("@babel/traverse").default;
// ...
traverse(ast, {
CallExpression(path) {
if (path.node.callee.type === "Identifier" && path.node.callee.name === "require"){
... //寻找到了所有require语法
}
},
});
complilation
实例执行完了上述任务后,入口文件的代码最终转换成了下面的样子.
下面这段代码已经对js
、css
和图片都做了处理,但是这段代码丢到浏览器里面仍然运行不了.浏览器不认识require
,它也无法智能的将其他js
引入进来.
var _require = require("./other.js");
(function(){
var tag = document.createElement("STYLE"); // 创建style标签
tag.innerHTML = "body { color : red;}"; // global.css的样式内容
var head = document.getElementsByTagName("Head")[0]; // 寻找head标签
head.appendChild(tag);
})();
var img_url = "image/0e3c014db8b376a43cbf7cca8291a036a357b2937f4b6dfb03864d0ea2c9bf11.png";
console.log(_require.add(1,1));
complilation
实例第三步要开始处理require
引入js
的情况,假设项目的源代码如下.
项目中存在三个文件:index.js
、other.js
以及three.js
.其中index.js
引入了other.js
导出的add
方法.而other.js
引入了three.js
的multiple
方法.
现在如何把这三块代码合并起来放到浏览器中运行呢??
// index.js 入口文件(已被loader处理完)
var _require = require("./other.js");
(function(){
var tag = document.createElement("STYLE"); // 创建style标签
tag.innerHTML = "body { color : red;}"; // global.css的样式内容
var head = document.getElementsByTagName("Head")[0]; // 寻找head标签
head.appendChild(tag);
})();
var img_url = "image/0e3c014db8b376a43cbf7cca8291a036a357b2937f4b6dfb03864d0ea2c9bf11.png";
console.log(_require.add(1,1));
----------------------
// other.js
const { multiple } = require("./three");
exports.add = (a,b)=>{
return multiple(a+b);
}
---------------------
// three.js
exports.multiple = (total)=>{
return total * 10;
}
如果上述三块代码被转换成了以下形式,不就完成了代码的合并吗?
// bundle.js
var entry = "./src/js/index.js"; //入口地址
var deps = { // 依赖图谱
"./src/js/index.js":"var _require = require(\"./other.js\");(function(){var tag = document.createElement(\"STYLE\"); ...... ",
"./other.js":"var _require = require(\"./three.js\");exports.add = (a,b)=>{ return _require.multiple(a+b);}",
"./three.js":"exports.multiple = (total)=>{ return total * 10;}"
}
(function(entry,modules){
function require(pathname){
var module = {
exports:{}
}
;(function(require,module,exports){
const code = modules[pathname];
try{
eval(code);
}catch(error){
console.log(error);
}
})(require,module,module.exports);
return module.exports;
}
require(entry); // 执行入口文件
})(entry,deps)
deps
是一个数据对象,key
对应着文件名,value
对应着被loader
处理完后的代码.
该代码一旦丢入浏览器中,require
函数首先加载入口文件./src/js/index.js
,依赖图谱根据入口文件的地址返回源代码code
,随后使用eval
执行code
.
在执行eval(code)
的过程中,碰到了require("./other.js")
,又会触发require
函数递归调用,直至将所有依赖文件都执行完毕.
因此只要将项目的中各个文件的代码最终编译合成到bundle.js
里,就完成了整个编译构建任务.这个环节最难的就是依赖图谱的生成,那如何将各个文件中的代码转换成了上述依赖图谱deps
的数据结构呢?
我们还是要借助babel
工具实现目标(代码如下).analyseLib
是分析依赖的函数,初始时analyseLib
接受入口文件的源代码和文件名开始执行.
源代码随后被转化成了ast
语法树,通过遍历语法树,找到了入口文件依赖的所有js
文件并存到了deps
数组.
流程继续往下,deps
数组遍历循环,开始递归调用analyseLib
函数.递归全部调用结束后,所有的依赖文件和代码都赋值给了this.modules
.
有了this.modules
(依赖图谱),依据上面的格式,合成bundle.js
的代码就变得非常简单.
complilation
实例最后一步工作就是要在this.assets
对象上添加一个属性名bundle.js
和值,并将自己的构建结果this.assets
返回给compiler
.到此为止mini-webpack
的构建任务便结束了.
const traverse = require("@babel/traverse").default;
class Complilation {
...
modules = {}; //依赖图谱
//依赖分析
//code是源代码,filename是文件名
analyseLib(code,filename){
const ext = path.extname(filename); // 获取文件名后缀
if(ext !== ".js"){ // 只有js文件才需要做依赖分析
return;
}
const ast = parser.parse(code,{ // 将源代码生成ast语法树
sourceType:"module"
})
const deps = [];
traverse(ast, { //遍历ast语法树,寻找require语句
CallExpression(path) {
if (path.node.callee.type === "Identifier" && path.node.callee.name === "require") {
deps.push(path.node.arguments[0].value);
}
},
});
this.modules[filename] = code;
for(let i = 0; i < deps.length ; i++){
const dep = deps[i];
this.analyseLib(this.getCode(dep),dep); // 获取依赖的文件代码,并递归调用analyseLib函数
}
}
...
}
源代码