如何设计一个简单的模块系统

前言

前端社区早在几年前就进到了模块化开发方式中了, 实际上es6之前, js是没有模块概念的. 既没有变量的导出, 也没有变量的引入.

随着前端需要承担的功能越来越繁重, 需要复用的逻辑也越来越多, 全局变量越来越容易碰撞, 前端急需一个模块化方案. 那么既然javascript语言本身并没有模块化功能, 但是大佬们必然是有办法的. 于是各种模块化方案分分出现CMD AMD CommonJS, 再es6规范出来前, 这三种方案几乎统治了所有javascript开发. 他们都在自己不同的领域他提出了实现方案, 并解决了诸多的问题.

没想到的是, 前端是在变化太快, 2015年es6发布, 从此javascript有了自己的模块化方案. 并且很快, 基于模块化的打包工具webpack发布, 把前端工程化拉上一个全新高度—-万物皆模块.
善于接受变化的前端社区统统拥抱webpack, 很快webpack天下大统, 之前的三大解决方案,除了commonJS因为NodeJS亲儿子的高贵血统, CMD AMD的两个方案require.js sea.js几乎已经告别江湖, 成为历史, 变成了老前端回忆自己峥嵘岁月的谈资了. 是不是很像jquery的经历?

既然webpack这么厉害, 那么他的打包方案是什么? 是esModule么?

webpack时代

实际上由于webpack是基于NodeJs的运行时, 所以webpack显然一开始是使用的commonJS的方案, 相比CMD AMD,commonJS的群体比较大, 因为是运行再NodeJS上, webpack的配置文件,plugin,loader,都必须使用commonJS的方式来引入, 所以其他代码如果都是用一套方案,这样也比较统一, 也符合直觉.

虽然webpack再js代码的模块加载是按照commonJS规范来的, 但是实际上由于打包后的代码是运行再前端的, 所以需要实现一个前端的模块加载运行时, 那么webpack到底是怎么让基于nodeJS的模块系统再打包后能成功运行再前端呢?

如何实现一个模块

理想的一个js module应该是有自己的环境, 模块之间互相无法影响, 相互见的全局变量都是不可见的. 并且模块需要可以导出数据, 还要能够加载其他模块导出的数据.那么这样的功能我们该如何实现呢?

示例

我们先定义两个三个文件, 互相加载一下:

/index.js

1
2
var moduleA = require('./dep.js')
console.log('hello world')

/dep.js

1
2
3
var moduleA = require('./common.js')
var funA = function() {};
module.exports = funA;

/common.js

1
2
var util = function() {};
module.exports = util;

他们的依赖关系如下: index.js -> dep.js -> common.js;

好了, 既然上面的示例已经定好了, 使用的是commonJS的规范, 既然我们要实现一个简单版的模块系统, 那么先来分析分析这里会用到的语法把

基本语法

1
2
3
4
5
6
7
8
9
10
11
// import 代码
var moduleA = require('./a.js')
var funA = function() {};
// export 导出代发方式1
module.exports = {
funA
};
// or 导出代码方式2
exports.funcA = funA;

// 上面的两种导出方式是等价的

从上面的代码,我们可以看到, 这里有三个全局变量require module exports, 这三个全局变量我们待会再说

开始

ok, 既然是模块, 首先需要考虑的就是隔离全局变量, 换而言之就是要让每一个模块的执行环境都在不同的作用于下,
那么如何形成新的作用于我就不多说了,只要使用一个函数把模块中的代码进行包裹就可以了,就像这样:

1
2
3
4
5
6
7
8
function(require, module, exports) {
var moduleA = require('./a.js')
var funA = function() {};
// export
module.exports = funA;
// or
exports.funA = funA
}

这样模块內的全局变量就都变成了这个函数的局部变量, 现在只需把全局变量通过模块的函数参数传入, 那么接下来我们需要解决两个问题:

  • 如何把所有的模块都改写成包裹函数的方式, 并且把这些函数找个地方统一管理起来
  • 如何实现requiremodule exports 这三个变量

解析依赖树

第一个问题, 可以参考我之前的文章, 使用babel来解析入口文件index.js

因为在解析过程中, 我们需要获取所有的模块, 并且生成这个模块的唯一id, 方便起见, 我们直接已模块的项目相对路径为key来记录

整个过程大概分下面几部:

  1. 从入口未见解析, 只要遇到require方法的调用, 就解析他的参数来读取对应的模块文件, 并修改require的参数为require文件的项目相对路径
  2. 记录下文件的路径, 并js文件字符串,对其进行包裹
  3. 递归的重复上面两步解析js直到获取所有的依赖文件.
  4. 遍历后生成一个路径到模块的mapping即可, 生成后的模块图大概如此
    1
    2
    3
    4
    5
    6
    7
    const loadedModulesMap = {
    'src/dep.js': function(require, module, exports) {
    var moduleA = require('src/common.js')
    /* code */
    },
    'src/common.js': function(require, module, exports) { /* code */ }
    };

实现require

解决第二个问题, 码字太麻烦, 直接上代码把

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
// 这里记录的是第一步解析出来的所有依赖文件
const loadedModulesMap = {
'src/dep.js': function(require, module, exports) {
var moduleA = require('src/common.js')
var funA = function() {};
module.exports = funA;
},
'src/common.js': function(require, module, exports) {
var util = function() {};
module.exports = util;
},
};
// 缓存, 防止重复加载相同的模块
const cacheModulesMap = {};

