一、前言
Webpack 一直都是有些人的心魔,不清楚原理是什么,不知道怎么去配置,只会基本的 API 使用。它就像一个黑盒,让部分开发者对它望而生畏。
大家之所以认为 Webpack 复杂,很大程度上是因为它依附着一套庞大的生态系统。其实 Webpack 的核心流程远没有我们想象中那么复杂,甚至只需百来行代码就能完整复刻出来。
因此在学习过程中,我们应注重学习它本身的设计思想,不管是它的 Plugin 系统还是 Loader 系统,都是建立于这套核心思想之上。所谓万变不离其宗,一通百通。
二、基本使用
初始化项目:
npm init //初始化一个项目
yarn add webpack //安装项目依赖
安装完依赖后,根据以下目录结构来添加对应的目录和文件:
├── node_modules
├── package-lock.json
├── package.json
├── webpack.config.js #配置文件
├── debugger.js #测试文件
└── src # 源码目录
|── index.js
|── name.js
└── age.js
webpack.config.js
const path = require("path");
module.exports = {
mode: "development", //防止代码被压缩
entry: "./src/index.js", //入口文件
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].js",
},
devtool: "source-map", //防止干扰源文件
};
src/index.js
const name = require("./name");
const age = require("./age");
console.log("entry文件打印作者信息", name, age);
src/name.js
module.exports = "不要秃头啊";
src/age.js
module.exports = "99";
文件依赖关系:
入口文件 src/index.js
依赖文件 src/name.js src/age.js
Webpack 本质上是一个函数,它接受一个配置信息作为参数,执行后返回一个 compiler 对象,调用 compiler 对象中的 run 方法就会启动编译。run 方法接受一个回调,可以用来查看编译过程中的错误信息或编译信息。
debugger.js
// const { webpack } = require("./webpack.js"); //后面自己手写
const { webpack } = require("webpack");
const webpackOptions = require("./webpack.config.js");
const compiler = webpack(webpackOptions);
//开始编译
compiler.run((err, stats) => {
console.log(err);
console.log(
stats.toJson({
assets: true, //打印本次编译产出的资源
chunks: true, //打印本次编译产出的代码块
modules: true, //打印本次编译产出的模块
})
);
});
执行打包命令:
node ./debugger.js
得到产出文件 dist/main.js
运行该文件,得到结果:
entry文件打印作者信息 不要秃头啊 99
三、核心思想
我们先来分析一下源代码和构建产物之间的关系:
入口文件(src/index.js)被包裹在最后的立即执行函数中,而它所依赖的模块(src/name.js、src/age.js)则被放进了 modules 对象中(modules 用于存放入口文件的依赖模块,key 值为依赖模块路径,value 值为依赖模块源代码)。
require 函数是 web 环境下 加载模块的方法( require 原本是 node环境 中内置的方法,浏览器并不认识 require,所以这里需要手动实现一下),它接受模块的路径为参数,返回模块导出的内容。
要想弄清楚 Webpack 原理,那么核心问题就变成了:如何将左边的源代码转换成 dist/main.js 文件?
核心思想:
- 第一步:首先,根据配置信息(webpack.config.js)找到入口文件(src/index.js)
- 第二步:找到入口文件所依赖的模块,并收集关键信息:比如路径、源代码、它所依赖的模块等:
var modules = [
{
id: "./src/name.js",//路径
dependencies: [], //所依赖的模块
source: 'module.exports = "不要秃头啊";', //源代码
},
{
id: "./src/age.js",
dependencies: [],
source: 'module.exports = "99";',
},
{
id: "./src/index.js",
dependencies: ["./src/name.js", "./src/age.js"],
source:
'const name = require("./src/name.js");\n' +
'const age = require("./src/age.js");\n' +
'console.log("entry文件打印作者信息", name, age);',
},
];
- 第三步:根据上一步得到的信息,生成最终输出到硬盘中的文件(dist): 包括 modules 对象、require 模版代码、入口执行文件等
在这过程中,由于浏览器并不认识除 html、js、css 以外的文件格式,所以我们还需要对源文件进行转换 —— Loader 系统。
Loader 系统 本质上就是接收资源文件,并对其进行转换,最终输出转换后的文件:
除此之外,打包过程中也有一些特定的时机需要处理,比如:
- 在打包前需要校验用户传过来的参数,判断格式是否符合要求
- 在打包过程中,需要知道哪些模块可以忽略编译,直接引用 cdn 链接
- 在编译完成后,需要将输出的内容插入到 html 文件中
- 在输出到硬盘前,需要先清空 dist 文件夹
这个时候需要一个可插拔的设计,方便给社区提供可扩展的接口 —— Plugin 系统。
Plugin 系统 本质上就是一种事件流的机制,到了固定的时间节点就广播特定的事件,用户可以在事件内执行特定的逻辑,类似于生命周期:
打包前的生命周期 => 打包过程中的生命周期 => 打包成功的生命周期 => 打包失败的生命周期
这些设计也都是根据使用场景来的,只有理清需求后我们才能更好的理解它的设计思想。
四、架构设计
在理清楚核心思想后,剩下的就是对其进行一步步拆解。
上面提到,我们需要建立一套事件流的机制来管控整个打包过程,大致可以分为三个阶段:
- 打包开始前的准备工作
- 打包过程中(也就是编译阶段)
- 打包结束后(包含打包成功和打包失败)
这其中又以编译阶段最为复杂,另外还考虑到一个场景:watch mode(当文件变化时,将重新进行编译),因此这里最好将编译阶段(也就是下文中的compilation)单独解耦出来。
在 Webpack 源码中,compiler 就像是一个大管家,它就代表上面说的三个阶段,在它上面挂载着各种生命周期函数,而 compilation 就像专管伙食的厨师,专门负责编译相关的工作,也就是打包过程中这个阶段。画个图帮助大家理解: