开发史上最强模块加载工具
这次要记录的是一个很简单的但是基本符合AMD规范的浏览器端模块加载工具的开发流程。因为自从使用过require.js、webpack等模块化加载工具之后就一直对它的实现原理很好奇,于是稍微研究了一下。
实现的方法有许多,但简单实现的话大致都会实现出以下的两个方法:
1 实现模块的加载。从主模块说起,我们需要通过一个入口来加载我们的主模块的依赖模块,同时在加载完依赖之后,能够取得所依赖模块的返回值,并将它们传入主模块代码中,再去执行我们的主模块代码。函数入口类似于这样的形式:
require([ dependents ], function( ){ // 主模块代码 })
至于如何去加载我们的依赖模块,这里一般可以有两种处理方式,一种是通过Ajax请求依赖模块,一种是为依赖模块动态创建 script 标签加载依赖模块,在这里我只选择第二种方式,不过如果你需要加载文本文件或者JSON文件的话,还是需要采用Ajax加载的方式,但这里为了简单处理我们不考虑这种情况。
所以我们会遍历主模块的依赖数组,对依赖模块的路径做简单的处理之后,动态创建 script 标签加载每一个依赖模块。所谓的加载模块,其本质便是通过网络请求将模块 Fetch 到本地。通过 script 标签加载资源有两个特点:
1.1 script 标签加载到JS代码之后会立即执行这一段代码。JSONP也利用了 script 标签的这个特点。
1.2 可以通过 script.onload 和 script.onerror 监听模块的加载状况。我们只需要缓存对应模块的返回值即可,所以可以监听 script 标签的 onload 事件,在模块缓存成功之后删除对应的 script 标签。
2 实现模块的定义。在AMD规范中,每一个模块的编写我们需要遵循类似于这样的形式:
define([ dependents ], factory( results ))
上面也说到,script 标签会立即执行加载成功的模块,所以如果在此之前我们的 define 函数已经被挂载到全局的话,define 函数会被立即执行,完成模块的定义工作。
关于模块定义的概念这里需要说一下,我们的模块定义,是指成功将模块的返回值(或者该模块的全部代码) cache 到我们的本地缓存当中,我们会使用一个变量负责去缓存所有的依赖模块以及这些依赖模块所对应的模块ID,所以每次在执行 require 方法或者 define 方法之前我们都会去检查一下所依赖的模块在缓存中是否存在(根据模块ID查找),即是否已经成功定义。如果已经成功定义过了,我们便会忽略对此模块的处理,否则就会去调用 require 方法加载并定义它。待依赖模块都已经成功定义过之后,我们再从缓存中取出这些依赖模块的返回值传入 factory 方法当中执行主模块或者 cache 我们当前定义的模块。
以上就是一个简单的模块加载器的一般原理了,具体细节再在下面具体说明。
所以我们的关键是实现 require 和 define 方法。不过在这里有一个重要的细节需要我们处理,前面有提到过,我们的每一次 require 或者 define 之前会去检查所依赖模块是否都已经完全定义,再去定义未定义的依赖模块,那如果所有的依赖模块都已经全部完成定义,我们的 require 或者 define 怎么样才能即时的知晓到这一情报呢?
我们可以借助于实现一个类似于 Nodejs 当中 EventEmiter 模块的事件发射器去完成我们的需求。
这个事件发射器有两个主要的方法 watch 和 emit。
watch :我们在加载依赖模块的同时,会将我们的依赖模块数组和回调函数( factory )传入事件发射器的 watch 方法,watch 方法会为我们创建一个任务,监听所传入依赖模块数组的加载状况,一旦检测到依赖模块数组中的模块全部都已经定义成功之后,主动触发之前传入的回调函数( factory ),执行接下来的逻辑。
emit :每次有模块被定义成功,便会调用事件发射器的 emit 方法发送一个模块定义成功的信号,之后事件发射器会检查一遍当前定义成功的模块所在的依赖模块数组中的依赖模块是否全部已经定义成功,如果是的话,再去执行依赖模块数组对应的回调函数( factory )。
事件发射器的代码如下:
var utils = { ...... proxy : (function( ){ var tasks = { } var task_id = 0 var excute = function( task ){ console.log( "excute task" ) var urls = task.urls var callback = task.callback var results = [ ] for( var i = 0; i < urls.length; i ++ ){ results.push( modules[ urls[ i ] ] ) } callback( results ) } var deal_loaded = function( url ){ console.log( "deal_loaded " + url ) var i, k, sum = 0 for( k in tasks ){ if( tasks[ k ].urls.indexOf( url ) > -1 ){ for( i = 0; i < tasks[ k ].urls.length; i ++ ){ if( m_methods.isModuleCached( tasks[ k ].urls[ i ] ) ){ sum ++ } } if( sum == tasks[ k ].urls.length ){ excute( tasks[ k ] ) delete( tasks[ k ] ) } } } } var emit = function( m_id ){ console.log( m_id + " was loaded !" ) deal_loaded( m_id ) } var watch = function( urls, callback ){ console.log( "watch : " + urls ) var sum for( var i = 0; i < urls.length; i ++ ){ if( m_methods.isModuleCached( urls[ i ] ) ){ sum ++ } } if( sum == urls.length ){ excute({ urls : urls, callback : callback }) } else { console.log( "创建监听任务 : " ) var task = { urls : urls, callback : callback } tasks[ task_id ] = task task_id ++ console.log( task ) } } return { emit : emit, watch : watch } })( ) }
define方法实现:
var define = function(deps, factory) { console.log("define...") var _deps = factory ? deps : [], _factory = factory ? factory : deps new Module(_deps, _factory) }
function Module(deps, factory) { var _this = this _this.m_id = doc.currentScript.src // 判断模块是否定义成功 if (m_methods.isModuleCached(_this.m_id)) { return } if (arguments[0].length == 0) { // 没有依赖模块 _this.factory = arguments[1] // 模糊定义成功,取返回值添加到缓存中 m_methods.cacheModule(_this.m_id, _this.factory()) utils.proxy.emit(_this.m_id) } else { // 有依赖模块 _this.factory = arguments[1] // 加载依赖模块 require(arguments[0], function(results) { m_methods.cacheModule(_this.m_id, _this.factory(results)) utils.proxy.emit(_this.m_id) }) } }
require方法:
var require = function(deps, callback) { console.log("require " + deps) if (!Array.isArray(deps)) { deps = [deps] } var urls = [] for (var i = 0; i < deps.length; i++) { // 处理模块路径 urls.push(utils.resolveUrl(deps[i])) } utils.proxy.watch(urls, callback) // 加载依赖模块 m_methods.fetchModules(urls) }
这里有一个小细节,在处理依赖模块路径的时候,可以借助 a 标签去获取到我们需要的绝对路径,a 标签有一个特点,当我们通过 JS 去获取它的 href 值时,它始终会给我们返回相对应的绝对路径,即使我们之前给它的 href 值赋予的是相对路径。
所以我们的路径处理可以这么实现:
...... var _script = document.getElementsByTagName("script")[0] var _a = document.createElement("a") _a.style.visibility = "hidden" document.body.insertBefore(_a, _script) ...... var utils = { resolveUrl: function(url) { _a.href = url var absolute_url = _a.href _a.href = "" return absolute_url }, ...... }
至此我们的模块加载工具的主要功能都已大致实现。完整代码在 https://github.com/KellyLy/loader.js
现在可以测试一下。假设我们现在有a、b、c、d四个模块,分别是:
以及主模块:
一切就绪,我们在关键区域都以打印 log 的方式做出标记,现在我们打开页面观察控制台:
没毛病,模块加载工具的整个加载流程在控制台里我们都可以观察得到,清晰明了。至此,这篇文章就结束啦,最后祝大家新年快乐!