「一劳永逸」从零实现webpack
前言
为什么我们要学习webpack执行流程?
第一
:webpack我们经常使用,开发必备,囊括了很多很有趣的功能,比如热更新,启动服务器,babel解析等等,简直就是一个包工头,让我们的开发变得简单,高效率
第二
:从架构的角度,我们可以通过学习执行流程,进一步深入源码,来看看webpack的框架设计,学习一些优点
学习不是漫无目的的,不是看完文章就觉得自己掌握了他,要多问几个为什么?
,多动手,多互动
源码github地址请参考 github.com/Y-wson/Dail…
通过这篇文章我们能学到什么?
-
- loader和plugin的原理,以及简单的loader和plugin的书写
-
- tapable的简单使用
-
- babel和ast是如何解析的
-
- webpack执行流程
-
- webpack打包的文件是如何执行的
-
- 实现一个简单的webpack
执行流程
我们先来用文字说一下webpack的执行流程
- 初始化参数:从配置文件和
Shell
语句中读取与合并参数,得出最终的参数; - 开始编译:用上一步得到的参数初始化
Compiler
类,加载所有配置的插件,执行对象的run
方法开始执行编译; 确定入口:根据配置中的entry
找出所有的入口文件 - 编译模块:从入口文件出发,调用所有配置的
Loader
对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理; - 完成模块编译:在经过第3步使用
Loader
翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系 - 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的
Chunk
,再把每个Chunk
转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会 - 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
代码实现
接下来我们根据上面的执行流程来一点一点展开讲解
在写代码之前,我们先把项目用到的库说一下
@babel/core babel核心库,可以用来依据plugin和presets转换代码
@babel/preset-env 用来把es6代码转换为es5代码
ejs 模板文件,支持在html中写js
tapable 一种发布订阅模式,webpack实现把插件串联起来的核心
大家yarn add安装一下
目录结构如下
然后我们先简单写一个webpack.config.js,webpack.config.js大家都知道是做什么的,就是webpack的配置项
// webpack.config.js
const path = require("path");
module.exports = {
context: process.cwd(), // 当前的根目录
mode: "development", // 工作模式
entry: path.join(__dirname, "src/index.js"), // 入口文件
output: { // 出口文件
filename: "bundle.js",
path: path.join(__dirname, "./dist"),
},
module: {// 要加载模块转化loader
rules: [
{
test: /\.js$/,
use: [
{
loader: path.join(__dirname, "./loaders/babel-loader.js"),
options: {
presets: ["@babel/preset-env"],
},
},
],
},
],
},
plugins: [new RunPlugin(), new DonePlugin()], //插件
};
第一步:初始化参数:从配置文件和Shell
语句中读取与合并参数,得出最终的参数;
这一步实现起来很简单
// lib/Compiler.js
class Compiler {
}
let options = require("../webpack.config");
第二步: 开始编译:用上一步得到的参数初始化Compiler
类,
这里我们写一个Compiler类,代表编译的意思,初始化options为webpack.config.js中配置的内容
//lib/Compiler
class Compiler {
constructor(options){
this.options = options;
}
}
let options = require("../webpack.config");
第三步:加载所有配置的插件(说的加载,其实是注册的意思,这里只是注册,然后在相应的阶段,执行相应的参数,比如在编译开始的时候,我们执行runPlugin插件,输出开始编译文字),执行对象的run
方法开始执行编译;
大家都知道插件是一个类,这就是我们为什么在webpack.config.js中配置plugins中配置类的时候,要new一下,因为插件是一个类的原因,把类的实例传给plugins
plugins: [new RunPlugin(), new DonePlugin()], //插件
我们这里面加载了两个类,new RunPlugin,开始编译的意思 new DonePlugin就是执行完成的意思
每一个插件里面都要有一个apply方法,用于注册插件,然后把Compiler的实例传给插件,插件就可以监听
run钩子了,注意这里只是监听,等待广播后才可以执行
是不是感觉很熟悉,对的,这就是发布订阅者模式的应用,那webpack里面是如何实现发布订阅者模式的呢?
// plugins/RunPlugin
module.exports = class RunPlugin {
// 注册插件
apply(compiler) {
compiler.hooks.run.tap("RunPlugin", () => {
console.log("RunPlugin");
});
}
};
这里我们要说一个库, tapable,这个库非常重要,webpack之所以能把插件串联起来,完全归功于tapable,
let { SyncHook } = require("tapable");
let hook = new SyncHook();
// 监听
hook.tap("some name", () => {
console.log("some name");
});
// 触发
hook.call();
第四步:确定入口:根据配置中的entry
找出所有的入口文件
定义了两个钩子,一个run钩子,一个done钩子,再来解释一下这个hooks,我们可以把这个钩子想象为react中的生命周期函数,有开始的声明周期run,有结束的声明周期done,而插件可以想想为组件,只要webpack执行相应的生命周期函数,那么插件中的生命周期函数中的代码块就会执行
// 使用同步钩子
let { SyncHook } = require("tapable");
class Compiler {
constructor(options) {
this.options = options;
// 定义两个钩子
this.hooks = {
run: new SyncHook(),
done: new SyncHook(),
};
}
run() {
this.hooks.run.call(); // 触发run钩子执行
let entry = path.join(this.options.context, this.options.entry);
}
}
let options = require("../webpack.config");
let compiler = new Compiler(options);
if (options.plugins && Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
// 调用插件的注册方法
plugin.apply(compiler);
}
}
我们可以把new Compiler这一部分放到一个单独的文件夹webpack.js中
// bin/webpack.js
const Compiler = require("../lib/Compiler");
// 1.获取打包配置
const config = require("../webpack.config");
// 2.创建一个compiler实例
const createCompiler = function () {
// 创建compiler实例
const compiler = new Compiler(config);
// 加载插件
if (Array.isArray(config.plugins)) {
for (const plugin of config.plugins) {
plugin.apply(compiler);
}
}
return compiler;
};
const compiler = createCompiler();
// 3.开启编译
compiler.run();
那么package.json配置一下build就可以指向webpack.js
"scripts": {
"build": "node bin/webpack.js"
},
第五步:从入口文件出发,调用所有配置的Loader
对模块进行编译,
run(){
this.hooks.run.call(); // 触发run钩子执行
let entry = path.join(this.options.context, this.options.entry);
// 构建模块
this.buildModule(entry, true);
}
我们知道webpack里面有module,chunk,file三种概念
file>chunk>module
时间原因,我们这里只通过module演示
在构造函数中我们初始化modules,用来保存模块
constructor(options) {
this.options = options;
this.modules = [];
this.hooks = {
run: new SyncHook(),
done: new SyncHook(),
};
}
第六步:构建模块,并进行广度优先遍历所有依赖的子模块
读取源代码
buildModule(modulePath, isEntry) {
// 模块源代码
const source = this.getSource(modulePath);
}
模块的源代码我们要经过loader编译
所以先写一个loader,配置到webpack.config.js中
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: path.join(__dirname, "./loaders/babel-loader.js"),
options: {
presets: ["@babel/preset-env"],
},
},
],
},
],
},
babel-loader.js是非常具有代表性的loader,所以我们就写这个loader
babel-loader中我们直接选择把options中的presets加载一下就可以了
loader/babel-loader.js
const babel = require("@babel/core");
const loader = function (source, options) {
let result = babel.transform(source, {
presets: options.presets,
});
return result.code;
};
module.exports = loader;
然后我们的getSource也就能写出来了,返回进行编译后的源码
getSource(modulePath) {
// 读取文件内容
let content = fs.readFileSync(modulePath, "utf-8");
const rules = this.options.module.rules;
for (let rule of rules) {
const { test, use } = rule;
if (test.test(modulePath)) {
// 递归所有loader
// use此处我们当做数组来处理,按照从右往左的方式执行
let length = use.length - 1;
function loopLoader() {
// 按照从右往左执行
const { loader, options } = use[length--];
let loaderFunc = require(loader);
// loader是一个函数
content = loaderFunc(content, options);
if (length >= 0) {
loopLoader();
}
}
if (length >= 0) {
loopLoader();
}
}
}
return content;
}
第七步:再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
我们如何找出该模块依赖的模块呢,我们要使用ast(抽象语法树了),可以方便的获取到依赖的模块,这是第一点我们要做的,还有一点要做的就是我们加载的模块里面的路径都是相对路径,所以我们也要把路径传进去,得到如何当前解析模块的相对路径
// 构建模块,并进行广度优先遍历所有依赖的子模块
buildModule(modulePath, isEntry) {
// 模块源代码
const source = this.getSource(modulePath);
// replace是在兼容windows系统
modulePath =
"./" + path.relative(this.root, modulePath).replace(/\\/g, "/");
const { sourceCode, dependencies } = this.parse(source, modulePath);
// 这里一个完整的模块就好了,保存到modules中
this.modules[modulePath] = JSON.stringify(sourceCode);
// 递归获取所有的模块依赖,并保存所有的路径与依赖的模块
dependencies.forEach((d) => {
this.buildModule(path.join(this.root, d));
}, false);
}
而相对的解析代码为
// 根据模块的源码进行解析
parse(source, moduleName) {
let dependencies = [];
const dirname = path.dirname(moduleName);
const requirePlugin = {
visitor: {
// 替换源码中的require为__webpack_require__
CallExpression(p) {
const node = p.node;
if (node.callee.name === "require") {
node.callee.name = "__webpack_require__";
// 路径替换
let modulePath = node.arguments[0].value;
modulePath =
"./" + path.join(dirname, modulePath).replace(/\\/g, "/");
node.arguments = [t.stringLiteral(modulePath)];
dependencies.push(modulePath);
}
},
},
};
let result = babel.transform(source, {
plugins: [requirePlugin],
});
return {
sourceCode: result.code,
dependencies,
};
}
第八步:输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk
,再把每个Chunk
转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
因为时间关系,我们就不把模块组装成chunks,file了。
经过上面的一番操作,我们已经完成90%的工作量了,接下来就可以把文件打包出来了,完善一下我们的run方法,
第九步:输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
run() {
this.hooks.run.call();
const entry = this.options.entry;
this.buildModule(entry, true);
const outputPath = path.resolve(this.root, this.options.output.path);
const filePath = path.resolve(outputPath, this.options.output.filename);
// 输出文件
this.mkdirp(outputPath, filePath);
}
在写一下mkdirp方法
mkdirp(outputPath, filePath) {
console.log("simple-webpack ------------------> 文件输出");
const { modules, entryPath } = this;
//创建文件夹
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath);
}
ejs
.renderFile(path.join(__dirname, "Template.ejs"), { modules, entryPath })
.then((code) => {
fs.writeFileSync(filePath, code);
console.log("simple-webpack ------------------> 打包完成");
});
}
ejs模板为
(function (modules) {
var installedModules = {};
function __webpack_require__(moduleId) {
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
module.l = true;
return module.exports;
}
return __webpack_require__("<%-entryPath%>");
})
({
<%for (const key in modules) {%>
"<%-key%>":
(function (module, exports, __webpack_require__) {
eval(<%-modules[key]%>);
}),
<%}%>
});
好啦,终于完成了,执行一下打包命令,npm run build
(function (modules) {
var installedModules = {};
function __webpack_require__(moduleId) {
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
var module = (installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {},
});
modules[moduleId].call(
module.exports,
module,
module.exports,
__webpack_require__
);
module.l = true;
return module.exports;
}
return __webpack_require__("./src/index.js");
})({
"./src/index.js": function (module, exports, __webpack_require__) {
eval(
'"use strict";\n\nvar _app = __webpack_require__("./src/app.js");\n\nconsole.log(_app.a);'
);
},
"./src/app.js": function (module, exports, __webpack_require__) {
eval(
'"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nexports.a = void 0;\nvar a = "app";\nexports.a = a;'
);
},
});
然后我们用index.html文件引用一下我们打包出来的bundle.js,看是否报错 index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body></body>
<script src="./dist/bundle.js"></script>
</html>
打开浏览器一看,发现我们想要的结果就历历在目,大功告成
小伙伴们可以按照上面的代码进行手写一下,如果有任何疑问,欢迎评论区留言,
总结
根据开头的webpack执行流程,我们手写了一遍webpack源码,通过手写源码,我们加深了对webpack的执行流程的认识,各位小伙伴们一定要多动手写起来
参考:
转载自:https://juejin.cn/post/7058217261336625182