前言
在前端开发中,我们经常会使用到模块化开发,但是浏览器对模块化支持很不好,所以我们一般需要Webpack这类打包工具将我们的模块打包成浏览器可以识别的代码。我们通过实现一个简易的模块打包器,来学习模块打包器的基本原理。
依赖分析
打包器一般需要一个入口文件,从入口文件开始通过递归的形式分析模块的相互依赖关系,这个依赖关系被称之为依赖图。我们首先创建一个辅助函数,他能收集某个文件的依赖模块,并将该文件转换为ES5代码,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
|
const fs = require('fs'); const path = require('path'); const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const { transformFromAst } = require('@babel/core');
let ID = 0;
function createAsset(filename) { const content = fs.readFileSync(filename, 'utf-8');
const ast = parser.parse(content, { sourceType: 'module' });
const dependencies = []; traverse(ast, { ImportDeclaration: (path) => { dependencies.push(path.node.source.value); } });
const code = transformFromAst(ast, null, { presets: ['@babel/preset-env'] }).code;
const id = ID++; return { id, filename, dependencies, code };
}
|
在demo文件夹创建三个文件来测试打包效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import { add } from './math.js'; import { print } from './utils.js';
function main() { const sum = add(123, 2344); print(sum); }
main();
export const add = (a, b) => a + b;
export function print(value) { console.log(value); }
|
然后在minipack.js中添加如下代码:
1 2
| const asset = createAsset('./demo/main.js'); console.log(asset);
|
执行node ./src/minipack.js可以看到测试效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| { id: 0, filename: './demo/main.js', dependencies: [ './math.js', './utils.js' ], code: '"use strict";\n' + '\n' + 'var _math = require("./math.js");\n' + 'var _utils = require("./utils.js");\n' + 'function main() {\n' + ' var sum = (0, _math.add)(123, 2344);\n' + ' (0, _utils.print)(sum);\n' + '}\n' + 'main();' }
|
依赖图
辅助函数完成之后,紧接着需要创建一个函数,从入口文件开始,递归的分析所有的依赖,并调用createAsset函数为每个文件创建模块对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| function createGraph(entry) { const mainAsset = createAsset(entry);
const queue = [mainAsset]; for (const asset of queue) { const dirname = path.dirname(asset.filename); asset.mapping = {};
asset.dependencies.forEach(relativePath => { const absolutePath = path.join(dirname, relativePath); const child = createAsset(absolutePath); asset.mapping[relativePath] = child.id; queue.push(child); }); } return queue; }
|
此时添加如下测试代码:
1 2
| const graph = createGraph('./demo/main.js'); console.log(graph)
|
执行node ./src/minipack.js
,可以看到输出的是一个数组,数组中包含了所有的模块对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| [ { id: 0, filename: '/xxx/minipack/demo/main.js', dependencies: [ './math.js', './utils.js' ], code: '"use strict";\n' + '\n' + 'var _math = require("./math.js");\n' + 'var _utils = require("./utils.js");\n' + 'function main() {\n' + ' (0, _utils.print)((0, _math.add)(123, 2344));\n' + '}\n' + 'main();', mapping: { './math.js': 1, './utils.js': 2 } }, { id: 1, filename: '/xxx/minipack/demo/math.js', dependencies: [], code: '"use strict";\n' + '\n' + 'Object.defineProperty(exports, "__esModule", {\n' + ' value: true\n' + '});\n' + 'exports.add = void 0;\n' + 'var add = exports.add = function add(a, b) {\n' + ' return a + b;\n' + '};', mapping: {} }, { id: 2, filename: '/xxx/minipack/demo/utils.js', dependencies: [], code: '"use strict";\n' + '\n' + 'Object.defineProperty(exports, "__esModule", {\n' + ' value: true\n' + '});\n' + 'exports.print = print;\n' + 'function print(value) {\n' + ' console.log(value);\n' + '}', mapping: {} } ]
|
模块加载
接下来需要创建一个函数,将依赖图转换为浏览器可执行的代码,函数的输入是上一步得到的依赖图,输出的是代码打包后的代码字符串。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| function bundle(graph) { let modules = ''; graph.forEach(mod => { modules += `${mod.id}: [ function (require, module, exports) { ${mod.code} }, ${JSON.stringify(mod.mapping)} ],`; });
const result = ` (function(modules) { function require(id) { const [fn, mapping] = modules[id]; function localRequire(name) { return require(mapping[name]); } const module = { exports: {} }; fn(localRequire, module, module.exports); return module.exports; } require(0); })({${modules}}) `;
return result; }
|
添加测试代码:
1 2 3
| const graph = createGraph('./demo/main.js'); const bundleCode = bundle(graph); fs.writeFileSync('bundle.js', bundleCode);
|
再次执行node ./src/minipack.js
,可以看到在项目根目录下生成了一个bundle.js文件,就是我们打包后的文件。
为了理解为什么代码后的代码能在浏览器中执行,我们将关键require函数提取出来看下它的执行流程:
1 2 3 4 5 6 7 8 9 10 11
| function require(id) { const [fn, mapping] = modules[id]; function localRequire(name) { return require(mapping[name]); } const module = { exports: {} }; fn(localRequire, module, module.exports); return module.exports; }
|
另外math.js模块的fn的形式如下:
1 2 3 4 5 6 7 8 9 10 11
| function (require, module, exports) { "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); exports.add = void 0; var add = exports.add = function add(a, b) { return a + b; }; }
|
该函数的第一个参数require函数的实现是一个递归调用,通过mapping对象来找到依赖模块的id,然后调用require函数加载依赖模块
1 2 3 4
| function localRequire(name) { return require(mapping[name]); }
|
函数的第二个参数module是一个包含exports对象的对象,模块内部的变量都挂载在module.exports上
1
| const module = { exports: {} };
|
所以为了理解这个函数的执行流程,以上面的三个文件为例,其中main.js和math.js经过编译后输出的代码加上匿名函数的包裹之后代码分别如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| function (require, module, exports) { "use strict"; var _math = require("./math.js"); var _utils = require("./utils.js"); function main() { var sum = (0, _math.add)(123, 2344); (0, _utils.print)(sum); } main(); }
function (require, module, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.add = void 0; var add = exports.add = function add(a, b) { return a + b; }; }
|
require函数返回一个module.exports对象,math.js导出的add函数被挂载到module.exports对象,同时exports对象还有一个__esModule属性,这个属性是为了兼容ES6模块的导出规范,utils.js也是类似的。
其他
以上代码在输出时,注意到源码的函数调用经历了如下转换:
1 2 3 4 5 6 7
| const sum = add(123, 2344); print(sum);
var sum = (0, _math.add)(123, 2344); (0, _utils.print)(sum);
|
所以(0, _math.add)(123, 2344)
这种语法是什么意思?
首先(0, _math.add)
是一个逗号表达式,它的形式如下(value1, value2, ....valueX)
,这个表达式的返回值是最后一个值,所以(0, _math.add)
的返回值其实就是_math.add
,而后面的括号相当于add函数的参数。这种写法本质就是调用了add函数,前边的0只是一个占位符,无实际意义。那babel将函数调用转换成这种语法呢?
我们打包后的math模块被赋值给了一个_math变量,然后通过_math.add来调用add函数,如果我们直接使用_math.add(123, 2344)的形式调用,add函数内的this变量会指向_math对象,这样的this指向是错误的,而通过逗号操作符和()的形式调用,add函数内的this会指向global对象,或者在严格模式下是undefined,这样就避免了this指向错误的问题。
如下代码可以验证这个问题:
1 2 3 4 5 6 7 8 9 10 11 12
| var foo = { fullName: "Peter", sayName: function() { console.log("My name is", this.fullName); } };
window.fullName = "Shiny";
foo.sayName();
(foo.sayName)();
(0, foo.sayName)();
|
总结
到这里我们已经实现了一个简易的模块打包器,我们通过递归的方式分析模块的依赖关系,然后将依赖图转换为浏览器可执行的代码。这个打包器还有很多不足之处,比如没有处理循环依赖,没有处理异步加载等等,但是通过这个简单的实现,我们可以了解模块打包器的基本原理。
参考资料