前言
前端模块化已经成为了当下主流的开发模式.模块化能将不同页面的逻辑分别写在对应的文件中管理,其次还能将一些公共的函数或插件单独封装成模块.页面要调用相关功能只需在代码中引用一遍就可以使用.通过模块化可以使大型复杂的项目拆分成不同的模块管理,有助于后期维护和拓展.
本文将手写一遍模块加载器的核心代码,整个过程要实现以下功能.
- 设计模块加载器的整体架构
- 父模块如何一步步加载子模块和后代
- 子模块加载完毕了如何通知父模块
调用方式
先从用户的角度设计好模块加载器的调用方式,再从结果反推实现过程.假设当前页面需要引用a
模块,代码如下.
<script src="./lib/mload.js"></script>
<script>
Mload.use(['/js/a.js'], function (a) {
console.log(a);//输出a模块的导出内容
});
</script>
Mload
是最终暴露给用户调用的对象,运行use
方法后,浏览器会开始请求项目目录下js
文件夹下的a.js
.脚本加载成功后触发回调函数,a.js
返回的数据将作为回调函数的参数参与执行.
/js/a.js 文件
define(function (require, exports, module) {
var c = require("./c.js"); //引用c模块的数据.引用后就可以调用c模块对外暴露的属性和方法
exports.data = {
value: 123
};
});
在a.js
中约定所有代码全部写在define
函数包裹的函数体内部.define
包裹的函数会对外暴露三个参数:require
、exports
和module
.
require
能够获取其他模块的数据.通过传入其他模块的文件路径,就可以返回对外导出的数据.exports
是本模块对外导出数据和方法的实现机制.exports
可以写多条导出代码,那么其他模块引用当前模块时就可以获取exports
导出的数据.
//a模块
exports.data = 123;
exports.test = function(){console.log("hello world")};
...
//其他模块引用a模块
var data = require("./a.js");
console.log(data); // {data:123,test:function(){console.log("hello world")}
module
的功能和exports
一样,都是导出数据供外部调用.不过module
的调用方式和exports
有区别,module
在模块中只能导出一次,导出语句如下.
//a模块
module.exports = {
value:123
}
...
//其他模块
var data = require("./a.js");
console.log(data); // {value:123}
define
函数的实现过程是模块加载器的重点,了解清楚define
函数具体在用户端如何使用后,接下来就可以一探其底层的代码逻辑.
页面引用a
模块后,最终输出结果.
{
data:{
value:123
}
}
实现
在页面入口调用以下代码,启动模块加载器.随后模块加载器整个执行过程分为三部分.
- 加载子模块
- 子模块全部加载完毕后一层层向上通知父模块,直到入口文件处的根模块
- 根模块收到所有脚本文件加载完毕的通知后,开始获取子模块的数据供外部使用
Mload.use(['/js/a.js'], function (a) {
console.log(a);//输出a模块的导出内容
});
加载子模块
上面代码执行后,模块加载器便会去请求a.js
的脚本,脚本返回后开始执行.此时在a.js
中又可能引用多个其它的子模块,那么又要去加载其他子模块.其他子模块里又可能引用子模块,就这样一直往下加载,直到最后一个子模块没有外部引入时为止.
上面的执行过程可以拆分成以下步骤一步步实现.
- 创建根模块和一个
callback
回调函数.将a.js
划到根模块的子级依赖数组中存储起来.回调函数要等到所有子级全部加载完毕后再执行. - 根模块开始循环遍历依赖数组,依次加载子模块.此时数组中只有一个
a.js
,针对这个脚本文件创建一个a
模块.随后让浏览器请求a.js
的脚本,请求成功后执行脚本内容. a.js
的脚本执行完毕后,将a.js
里面使用了require
语法引用其他子模块的路径收集存储在a
模块的依赖数组里.再按照上述第一步的步骤嵌套加载子模块,直到最后一个不再有引用的子模块为止.
加载根模块
首先先创建3个全局变量.缓存对象cache_data
、状态码code
和构造函数Mload
.
var cache_data = {}; //缓存对象
/**
* 状态码
*/
var code = {
created: 1, //创建模块
load:2, //开始加载依赖
feching: 3, //开始加载远程脚本文件
feched:4, //脚本文件加载完毕
loaed:5, //模块加载完毕
};
/**
* 模块构造函数
* @param {*} params
*/
function Mload(params) {
this.id = params.id;
this.libs = params.libs || [];
this.status = code.created;
this.cnum = 0; //有几个子级没有加载完
this.parent_lib = {}; //有哪些父级引用我
this.exports = {}; //对外导出的数据
}
Mload
是创建模块的构造函数.它里面会存储与模块构建相关的数据.每个模块的id都是唯一的,子模块的id就是请求该模块脚本文件的绝对路径.比如"/js/a.js"
文件对应着a
模块,那么它的id
就等于请求该文件的绝对路径,形如http://www.xxx.com/js/a.js
.另外libs
里面存储着该模块依赖的子级组成的id
数组.code
里面的状态标志着模块处于生命周期的各个阶段.cache_data
是全局缓存模块的对象.它的结构形如{'http://www.xxx.com/js/a.js':a模块实例}
.cache_data
会将每一个模块的id
作为key
值,实例作为值缓存起来.
Mload.use = function (libs, callback) {
var root_id = getPath() + getModuleId(); //生成一个随机id
libs = pathHandler(libs); //对路径格式做规范处理
var m = getModule(root_id);
m.callback = function () {
//所有子模块加载完毕后再执行
};
m.libs = libs;
m.cnum = libs.length; //依赖的数量
m.loadModule();//加载子模块
};
/**
* 获取module
*/
function getModule(id) {
return cache_data[id]
? cache_data[id]
: (cache_data[id] = new Mload({ id }));
}
Mload.use
是模块加载器的入口函数.它会完成创建根模块和callback
函数的工作.随后就开始加载子模块.
加载子模块
根模块通过执行loadModule
方法开始加载子模块.遍历循环根模块的依赖数组libs
的过程中,这行代码很关键.
seed.parent_lib[m.id] = 1;
子模块要把根模块的id
记录下来,为了后面子模块一旦加载完毕可以通知到父模块.
Mload.prototype.loadModule = function () {
var m = this;
m.status = code.load; //准备加载依赖
var libs = m.libs;
for (var i = 0; i < libs.length; i++) {
var seed = getModule(libs[i]); //获取子模块
seed.parent_lib[m.id] = 1; //将父模块全部注册进来
if (seed.status < code.feching) {
//开始加载子模块
seed.fetchFile();
}
}
};
从下面代码可以看出,加载子模块的脚本文件是通过动态创建script
标签实现的.
创建script
标签后,将请求地址赋给src
再挂载在body
下,这时候浏览器就开始去请求脚本了.等到脚本加载完毕了,浏览器默认会去执行脚本的代码.
脚本执行完毕后才会触发下面的onload
方法进而执行callback
函数.
/**
* 请求子文件
*/
Mload.prototype.fetchFile = function () {
var m = this;
m.status = code.feching; //加载脚本文件
var callback = function () {
//脚本加载完毕后的工作
};
requestFile(m.id, callback);
};
function requestFile(id, callback) {
var script = document.createElement('SCRIPT');
script.src = id;
document.body.appendChild(script);
script.onload = function () {
script.remove();
callback();
};
}
子模块继续加载子模块
脚本文件从远程加载到body
下面开始执行.此时脚本的文件的内容如下,define
函数被执行,它包裹了一个函数体将作为参数传递进去.
define(function (require, exports, module) {
var c = require("./c.js"); //引用c模块的数据.引用后就可以调用c模块对外暴露的属性和方法
exports.data = {
value: 123
};
});
define
函数里会将函数体factory
转化成字符串,再使用正则表达式匹配出所有require
包裹的参数.
这样该模块依赖的子模块的路径都能获取到,随后将结果存在一个全局变量tmp_data
中.
function define(factory) {
var factory_string = factory.toString();
//匹配出 require("c.js") 里面的 c.js
var reg = /\brequire\((['"])([a-zA-Z\d\.-_]+)\1\)/g;
var libs = [];
factory_string.replace(reg, function (match, $1, $2) {
libs.push($2);
return match;
});
var obj = {
factory: factory,
libs: libs,
};
tmp_data = obj; //tmp_data是一个全局变量
}
此时远程请求的脚本文件已经执行完了,线程又会回到上面介绍过的fetchFile
方法执行callback
函数.
Mload.prototype.fetchFile = function () {
var m = this;
m.status = code.feching; //加载脚本文件
var callback = function () {
m.factory = tmp_data.factory; //工厂函数
var result = childPathHandler(m.id, tmp_data.libs); //将依赖数组里面的路径做一下规范处理
var libs = [];
for (var i = 0; i < result.length; i++) {
//为了防止a文件引入b,b里面又引入a造成的问题.
if (getModule(result[i]).status < code.feched) {
libs.push(result[i]);
}
}
m.libs = libs;
m.cnum = libs.length; //依赖的数量
m.status = code.feched; // 脚本文件加载完毕
if (m.cnum == 0) {
//加载完毕,没有依赖了
m.loaded();
} else {
m.loadModule();
}
};
requestFile(m.id, callback);
};
在callback
函数里,拿到刚才缓存的全局变量tmp_data
,将该模块依赖的数组libs
存储起来.
此时要对模块的依赖数量做一下判断,如果它没有再引入子级了,就执行loaded
方法.如果还有子级继续回到上面流程,嵌套加载子模块.
通知父模块
在走完了上面加载子模块
流程之后,最终所有的子级都加载完毕.此时最后一个子级因为没再引入其他子级了,它便会执行loaded
方法.
/**
* 加载完毕
*/
Mload.prototype.loaded = function () {
var m = this;
m.status = code.loaed; //模块加载完毕
if (m.callback) {
m.callback();
return false;
}
var parent_lib = m.parent_lib;
for (var key in parent_lib) {
var parent_module = getModule(key);
parent_module.cnum--;
if (parent_module.cnum == 0) {
//子依赖全部加载完毕
parent_module.loaded();
}
}
};
parent_lib
在前面提到过,子级可以通过它找到父级模块.
当父级模块下面的所有子级模块全部加载完毕后,它的cnum
依赖数量会减到0,它又会继续嵌套调用loaded
通知它的父级.
子级一层层往上通知,直到通知到了根模块.在入口函数里根模块绑定了一个callback
函数,代码跑到此处便会执行callback
函数.
//入口函数
Mload.use = function (libs, callback) {
var root_id = getPath() + getModuleId();
libs = pathHandler(libs); //对路径处理
var m = getModule(root_id);
m.callback = function () {
var result = [];
for (var i = 0; i < libs.length; i++) {
var seed = getModule(libs[i]);
result.push(seed.exec()); //获取模块的导出数据
}
callback.apply(null, result);
};
m.libs = libs;
m.cnum = libs.length; //依赖的数量
m.loadModule();
};
在上面入口函数定义的callback
中,代码首先会获取根模块的依赖数组libs
,通过遍历libs
得到子模块的导出数据.最终将这份数据传递给外部编写的回调函数里.
如此用户就可以通过模块加载器在外部得到任意模块返回的数据了.
至此大功就告成了,但上面父模块是怎么获取子模块的导出数据呢?答案是通过调用exec
方法就得到子模块导出的数据,实现细节后面详述.
Mload.use(['/js/a.js'], function (a) {
console.log(a);
});
获取数据
根模块遍历循环它下面的子级依赖,想得到子级的导出数据.
于是子模块开始执行exec
方法返回数据.
/**
* 获取模块的导出数据
*/
Mload.prototype.exec = function () {
var m = this;
if (JSON.stringify(m.exports)!=="{}") {
//已经将导出数据缓存了
return m.exports;
}
var factory = m.factory;
var require = function (path) {
path = childPathHandler(m.id, path); //规范路径格式
var seed = getModule(path);
if (seed.libs.indexOf(m.id) !== -1) {
return seed.exports;
}
return seed.exec();
};
var module = {
exports: {},
};
factory.apply(null, [require, module.exports, module]);
for(var key in module.exports){
m.exports[key] = module.exports[key];
}
return m.exports;
};
在exec
函数中,最关键的部份就是会去执行define
包裹的函数体factory
.在执行这个factory
之前,会定义好三个变量require
、module.exports
和module
作为参数传递进去.
factory
的函数体形如下面代码.
define(function (require, exports, module) {
var c = require("./c.js");
exports.data = {
value: 123,
c:c
};
});
module.exports
和module
其实指向的数据源是相同的.factory
执行后会将脚本文件导出的数据赋予module.exports
.将它存储在模块实例的exports
属性里,最终这个数据将会作为该模块的对外导出数据.require
函数通过嵌套调用exec
函数获取下一个子级的导出数据.如果层级比较深,它就会一直往下获取,直到最后一层没有引入为止.
exec
函数通过这样嵌套调用的机制,最终会把属于该模块的数据悉数导出供给外部调用.
总结
模块加载器整个流程还是挺复杂的,从宏观上可以将其过程分成三步.
- 从根模块开始,嵌套加载所有子模块和后代.
- 子模块加载完毕后开始层层往上通知父模块,直到最后通知到根模块.
- 根模块知道所有后代模块全部加载完毕后,又开始向下一层一层嵌套获取子模块的导出数据.最终将数据返回给用户端使用.