背景
以下是手写系列内容预告,初步计划一周一个主题。
- 手写Promise、Promise.all、Promise.race
- 手写发布订阅模式
- 手写简易Redux
- 手写简易模块加载器
- 手写简易模块打包器
- 手写简易React
- 手写简易React Hooks
- ......(根据小伙伴的提议待定)
需求
假设我们在同一个文件夹下有两个源文件
文件 main.js
const { sum } = require('./math.js')
console.log(sum(2, 3))
文件 math.js
function sum(...args) {
return args.reduce((v1, v2) => v1 + v2)
}
exports.sum = sum
之后我们使用Node.js写好一个名叫 webpacker 的打包工具,全局安装。当使用如下命令时,能把 main.js 做为入口文件,自动分析依赖,最终生成可执行的 bundle.js :
webpacker main.js --output=bundle.js
这个webpacker能做什么,打包之后的文件内容是什么?
如何做
先初略猜测一下bundle.js 里是什么。假设打包过程只是把main.js 里依赖的文件的代码都合并到一起(如下代码所示),以下代码显然无法直接运行,因为代码里出现的 require 和 exports 都不存在,且各个文件中的变量都未隔离开。
const { sum } = require('./sum.js')
console.log(sum(2, 3))
function sum(...args) {
return args.reduce((v1, v2) => v1 + v2)
}
exports.sum = sum
换个思路,每个源文件都内容都被一个函数包裹,如下代码所示,至少代码能勉强跑通。
function fn1(require, exports) {
const { sum } = require('./sum.js')
console.log(sum(2, 3))
}
function fn2(require, exports) {
function sum(...args) {
return args.reduce((v1, v2) => v1 + v2)
}
exports.sum = sum
}
function require() {
//todo...
}
不过仍未解决我们都问题:1. 整个代码的入口是什么?如何启动?2. require函数如何定义。3. 如何知道 require('./sum.js')到底加载哪个模块?
更进一步
假设打包后的 bundle.js 如下代码所示,我们把用户的源文件包裹一个函数的壳,放到modules内。其中入口文件对应的模块的id为0。通过exec(0)获取到入口模块并且执行。
const modules = {
0: function(require, exports) {
const { sum } = require('./sum.js')
console.log(sum(2, 3))
},
1: function(require, exports) {
function sum(...args) {
return args.reduce((v1, v2) => v1 + v2)
}
exports.sum = sum
}
}
//执行模块,返回结果
function exec(id) {
let module = modules[id]
let exports = {};
module(require, exports)
function require(path) {
//todo...
//根据模块路径,返回模块执行的结果
}
return exports
}
exec(0)
但依旧有个问题尚未解决。当模块内部执行到 require时,到底如何根据requre的路径获取到其他模块?
最终思路
假设打包后的 bundle.js 如下代码所示,我们把用户的源文件包装成一个对象放到modules内,每个模块对象里面包含当前模块代码和依赖映射。通过exec(0)获取到入口模块并且执行。
const modules = {
0: {
module(require, exports) {
const { sum } = require('./sum.js')
console.log(sum(2, 3))
},
mapping: {'./sum.js': 1 }
},
1: {
module(require, exports) {
function sum(...args) {
return args.reduce((v1, v2) => v1 + v2)
}
exports.sum = sum
},
mapping: {}
}
}
function exec(id) {
const { module, mapping } = modules[id]
let exports = {}
module(path => exec(mapping[path]), exports)
return exports
}
exec(0)
代码通过exec(0)获取到入口模块的代码(module) 和依赖映射(mapping);执行module;执行时如果里面遇到了requrie('./sum.js'),require就是箭头函数,执行的结果是exec(mapping['./sum.js']) 也就是 exec(1),最终拿到id为1的模块对象里面的exports。
这个打包后的bundle.js能按照我们的预期正常执行。
总结
我们自己的打包工具 webpacker根据用户配置的入口文件(main.js),读取js源码字符串并分析源码里的依赖(require),再根据依赖的路径层层递进,最终把用户的源码拼接成如上代码所示的bundle,写入 bundle.js。这里就不在详细实现。下方是完整版源码和讲解视频。