如何实现一个异步模块加载器--以requireJS为例
youngwind opened this issue · 6 comments
为什么
最近参考requirejs的API,自己动手实现了一个简单的异步模块加载器fake-requirejs。
为什么要做这样一个东西呢?
原因是:我一直觉得自己对模块化这方面的理解不够深入,即便用了很长时间的webpack,看了很多模块化相关的资料,比如模块化的发展历史,比如amd,commonjs和cmd规范之争等等。然而,我依然觉得自己的理解流于表面,所以决定自己动手实现一个。
目标的选择:本来一开始的目标是webpack的,但是后来考虑到webpack是建立在模块化基础上的一个构建工具,且webpack的实现也相当的复杂,而我希望能够刻意区分开模块化和构建这两个概念。因为这有助于我集中有限的精力研究模块化这一个概念,所以后来决定实现requirejs,这是一个相对来说比较简单的异步模块加载器。虽然现在使用它的人已经越来越少了,但是正因为其简单和纯粹,倒是非常适合现在的我。
注:请确保掌握了requirejs的基本用法再往下阅读。
Module原型的设计
刚开始敲代码的时候,我就在想如何实现require
函数和define
函数,但是后来我发现我错了,因为这陷入了面向过程编程
的误区,正确的方式应该是面向对象编程
。所以,我重新进行了思考。
问题:这里都有哪些类型的对象呢?
答案:至少有模块(Module)这一类对象
那模块类对象有哪些数据呢?
Module.id // 模块id
Module.name // 模块名字
Module.src // 模块的真实的uri路径
Module.dep // 模块的依赖
Module.cb // 模块的成功回调函数
Module.errorFn // 模块的失败回调函数
Module.STATUS // 模块的状态(等待中、正在网络请求、准备执行、执行成功、出现错误……)
又有哪些对应的操作这些数据的方法呢?
Module.prototype.init // 初始化,用来赋予各种基本值
Module.prototype.fetch // 通过网络请求获取模块
Module.prototype.analyzeDep // 分析、处理模块的依赖
Module.prototype.execute // 运算该模块
依赖分析与处理
顺着上面的思路一步步写,我碰到了一个难点:如何分析和处理模块的依赖?
举个例子:
// 入口main.js
require(['a', 'b'], function (a, b) {
a.hi();
b.goodbye();
}, function () {
console.error('Something wrong with the dependent modules.');
});
我们的目标是:当模块a
和b
都准备好之后,再执行成功回调函数;一旦a
或b
有任意一个失败,都执行失败回调函数。
这个跟使用Promise.all
和Promise.race
很像,但这一次我们是要实现它们。怎么办呢?
我想了一个方法:记数法。分两步走。
- 为Module原型新增
Module.depCount
属性,初始值为该模块依赖模块数组的长度。 - 假如
depCount===0
,说明该模块依赖的模块都已经运算好了,通过setter触发执行该模块。 - 某模块执行成功之后,
Module.STATUS===5
,通过setter
触发下一步。 - 通过对象
mapDepToModule
,查找到依赖与该模块的所有模块,那么让那些模块都执行depCount--
。
注:对象mapDepToModule
的作用是映射被依赖模块到依赖模块之间的关系。
结构如下图所示。举个例子:当模块a
准备好之后,我们就遍历mapDepToModule['a']
对应的数组,里面的每一项都执行depCount--
。
下面是一些关键的代码:
Module.prototype.analyzeDep = function () {
// .......
let depCount = this.dep ? this.dep.length : 0;
Object.defineProperty(this, 'depCount', {
get() {
return depCount;
},
set(newDepCount) {
depCount = newDepCount;
if (newDepCount === 0) {
console.log(`模块${this.name}的依赖已经全部准备好`);
this.execute(); // 如果depCount===0,执行该模块
}
}
});
this.depCount = depCount;
// ....
};
Object.defineProperty(this, 'status', {
get () {
return status;
},
set (newStatus) {
status = newStatus;
if (status === 5) {
// 假如某个模块已经准备好了(STATUS===5),
// 那么找出依赖于这个模块的所有模块,让他们都执行depCount--
let depedModules = mapDepToModule[this.name];
if (!depedModules) return;
depedModules.forEach((module) => {
setTimeout(() => {
module.depCount--;
});
});
}
}
})
循环依赖
虽然我们都说循环依赖是一种不好的现象,应该在设计之初尽量避免。但是,随着项目越滚越大,谁又能保证一定不会出现?所以,**作为一个合格的模块加载器,必须解决循环依赖的问题。**那么,让我们先来看看别人是怎么处理的吧。
Commonjs和ES6的循环依赖
seajs的循环依赖
requirejs的循环依赖
这里我们不讨论各种处理方式孰优孰劣,我们只关注:
如何实现requireJS API文档中那样的功能?
仔细观察下面的例子:a
与b
出现循环依赖
// main.js
require(['a','b'], function (a, b) {
a.hi();
b.goodbye();
}, function () {
console.error('Something wrong with the dependent modules.');
});
// a.js
define(['b'],function (b) {
var hi = function () {
console.log('hi');
};
b.goodbye();
return {
hi: hi
}
});
// b.js
define(['require', 'a'], function (require) {
var goodbye = function () {
console.log('goodbye');
};
// 因为在运算b的时候,a还没准备好,所以不能直接拿到a,只能用require再发起一次新的任务
require(['a'], function (a) {
a.hi();
});
return {
goodbye: goodbye
}
});
我们能看到:模块b
的回调函数中,并不能直接引用到a
,需要使用require
方法包住。
那么问题来了:**在原先的设计中, 每一个define
是跟一个模块一一对应的,require
只能用一次,用于主入口模块(如:main.js)的加载。
但是,现在在模块b
的回调函数中,又出现require(['a'])
,这显然是乱套了。
至此,我发现require
不应该仅仅是用于主入口模块的加载,require
应该对应更高层次的抽象概念:我将它命名为:任务(Task)
,这是一个有别于Module的新的类。
每一次调用require
,相当于新建一个Task(任务)。这个任务的功能是:当任务的所有依赖都准备好之后,执行该任务的成功回调函数。
有没有发现这个Task
原型与Module
很像?它们都有依赖、回调、状态,都需要分析依赖、执行回调函数等方法。但是又有些不同,比如Task
没有网络请求,所以不需要fetch
这样的方法。
所以,我让**Task
继承了Module
**,然后重写某些方法。
关键代码如下:
// before
require = function (dep, cb, errorFn) {
// mainEntryModule是主入口模块
modules[mainEntryModule.name] = mainEntryModule;
mainEntryModule.dep = dep;
mainEntryModule.cb = cb;
mainEntryModule.errorFn = errorFn;
mainEntryModule.analyzeDep();
};
// after
require = function (dep, cb, errorFn) {
let task = new Task(dep, cb, errorFn);
task.analyzeDep();
};
// 引入新的类: Task(任务)
function Task(dep, cb, errorFn) {
this.tid = ++tid;
this.init(dep, cb, errorFn);
}
// Task类继承于Module类
Task.prototype = Object.create(Module.prototype);
至此,我们就完成了一个简单的异步模块加载器。
参考资料
------完-----------
mark
在depedModules.forEach
的回调函数中depCount--
为什么要加上setTimeout呢?
a模块加载完, 所有依赖a的小伙伴们的depCount--; 当depCount为0的时候, 就执行这个回调。
不错
文中,“比如Task没有网络请求,所以不需要fetch这样的方法“,请教下这里,感觉这里说的不太准确,例如在模块a中通过require['c'], 这样的方式(虽然建议在依赖中加入,但是不能排除这种使用)加载模块c,因为之前没有对c的依赖,所以会new Module, 并且执行fetch. 只有在循环依赖模块中的时候,文中所描述的才是对的。这是我的理解。。。不知道有问题木有。。。
nice