// 定义require方法
require(path) {
// 先走缓存
if (cacheModulesMap[path]) {
return cacheModulesMap[path].exports;
}
// 注册缓存, 拿到缓存上的引用指针
const module = cacheModulesMap[path] = { exports: {} };
// 去模块map里拿对应的模块
const modulesFn = loadedModulesMap.path;
// 执行模块, 这里要注意, 模块的导出值灰写入的module上, 由于module是
// cacheModulesMap的引用, 所以导出值都会同时被保存
modulesFn(require, module, module.exports );
// module已经拿到导出值了, 直接返回对应导出值
return module.export;
}

// 运行入口文件代码, 一切慢慢展开
var moduleA = require('src/dep.js')
console.log('hello world')

这样, 一个解析后的代码, 就可以被正常的运行了, 这里我们可以发现一个commonJS模块的一个特点, 就是模块的加载是在运行时完成的, 也就是说, 只有在代码运行的时候, 模块才会被加载, 并且加载顺序和代码的调用顺序是强关联的, 这个和esModule是不同的, 感兴趣的同学可以去了解一下两者之间的区别

异步require

异步模块数据结构

上面是我们普通的require模块的简单实现. 但是在脱离的node环境后, 前端资源实际上很大程度上是依赖于异步获取的,那么一个需要异步获取的模块应该设计成什么样呢? 看看下面的数据结构;

1
2
3
4
5
6
{
`src/async.js`: function(require, module, exports) {
var asyncFn = function() {};
module.exports = funA;
}
}

这个结构保留了我们上面loadedModulesMap的数据结构, 方便merge到里面去, 现在需要设计一种加载机制, 把这部分代码加载到loadedModulesMap

主代码如何加载异步代码

webpack实现了一种异步加载的方法require.ensure, 我们就沿用把, 只不过既然是简单版, 就一个参数吧! 我们让dep.js变成异步加载common.js

/dep.js

1
2
3
4
5
6
7
8
9
10
11
// 之前
var moduleA = require('./common.js')
var funA = function() {};
module.exports = funA;
// 之后

var moduleA = require.ensure('./common.js').then((util) => {
util.xxx();
})
var funA = function() {};
module.exports = funA;

异步模块设计

有一点可以肯定, 我们肯定不能使用Ajax的方式进行, 因为我们需要执行它, 所以我们选择使用script标签, 那么只有一条路了! JSONP!

异步模块

1
2
3
4
5
6
7
8
9
// 两边定义一个共有变量 JSON_LIST
// 异步模块在加载后直接执行, 把模块挂载的全局队列里, 等待主模块读取
window.JSONP_LIST.push(
{
`src/async.js`: function(require, module, exports) {
var asyncFn = function() {};
module.exports = funA;
}
})

主模块

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

// 定义异步加载模块的方法
require.ensure = function(path) {
// 说明异步模块没加载
return new Promise((resolve) => {
// 判断是否加载过了
if (!loadedModulesMap[path]) {
resolve(require(path));
}

// 定义script标签
var script = document.createElement('script');
// 全局公共路径 + 相对路径拼接请求, 简单处理啦!
var url = PUBLIC_PATH + path;
script.url = url;
// 定义加载完成后的成功回调
script.onload = function() {
// 模块请求成功后, 加载队列里的所有模块
window.JSONP_LIST.forEach((moduleObj) => {
Object.keys(moduleObj).forEach((_path) => {
if (!loadedModulesMap[_path]) {
loadedModulesMap[_path] = moduleObj[_path];
}
})
})
// 处理了JSON_LIST里面已经加载郭的模块就要清空
window.JSON_LIST = [];
// 这时 已经成功加载了异步模块, 这时候返回模块值
if (!loadedModulesMap[path]) {
resolve(require(path));
}
}
document.head.appendChild(script);
})
}

一些其他细节

在增加了require.ensure之后, 你需要在解析文件的时候考虑异步加载的情况:

  • 解析require.ensure的调用
  • 异步应依赖的子树单独生成异步模块文件

最后生成的代码应该是:

主文件main.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
47
const loadedModulesMap = {
'src/dep.js': function(require, module, exports) {
var moduleA = require.ensure('src/common.js')
var funA = function() {};
module.exports = funA;
},
};

const cacheModulesMap = {};

require(path) {
if (cacheModulesMap[path]) {
return cacheModulesMap[path].exports;
}
const module = cacheModulesMap[path] = { exports: {} };
const modulesFn = loadedModulesMap.path;
modulesFn(require, module, module.exports );
return module.export;
}

require.ensure = function(path) {
return new Promise((resolve) => {
if (!loadedModulesMap[path]) {
resolve(require(path));
}
var script = document.createElement('script');
var url = PUBLIC_PATH + path;
script.url = url;
script.onload = function() {
window.JSONP_LIST.forEach((moduleObj) => {
Object.keys(moduleObj).forEach((_path) => {
if (!loadedModulesMap[_path]) {
loadedModulesMap[_path] = moduleObj[_path];
}
})
})
window.JSON_LIST = [];
if (!loadedModulesMap[path]) {
resolve(require(path));
}
}
document.head.appendChild(script);
})
}

var moduleA = require('src/dep.js')
console.log('hello world')

异步文件src/common.js;

1
2
3
4
5
6
7
window.JSONP_LIST.push(
{
`src/common.js`: function(require, module, exports) {
var util = function() {};
module.exports = util;
}
})

结语

以上只是一个非常非常简单的模块加载系统, 玩具中的玩具, 但是这不妨碍我们理解js模块化的本质, 只要看明白本质, 实际上webpack那些
眼花缭乱的操作也就不再那么神奇了

如果之后有机会, 我也许会在这个篇文章里补全简易版的webpack module federation, 敬请期待……

也许是后会无期?(大笑😄,逃……)