haha好吧,我承认这篇文章有点标题党了。
这次要记录的是一个很简单的但是基本符合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 监听模块的加载状况。
2 实现模块的定义。在AMD规范中,每一个模块的编写我们需要遵循类似于这样的形式:
define([ dependents ], factory)
上面也说到,script 标签会立即执行所加载成功的模块,所以如果在此之前我们的 define 函数被挂载到全局的话,define 函数会被传入 ([ dependents ], factory) 等参数后执行,以完成模块的定义工作。
关于模块定义的概念这里需要说一下,我们的模块定义,是指成功将模块的返回值(或者该模块的全部代码) cache 到我们的本地缓存当中,我们会使用一个变量负责去缓存所有的依赖模块以及这些依赖模块所对应的模块ID,所以每次在执行 require 方法或者 define 方法之前我们都会去检查一下所依赖的模块在缓存中是否存在(根据模块ID查找),即是否已经成功定义。如果已经成功定义过了,我们便会忽略对此模块的处理,否则就会去调用 require 方法加载并定义它。待所依赖模块数组对应全部模块都检查已经成功定义过之后,我们再从缓存中取出这些依赖模块的返回值传入 factory 方法当中执行主模块或者 cache 我们当前定义的模块。
以上就是一个简单的模块加载器的一般原理了,具体细节再在下面具体说明。
所以我们的关键是实现 require 和 define 方法。不过在这里有一个重要的细节需要我们处理,前面有提到过,我们的每一次 require 或者 define 之前会去检查所依赖模块是否都已经完全定义,再去定义未定义的依赖模块,那如果所有的依赖模块都已经全部完成定义,我们的 require 或者 define 怎么样才能即时的知晓到这一情报呢?
我们可以借助于实现一个类似于 Nodejs 当中 EventEmiter 模块的事件发射器去完成我们的需求。
这个事件发射器有两个主要的方法 watch 和 emit。
watch :我们在加载依赖模块的同时,将我们的依赖模块数组和回调函数传入事件发射器的 watch 方法,watch 方法会创建一个任务,监听所传入依赖模块数组的加载状况,并且当检测到所依赖模块数组模块全部定义成功之后主动触发之前传入的回调函数,执行接下来的逻辑。
emit :每次有模块被定义成功,便会调用事件发射器的 emit 方法发送一个模块定义成功的信号,同时事件发射器会调用 deal_loaded 方法检查一遍当前定义成功的依赖模块所在依赖模块数组是否全部定义成功,如果是的话,再去调用 excute 方法执行当前完全定义成功的依赖模块数组所对应的回调函数。
事件发射器的代码如下:
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 的方式做出标记,现在我们打开页面观察控制台:
模块加载工具的整个加载流程在控制台里我们都可以观察得到,清晰明了。至此,这篇文章就结束啦,最后祝大家新年快乐!