手写 min-webpack 弄懂 webpack 核心源码思想
在写min-webpack之前,我们先试着理解webpack打包产物bundle的代码思路。通过了解了bundle代码的逻辑后,再去理解webpack的核心源码到底是通过什么处理自动生成的bundle,最终实现一个min-webpack。
初始化一个项目
npm init -y
package.json
添加 "type": "module"
以支持ESM
模块
// package.json
{
"type": "module"
}
这样,在项目中就可以以ESM
的方式引入模块
import fs from 'fs';
新建一个example
文件夹,模拟webpack打包,里面新建文件:
main.js
可以理解为打包入口文件foo.js
被main.js
引入,模拟文件间的依赖关系bundle.js
模拟webpack打包后的bundle,这里会先分析webpack的打包思路,后面逐步完善代码index.html
用于引入打包后的bundle
// main.js
import foo from './foo.js';
foo();
console.log('This is main.js');
// foo.js
export default function foo() {
console.log('This is foo.js');
}
// bundle.js
// 这个是核心,后面写
<!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
- 为了防止各个模块间命名冲突,需要有个函数包裹着各个模块,这个暂且称为模块函数。
- 从入口文件模块开始,找到入口模块所在的模块函数并执行
// 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
方法。
// 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
来实现。
// bundle.js
function webpackRequire(filePath) {
const module = {
exports: {},
};
// 返回该模块导出的成员
return module.exports;
}
// 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
与模块之间的关系:
// bundle.js
const FnMap = {
'./main.js': mainjs,
'./foo.js': foojs,
}
这样就简单地创建了一个模块路径和模块之间的映射关系。
那要执行模块代码就简单了:
// 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
这些代表模块的函数,可以有统一的入参:
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
的代码:
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
函数,这个也好办,也把它作为模块函数的入参传过去供其调用
(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开始,依次递增,这样就可以做到模块被唯一标识,就不会造成冲突了。修改后:
(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
。
let id = 0;
function createAsset() {
return {
id: id++,
}
}
获取到模块本身的内容code
获取文件内容,其实需要获取代码的抽象语法树,需要用到@babel-paser
依赖,还有代码需要转成CJS
规范,所以需要用到babel-core
、babel-preset-env
。
pnpm i @babel/parser@7.16.7
pnpm i babel-core@6.26.3
pnpm i babel-preset-env@1.7.0
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
// 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
"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
pnpm i @babel/traverse@7.16.7
定义一个常量deps
数组,在遍历ast
时不断把依赖到的模块路径值添加到数组里。
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
遍历,在遍历模块依赖集合时,把模块的数据添加到一个依赖关系(一种数据结构)中。
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();
这是模块依赖关系的数据:
[
{
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
文件内容放到这里:
// 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
表示:
(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
pnpm i ejs@3.1.6
创建函数build
用来实现打包的功能,这个函数需要实现:
- 获取模板文件
- 根据依赖关系数据结构
group
获取需要传给ejs
的数据data
- 使用
ejs
渲染出最终的打包代码code
- 把最终的代码
code
使用fs
模块的writeFileSync
写入输出文件夹
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
(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
对应的关系:
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
<% data.forEach(info => { %>
"<%- info["id"] %>": [function(require, module, exports) {
<%- info['code'] %>
},<%- JSON.stringify(info["mapping"]) %>],
<% });%>
而webpackRequire
内部,我们可以拿到mapping
,实现一个函数localRequire
,内部根据mapping
进一步拿到依赖的id
,返回webpackRequire
的返回值。而我们传给依赖模块的require
不再是webpackRequire
,而是改成localRequire
。
// 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
变成了这样:
(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
的第三个参数即可。
(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
文件:
node dist/bundle.js
结果输出:
This is foo.js
This is main.js
那么,min-webpack的核心代码就实现了。
感谢你的阅读。
参考
[1] 手摸手带你实现打包器 仅需 80 行代码理解 webpack 的核心-b站
转载自:https://juejin.cn/post/7282691800849301563