在写min-webpack之前,我们先试着理解webpack打包产物bundle的代码思路。通过了解了bundle代码的逻辑后,再去理解webpack的核心源码到底是通过什么处理自动生成的bundle,最终实现一个min-webpack。
初始化一个项目
shell
npm init -y
package.json 添加 "type": "module" 以支持ESM模块
json
// package.json
{
"type": "module"
}
这样,在项目中就可以以ESM的方式引入模块
js
import fs from 'fs';
新建一个example文件夹,模拟webpack打包,里面新建文件:
main.js可以理解为打包入口文件foo.js被main.js引入,模拟文件间的依赖关系bundle.js模拟webpack打包后的bundle,这里会先分析webpack的打包思路,后面逐步完善代码index.html用于引入打包后的bundle
js
// main.js
import foo from './foo.js';
foo();
console.log('This is main.js');
js
// foo.js
export default function foo() {
console.log('This is foo.js');
}
js
// bundle.js
// 这个是核心,后面写
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>min-webpack</title>
</head>
<body>
<script src="./bundle.js"></script>
</body>
</html>
bundle 代码思路分析
在说bundle的代码思路之前,我们先了解下webpack的核心原理,那就是根据配置文件,找到入口文件,找到文件间的依赖关系,形成依赖树,遍历这个依赖树,把所有资源都打包到一个bundle文件中(这里不考虑分包机制),所以,我们可以这样理解bundle产物的内容:
- 所有资源模块都汇聚在bundle.js
- 为了防止各个模块间命名冲突,需要有个函数包裹着各个模块,这个暂且称为模块函数。
- 从入口文件模块开始,找到入口模块所在的模块函数并执行
js
// bundle.js
// 立即执行入口文件
mainjs();
function mainjs() {
// 模仿 main.js 写的伪代码方便理解
import foo from './foo.js';
foo();
console.log('This is main.js');
}
function foojs() {
export default function() {
console.log('This is foo.js');
}
}
意思是这么个意思了,先写段伪代码便于理解。这里是先理解webpack的打包思路,后面再逐一完善代码。
然而ESM的import只能在顶层作用,不支持在函数里写ESM,但是支持CJS的模块化规范,所以需要把ESM模块翻译成CJS模块规范,需要自定义实现require方法。
js
// bundle.js
function webpackRequire(filePath) {}
// 立即执行入口文件
mainjs();
function mainjs() {
// 模仿 main.js 写的伪代码
const foo = webpackRequire('./foo.js');
foo();
console.log('This is main.js');
}
function foojs() {
export default function() {
console.log('This is foo.js');
}
}
这里,我们使用webpackRequire的目的是引入模块,而引入模块是为了能够执行模块的逻辑,同时获得模块导出的成员。这里,我们便明确了webpackRequire的内部需要实现的功能是:
- 执行入参
filePath对应的模块代码 - 返回该模块导出的成员
我们知道CJS的require导入对应了导出module.exports,所以对于文件中的export我们可以用module.exports来实现。
js
// bundle.js
function webpackRequire(filePath) {
const module = {
exports: {},
};
// 返回该模块导出的成员
return module.exports;
}
js
// bundle.js
function foojs() {
// export default function() {
// console.log('This is foo.js');
// }
// 改成cjs
function foo() {
console.log('This is foo.js');
}
// 这里module我们还不明确怎么来的,可以先这样写着
module.exports = {
foo,
}
}
而对于执行模块代码这部分,为了方便理解,我们可以先构造一个映射关系表示模块路径filePath与模块之间的关系:
js
// bundle.js
const FnMap = {
'./main.js': mainjs,
'./foo.js': foojs,
}
这样就简单地创建了一个模块路径和模块之间的映射关系。
那要执行模块代码就简单了:
js
// bundle.js
function webpackRequire(filePath) {
const module = {
exports: {},
}
const fn = FnMap[filePath];
// 执行入参`filePath`对应的模块代码
fn();
// 返回该模块导出的成员
return module.exports;
}
这个fn就是我们写的mainjs、foojs。上面说到不明确module的来源,那这里,我们在webpackRequire这里就有一个创建好了的module,刚好也可以给到fn作为入参。
所以mainjs、foojs这些代表模块的函数,可以有统一的入参:
js
function mainjs(module) {
// 模仿 main.js 写的伪代码
// import foo from './foo.js';
const { foo } = webpackRequire('./foo.js');
foo();
console.log('This is main.js112');
}
function foojs(module) {
// export default function() {
// console.log('This is foo.js');
// }
// 改成cjs
function foo() {
console.log('This is foo.js');
}
module.exports = {
foo,
}
}
而入口文件的立即执行,可以直接改成调用webpackRequire('./main.js')
至此,放上整个bundle.js的代码:
js
const FnMap = {
'./main.js': mainjs,
'./foo.js': foojs,
}
function webpackRequire(filePath) {
const module = {
exports: {},
}
const fn = FnMap[filePath];
fn(module);
return module.exports;
}
webpackRequire('./main.js');
function mainjs(module) {
// import foo from './foo.js';
// 改成cjs
const { foo } = webpackRequire('./foo.js');
foo();
console.log('This is main.js112');
}
function foojs(module) {
// export default function() {
// console.log('This is foo.js');
// }
// 改成cjs
function foo() {
console.log('This is foo.js');
}
module.exports = {
foo,
}
}
然而事实上webpack的模块路径与模块之间的映射关系并不是定义常量实现的。
我们可以进一步改进上面的bundle.js代码:
- 把映射关系作为立即执行函数的入参传进去
- 原本写在里面的
mainjs、foojs模块函数抽出去写到入参映射关系那里 - 里面用到
webpackRequire函数,这个也好办,也把它作为模块函数的入参传过去供其调用
js
(function(modules) {
function webpackRequire(filePath) {
const module = {
exports: {},
}
const fn = modules[filePath];
fn(webpackRequire, module);
return module.exports;
}
webpackRequire('./main.js');
})({
'./main.js': function(require, module) {
const { foo } = require('./foo.js');
foo();
console.log('This is main.js112');
},
'./foo.js': function(require, module) {
function foo() {
console.log('This is foo.js');
}
module.exports = {
foo,
}
},
})
在入参那里,模块函数的key是./main.js、./foo.js,这个还可以改进一下。
这个key需要是模块函数的唯一标识才行,这里我们可以改成数字,从0开始,依次递增,这样就可以做到模块被唯一标识,就不会造成冲突了。修改后:
js
(function(modules) {
function webpackRequire(filePath) {
const module = {
exports: {},
}
const fn = modules[filePath];
fn(webpackRequire, module);
return module.exports;
}
webpackRequire(0);
})({
0: function(require, module) {
const { foo } = require(1);
foo();
console.log('This is main.js112');
},
1: function(require, module) {
function foo() {
console.log('This is foo.js');
}
module.exports = {
foo,
}
},
})
我们知道webpack的bundle是webpack通过控制台命令自动生成的,那么我想你肯定很想知道webpack是通过什么来实现自动生成了这么一个bundle.js的呢?
接下来将实现一个min-webpack,了解webpack内部生成bundle的核心源码。
min-wqebpack
在项目根目录中创建一个新文件index.js,在这里写min-webpack的主要代码。
由bundle.js可以知道,我们最为关键需要获取到的是模块的key与模块函数之间的映射关系,而且模块函数内部的模块内容必须是遵循CJS规范的,由上面分析过,在函数里不能使用ESM。
一个模块有其依赖的模块集合,所以还需要找到模块的依赖集合。
所以,初步可以得出,webpack在打包的时候主要干的事是:
- 从0开始定义模块的
key - 获取到模块本身的内容
code,需要遵循CJS规范 - 获取模块的依赖集合
deps - 遍历模块的依赖集合,找到依赖的依赖,这时依赖模块对应的
key需要依次递增以唯一标识依赖模块;在遍历模块依赖集合时,把模块的以上数据添加到一个依赖关系数据(一种数据结构)中
当然,这里还会有loader、plugins相关的逻辑,我们这里先不关注这些,主要先关注主要的打包流程。
从0开始定义模块的key
入口文件的唯一key从0开始,这里先定义一个变量id表示key,后面遍历依赖集合的话需要依次递增,所以可以在返回id的时候就可以先给它自增id++。
因为需要遍历依赖集合,所以一开始我们就先定义好一个函数createAsset,里面专门实现获取模块数据id、code、deps。
js
let id = 0;
function createAsset() {
return {
id: id++,
}
}
获取到模块本身的内容code
获取文件内容,其实需要获取代码的抽象语法树,需要用到@babel-paser依赖,还有代码需要转成CJS规范,所以需要用到babel-core、babel-preset-env。
shell
pnpm i @babel/parser@7.16.7
pnpm i babel-core@6.26.3
pnpm i babel-preset-env@1.7.0
js
import fs from 'fs';
import parser from '@babel/parser';
import { transformFromAst } from 'babel-core';
function createAsset(filePath) {
// 获取文件内容
let source = fs.readFileSync(filePath, {
encoding: 'utf-8',
});
// 文件内容转 ast 即 抽象语法树
const ast = parser.parse(source, {
sourceType: 'module',
});
// 通过配置presets把代码的esm规范转成cjs规范
const { code } = transformFromAst(ast, null, {
presets: ['env'],
});
return {
id: id++,
code,
}
}
createAsset('./example/main.js');
这是生成的抽象语法树 ast
js
// ast
Node {
type: 'File',
start: 0,
end: 67,
loc: SourceLocation {
start: Position { line: 1, column: 0 },
end: Position { line: 4, column: 31 },
filename: undefined,
identifierName: undefined
},
errors: [],
program: Node {
type: 'Program',
start: 0,
end: 67,
loc: SourceLocation {
start: [Position],
end: [Position],
filename: undefined,
identifierName: undefined
},
sourceType: 'module',
interpreter: null,
body: [ [Node], [Node], [Node] ],
directives: []
},
comments: []
}
这是根据ast生成的CJS代码code
js
"use strict";
var _foo = require("./foo.js");
var _foo2 = _interopRequireDefault(_foo);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
(0, _foo2.default)();
console.log('This is main.js');
获取模块的依赖集合deps
接着需要遍历整个抽象语法树,获取到node.source.value这个值,这个值表示的是本模块依赖到的模块集合。
遍历抽象语法树需要用到依赖@babel/traverse
shell
pnpm i @babel/traverse@7.16.7
定义一个常量deps数组,在遍历ast时不断把依赖到的模块路径值添加到数组里。
js
import fs from 'fs';
import parser from '@babel/parser';
import { transformFromAst } from 'babel-core';
import traverse from '@babel/traverse';
function createAsset(filePath) {
// 获取文件内容
let source = fs.readFileSync(filePath, {
encoding: 'utf-8',
});
// 文件内容转 ast 即 抽象语法树
const ast = parser.parse(source, {
sourceType: 'module',
});
// 通过配置presets把代码的esm规范转成cjs规范
const { code } = transformFromAst(ast, null, {
presets: ['env'],
});
// 获取模块的依赖集合
const deps = [];
traverse.default(ast, {
ImportDeclaration({ node }) {
deps.push(node.source.value);
}
});
return {
id: id++,
code,
deps,
}
}
createAsset('./example/main.js');
遍历模块的依赖集合,找到依赖的依赖
经过上面的代码,我们只获取到了入口文件的依赖模块集合,但是我们要获取这些依赖模块的依赖模块,那就要对依赖集合deps遍历,在遍历模块依赖集合时,把模块的数据添加到一个依赖关系(一种数据结构)中。
js
import fs from 'fs';
import parser from '@babel/parser';
import traverse from '@babel/traverse';
import { transformFromAst } from 'babel-core';
import path from 'path';
function createGraph() {
const mainAsset = createAsset('./example/main.js');
// 定义一个依赖关系结构`queue`
const queue = [mainAsset];
for(const asset of queue) {
// 遍历依赖集合
asset.deps.forEach(relativePath => {
const child = createAsset(path.resolve('./example', relativePath));
// 把模块数据添加到依赖关系中
queue.push(child);
});
}
return queue;
}
const group = createGraph();
这是模块依赖关系的数据:
js
[
{
id: 0,
code: '"use strict";\n' +
'\n' +
'var _foo = require("./foo.js");\n' +
'\n' +
'var _foo2 = _interopRequireDefault(_foo);\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
'\n' +
'(0, _foo2.default)();\n' +
"console.log('This is main.js');",
deps: [ './foo.js' ]
},
{
id: 1,
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports.default = foo;\n' +
'\n' +
'function foo() {\n' +
" console.log('This is foo.js');\n" +
'}',
deps: []
}
]
到了这里,就剩下打包的功能了。在打包之前,我们先思考下,我们怎么才可以自动生成一个立即执行函数呢?并生成到输出文件夹里呢?
还有怎么把模块id和模块函数一一对应起来作为立即执行函数的入参呢?
还有怎么把模块code嵌入到模块函数里呢?
实现打包功能
我们可以创建一个bundle的模板文件bundle.ejs,把一开始我们写好的bundle.js文件内容放到这里:
js
// bundle.ejs
(function(modules) {
function webpackRequire(filePath) {
const module = {
exports: {},
}
const fn = modules[filePath];
fn(webpackRequire, module);
return module.exports;
}
// 立即执行入口文件
webpackRequire(0);
})({
0: function(require, module) {
const { foo } = require(1);
foo();
console.log('This is main.js');
},
1: function(require, module) {
function foo() {
console.log('This is foo.js');
}
module.exports = {
foo,
}
},
})
而入参并不可能是我们自己手写,所以需要用到一个模板引擎ejs,把依赖关系数据丢给ejs,让它遍历依赖关系数据,自动生成入参。那么我们的模板文件bundle.ejs可以进一步改进,假设传过来的依赖关系结构数据用data表示:
js
(function(modules) {
function webpackRequire(filePath) {
const module = {
exports: {},
}
const fn = modules[filePath];
fn(webpackRequire, module);
return module.exports;
}
// 立即执行入口文件
webpackRequire(0);
})({
<% data.forEach(info => { %>
"<%- info["id"] %>": function(require, module, exports) {
<%- info['code'] %>
},
<% });%>
})
安装ejs
shell
pnpm i ejs@3.1.6
创建函数build用来实现打包的功能,这个函数需要实现:
- 获取模板文件
- 根据依赖关系数据结构
group获取需要传给ejs的数据data - 使用
ejs渲染出最终的打包代码code - 把最终的代码
code使用fs模块的writeFileSync写入输出文件夹
js
import fs from 'fs';
function build(group) {
const template = fs.readFileSync('bundle.ejs', { encoding: 'utf-8' });
const data = group.map(asset => {
const { id, code } = asset;
return {
id,
code,
}
});
const code = ejs.render(template, { data });
// 打包输出文件夹
const outputPath = './dist/bundle.js';
fs.writeFileSync(outputPath, code);
}
build(group);
至此,我们查看下生成的dist/bundle.js
js
(function (modules) {
function webpackRequire(filePath) {
const module = {
exports: {},
};
const fn = modules[filePath];
fn(webpackRequire, module);
return module.exports;
}
// 立即执行入口文件
webpackRequire(0);
})({
0: function (require, module, exports) {
"use strict";
var _foo = require("./foo.js");
var _foo2 = _interopRequireDefault(_foo);
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
(0, _foo2.default)();
console.log("This is main.js");
},
1: function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = foo;
function foo() {
console.log("This is foo.js");
}
},
});
可以发现一个问题,在模块函数内部,require('./foo.js')这个地方,我们并没有实现传给require的参数为数字这么一个功能。所以代码还需改进下。
找到遍历deps的那个代码,给asset新增一个属性mapping,用于存放模块的依赖与该依赖作为模块时的id对应的关系:
js
function createAsset(filePath) {
...
return {
...
mapping: {}
}
}
function createGraph() {
const mainAsset = createAsset('./example/main.js');
// 定义一个依赖关系图结构`queue`
const queue = [mainAsset];
for(const asset of queue) {
// 遍历依赖集合
asset.deps.forEach(relativePath => {
const child = createAsset(path.resolve('./example', relativePath));
// 模块的依赖与依赖作为模块时的`id`对应的关系
asset.mapping[relativePath] = child.id;
// 把模块数据添加到依赖关系图中
queue.push(child);
});
}
return queue;
}
function build(group) {
...
const data = group.map(asset => {
const { id, code, mapping } = asset;
return {
...
// mapping需要传给ejs模板引擎给到bundle.ejs内部
mapping,
}
});
...
}
bundle.ejs拿到mapping后,修改下模板数据,id对应的值不再是模块函数,而是一个数组,数组第一项才是模块函数,第二项是mapping
ejs
<% data.forEach(info => { %>
"<%- info["id"] %>": [function(require, module, exports) {
<%- info['code'] %>
},<%- JSON.stringify(info["mapping"]) %>],
<% });%>
而webpackRequire内部,我们可以拿到mapping,实现一个函数localRequire,内部根据mapping进一步拿到依赖的id,返回webpackRequire的返回值。而我们传给依赖模块的require不再是webpackRequire,而是改成localRequire。
ejs
// bundle.ejs
function webpackRequire(id) {
const module = {
exports: {},
}
const [fn, mapping] = modules[id];
const localRequire = function(filePath) {
const id = mapping[filePath];
return webpackRequire(id);
}
fn(localRequire, module);
return module.exports;
}
// 立即执行入口文件
webpackRequire(0);
现在打包后的dist/bundle.js变成了这样:
js
(function (modules) {
function webpackRequire(id) {
const module = {
exports: {},
};
const [fn, mapping] = modules[id];
const localRequire = function (filePath) {
const id = mapping[filePath];
return webpackRequire(id);
};
fn(localRequire, module);
return module.exports;
}
// 立即执行入口文件
webpackRequire(0);
})({
0: [
function (require, module, exports) {
"use strict";
var _foo = require("./foo.js");
var _foo2 = _interopRequireDefault(_foo);
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
(0, _foo2.default)();
console.log("This is main.js");
},
{ "./foo.js": 1 },
],
1: [
function (require, module, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true,
});
exports.default = foo;
function foo() {
console.log("This is foo.js");
}
},
{},
],
});
至此,还有个小问题,就是模块函数接收的有3个参数,分别是require、module、exports,而我们的模板文件bundle.ejs少了第三个参数exports。
这个好办,我们直接把module.exports作为fn的第三个参数即可。
ejs
(function(modules) {
function webpackRequire(id) {
const module = {
exports: {},
}
const [fn, mapping] = modules[id];
const localRequire = function(filePath) {
const id = mapping[filePath];
return webpackRequire(id);
}
fn(localRequire, module, module.exports);
return module.exports;
}
// 立即执行入口文件
webpackRequire(0);
})({
<% data.forEach(info => { %>
"<%- info["id"] %>": [function(require, module, exports) {
<%- info['code'] %>
},<%- JSON.stringify(info["mapping"]) %>],
<% });%>
})
我们在控制台执行下最终的dist/bundle.js文件:
shell
node dist/bundle.js
结果输出:
csharp
This is foo.js
This is main.js
那么,min-webpack的核心代码就实现了。
感谢你的阅读。
参考
1\] 手摸手带你实现打包器 仅需 80 行代码理解 webpack 的核心-b站