手写一个简易版的打包器 webpack
本教程将带着大家一步一步制作一个简易的 webpack。
https://github.com/Sunny-117/mini-webpack
首先我们创建一个简单的项目,项目结构如下:
上面是一个基本的项目目录,其中 src 是我们的源码目录,彼此之间存在一定的依赖关系
// index.js
import { say } from "./hello.js";
document.write(say("webpack"));
// hello.js
import { randomNum } from "./tool1.js";
import { printTxt } from "./tool2.js";
export function say(name) {
const num = randomNum(1, 100);
printTxt(num);
return `hello ${name}`;
}
// tool1.js
export function randomNum(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
// tool2.js
export function printTxt(txt) {
console.log(txt);
}
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>hello webpack</title>
</head>
<body>
<script src="./src/index.js" type="module"></script>
</body>
</html>
接下来我们要进行打包,就需要有一个 webpack 配置文件
// webpack 配置文件
const path = require("path");
module.exports = {
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "./dist"),
filename: "main.js",
},
};
理论上来讲,有了 webpack 配置文件,我们就可以安装 webpack 来进行打包了。但是这里我们要自己来制作 webpack。
所以到这一步为止,我们的准备工作就做好了。
接下来,在 lib 目录下面来制作我们的的小型 Webpack。
该目录下存在两个文件,分为是 Compiler 和 Parser
其中 Compiler 负责主要的流程工作,回忆一下整个 Webpack 的打包过程:
-
初始化
-
编译
-
输出
我们创建一个 Compiler 类:
// 引入内置模块
const fs = require("fs");
const path = require("path");
class Compiler {
constructor(options) {
this.entry = options.entry; // 实例属性 entry 存储用户配置的 entry 信息
this.output = options.output; // 实例属性 output 存储用户配置的 output 信息
this.modules = []; // 存储模块
}
// 启动构建
run() {}
// 构建模块对象
build() {}
// 生成对应代码
generate() {}
}
// 导出这个类
module.exports = Compiler;
我们首先来看 build 方法
// 开始构建
build(filename) {
const { getAst, getDependecies, getCode } = Parser;
// 获取抽象语法树
const ast = getAst(filename);
// 获取到依赖文件
const dependecies = getDependecies(ast, filename);
// 获取到依赖文件对应的代码
const code = getCode(ast);
// 返回解析过后的内容对象
// 里面包含文件路径、依赖以及代码
return {
filename,
dependecies,
code,
};
}
该方法会接收一个 path 路径,例如我们指定的入口文件 ./src/index.js
然后该方法会从 Parser 中解构出三个方法,分别是
- getAst
- getDependecies
- getCode
最后将这些方法的返回结果组成一个对象返回。
通过上面的 build 方法,我们知道由 Parser 文件导出了 getAst、getDependecies、getCode
这里会用到 3 个依赖,分别是 @babel/parser、@babel/traverse、@babel/core
"dependencies": {
"@babel/core": "^7.7.7",
"@babel/parser": "^7.7.7",
"@babel/preset-env": "^7.7.7",
"@babel/traverse": "^7.7.4"
}
Parser 对应的代码如下:
// 引入内置模块
const fs = require("fs");
const path = require("path");
// 引入第三方库
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAst } = require("@babel/core");
module.exports = {
// 获取抽象语法树
getAst: function (path) {
// 根据传入的地址来读取文件内容
const content = fs.readFileSync(path, "utf-8");
// 将内容转为抽象语法树
return parser.parse(content, {
sourceType: "module",
});
},
// 获取依赖
/**
*
* @param {*} ast 抽象语法书
* @param {*} filename 文件路径
*/
getDependecies: function (ast, filename) {
// 创建一个对象用于存储依赖,最终返回这个对象
const dependecies = {};
// 遍历所有的 import 模块,存入dependecies
traverse(ast, {
// 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
ImportDeclaration({ node }) {
const dirname = path.dirname(filename);
// 保存依赖模块路径,之后生成依赖关系图需要用到
const filepath = "./" + path.join(dirname, node.source.value);
dependecies[node.source.value] = filepath;
},
});
// 返回该对象
return dependecies;
},
// 获取代码
getCode: function (ast) {
// AST 转换为 code
const { code } = transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
return code;
},
};
有了 build 方法用于构建模块信息对象后,我们就可以来书写 run 方法了。
整个 run 方法分为以下几个步骤:
- 首先第一步,我们将入口文件的路径传递过去,生成一个对应的入口文件的模块放入到数据里面
// 调用实例方法 build,将入口路径传递过去
const info = this.build(this.entry);
this.modules.push(info); // 此对象作为一个模块存储到 modules 数组中
- 寻找模块是否有其他依赖,有的话同样是生成模块信息对象放入到数组里面
// 接下来我们需要找到所有的依赖
for (var i = 0; i < this.modules.length; i++) {
var obj = this.modules[i];
// 查看当前的模块是否还有依赖
if (obj.dependecies && this.modules.indexOf(obj.filename) === -1) {
// 进入此 if,说明还有依赖,遍历所有的依赖
for (const dependency in obj.dependecies) {
// 重复之前的行为,得到模块信息对象后推入 modules 数组
this.modules.push(this.build(obj.dependecies[dependency]));
}
}
}
- 生成依赖图
// 代码运行到这一步,modules 数组里面存放了所有依赖模块的信息对象
// 接下来我们来生成依赖图
// 这里涉及到了数组 reduce 的基本用法,graph 代表前一项,item 代表当前项
const dependencyGraph = this.modules.reduce(
(graph, item) => ({
...graph,
[item.filename]: {
dependecies: item.dependecies,
code: item.code,
},
}),
{}
);
- 根据依赖图生成打包后的代码
// 生成代码
this.generate(dependencyGraph);
完整的 run 方法代码如下:
// 构建启动
run() {
// 调用实例方法 build,将入口路径传递过去
const info = this.build(this.entry);
this.modules.push(info); // 此对象作为一个模块存储到 modules 数组中
// 接下来我们需要找到所有的依赖
for (var i = 0; i < this.modules.length; i++) {
var obj = this.modules[i];
// 查看当前的模块是否还有依赖
if (obj.dependecies && this.modules.indexOf(obj.filename) === -1) {
// 进入此 if,说明还有依赖,遍历所有的依赖
for (const dependency in obj.dependecies) {
// 重复之前的行为,得到模块信息对象后推入 modules 数组
this.modules.push(this.build(obj.dependecies[dependency]));
}
}
}
// 代码运行到这一步,modules 数组里面存放了所有依赖模块的信息对象
// 接下来我们来生成依赖图
// 这里涉及到了数组 reduce 的基本用法,graph 代表前一项,item 代表当前项
const dependencyGraph = this.modules.reduce(
(graph, item) => ({
...graph,
[item.filename]: {
dependecies: item.dependecies,
code: item.code,
},
}),
{}
);
// 所生成的 dependencyGraph 实际上就是一个对象
// 对象的键名为模块路径,对象值为 dependecies 和 code 组成的对象
// 接下来我们来生成代码
this.generate(dependencyGraph);
}
该方法用于生成对应的 bundle
// 生产代码
generate(code) {
// 生成用户配置的打包文件存放路径
const filePath = path.join(this.output.path, this.output.filename);
// 生成 bundle
const bundle = `(function(graph){
function require(moduleId){
function localRequire(relativePath){
return require(graph[moduleId].dependecies[relativePath])
}
var exports = {};
(function(require,exports,code){
eval(code)
})(localRequire,exports,graph[moduleId].code);
return exports;
}
require('${this.entry}')
})(${JSON.stringify(code)})`;
// 最后将生成的 bundle 写入文件
fs.writeFileSync(filePath, bundle);
}
可以看到,该方法的基本思路就是先获取用户配置的打包文件存放路径,生成一个 bundle 字符串,最后将这个字符串写入文件。
那么这里令人比较疑惑的则是 bundle 字符串的生成。
这里首先传入了依赖图,其实就是一个这样的对象
然后定义一个立即执行函数,传入生成的依赖关系图,这里是以字符串的方式传递进去的
(function(graph){
function require(moduleId){
function localRequire(relativePath){
return require(graph[moduleId].dependecies[relativePath])
}
// ...
}
require('${this.entry}')
})(${JSON.stringify(code)})
在立即执行函数中,调用了 require 函数,然后重写了 require 函数,通过层层递归拿到所有的依赖模块对象。
function require(moduleId){
// ...
var exports = {};
(function(require,exports,code){
// commonjs 语法使用 module.exports 暴露实现
// 我们传入的 exports 对象会捕获依赖对象(hello.js)暴露的实现(exports.say = say)并写入
eval(code)
})(localRequire,exports,graph[moduleId].code);
return exports;
}
require('${this.entry}')
之后,使用 eval 来执行代码。
eval(code)的执行,是先通过require('./hello.js')拿到_hello,但要注意的是,这里的require实际是传入的localRequire了,而localRequire做的是就是寻址,通过'./hello.js'找到'./src/hello.js',并使用外面定义的require方法执行,也就是eval执行hello.js代码,这个方法返回的就是一个exports对象
最后,我们来进行打包测试
在一个 JS 文件中写入如下代码,并执行
const Complier = require("./lib/Compiler");
const options = require("./webpack.config");
new Complier(options).run();
执行后会在 dist 目录下生成一个打包文件,然后在 HTML 中引入该文件
<script src="./dist/main.js"></script>
至此,我们就实现了一个小型的 Webpack。
通过自己手动实现 Webpack,能够加深我们对 Webpack 整个构建流程的一个理解。
- 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数
- 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的
run
方法开始执行编译 - 确定入口:根据配置中的
entry
找出所有的入口文件 - 编译模块:从入口文件出发,调用所有配置的
loader
对模块进行翻译,再找出该模块依赖的模块,再递归
本步骤直到所有入口依赖的文件都经过了本步骤的处理 - 完成模块编译:在经过第 4 步使用
loader
翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系图
- 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的
Chunk
,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会 - 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
-EOF-