javascript模块加载器实践
更新日期:
但凡是比较成熟的服务端语言,都会有模块或者包的概念。模块化开发的好处就不用多说了。由于javascript的运行环境(浏览器)的特殊性。js很早之前一直都没有模块的概念。经过一代代程序猿们的努力。提供了若干的解决方案。
基本对象
为了解决模块化的问题。早期的程序员会把代码放到某个变量里。做一个最简单的命名空间的划分。
比如一个工具模块:util
1 2 3 4 5 6 7 8 | var util = { _prefix:'我想说:', log:function(msg){ console.log(_prefix +msg)} /* 其他工具函数 */ } |
这样所有的工具函数都托管在util这个对象变量里,极其简陋的弄了个伪命名空间。这样的局限性很大,因为我们可以随意修改。util不存在私有的属性。_prefix这个私有属性,后面可以随意修改。而我们很难定位到到底在哪边被修改了。
闭包立即执行
后来,一些程序员想到了方法解决私有属性的问题,有了下面这种写法:
1 2 3 4 5 6 7 8 | var util = (function(window){ var _prefix = '我想说:'; return { log:function(msg){ console.log(_prefix +msg)} } })(window) |
主要使用了匿名函数立即执行的技巧,这样 _prefix
是一个匿名函数里面的局部变量,外面无法修改。但是log这个函数里面又因为闭包的关系可以访问到_prefix。只把公用的方法暴露出去。
这是后来模块划分的主要技巧,各大库比如jQuery,都会在最外层包裹这样一个匿名函数。
但是这只是在同一个文件里面的技巧,如果我们把util单独写到一个文件util.js。而我们程序的主代码是main.js那我们需要在页面里面一起用script标签引入:
1 2 | <script src="main.js"></script> <script src="util.js"></script> |
这会有不少问题,最典型的比如如果我们的main.js如下:
1 | util.log('我是模块主代码,我加载好了')
|
这个就执行不了,因为我们的util.js是在main.js后面引入的。所以执行main.js的内容的时候util还没定义呢。
不止这个问题,再比如如果引入了其他的js文件,并且也定义了util这个变量。就会混乱。
模块加载器
node作为javascript服务端的一种应用场景,加入了文件模块的概念,主要是实现的CommonJS规范。
后来一些程序员就想,服务端可以有文件模块。浏览器端为什么就不可以呢。但是CommonJS规范是设计给服务端语言用的,不适合浏览器端的js。
于是出现了amd规范,并且在这个基础上出现了实现amd规范的库requirejs。
后来国内的大神玉伯由于多次给requirejs提建议(比如用时定义)一直不被采纳。于是另起炉灶制作了seajs。慢慢的也沉淀出了seajs的cmd规范。
关于模块规范的具体历史,可以参考:https://github.com/seajs/seajs/issues/588
两个规范差别并不是很大,可能由于写node习惯了,个人更喜欢cmd的编写方式。
首先我们看看基于cmd规范(其实就是seajs)后我们怎么写代码:
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 | //util.js define(function(require, exports, module){ var _prefix = '我想说:'; module.exports = { log:function(msg){ console.log(_prefix +msg)} } }) ///main.js define(function(require, exports, module){ var util = require('util') util.log('我是模块主代码,我加载好了') }) ///index.html <html> <head> <script src="seajs.js"></script> </head> <body> <script type='text/javascript'> seajs.use(["main"]) </script> </body> </html> |
seajs的书写风格跟node很像。
- 使用define来定义一个模块。
- 模块代码里可以使用require去加载另一个模块,
- 使用exports,module.exports来设置结果。
- 通过seajs.use来加载一个主模块。类似c,java里面的main函数。
seajs会自动帮你加载好模块的文件,并且正确的处理依赖关系。于是前端终于也可以使用模块化的开发方式了。
一步一步实现模块加载器
下面我们来实现一个简单的cmd模块加载器程序,也可以当作是seajs的核心源码分析。
获取加载根路径
cmd模块规定一个模块一个文件,当我们require('util')
的时候需要找到对应的文件,一般会加上根路径。默认情况下加载模块的根路径就是seajs.js所在目录。如何获取这个目录地址呢?我们只要在seajs.js里面写上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | var loadderDir = (function(){ //使用正则获取一个文件所在的目录 function dirname(path) { return path.match(/[^?#]*\//)[0] } //拿到引用seajs所在的script节点 var scripts = document.scripts var ownScript = scripts[scripts.length - 1] //获取绝对地址的兼容写法 var src = ownScript.hasAttribute ? ownScript.src :ownScript.getAttribute("src", 4) return dirname(src) })() |
这边有两个小技巧:
- 浏览器是遇到一个script标记执行一个,当seajs.js正在执行的时候,document.scripts获取到的最后一个script就是当前正在执行的script。所以我们可以通过
scripts[scripts.length - 1]
拿到引用seajs.js的那个script节点引用。 - 要获取一个 script节点的src绝对地址。除ie67外,ownScript.src返回的都是绝对地址,但是ie67src是什么就返回什么,这边就是’seajs.js’而不是绝对地址。幸好ie下支持
getAttribute("src", 4)
的方式获取绝对地址。参考这里.aspx)。ie67下没有 hasAttribute属性,所以就有了获取绝对地址的兼容写法。
异步js文件加载器
模块加载是建立在文件加载器基础上的。在浏览器环境下我们可以通过动态生成script标记的方式,加载js。我们写一个简单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 | var head = document.getElementsByTagName("head")[0] var baseElement = head.getElementsByTagName("base")[0] ;function request(url,callback){ var node = document.createElement("script") var supportOnload = "onload" in node if (supportOnload) { node.onload = function() { callback() } }else { node.onreadystatechange = function() { if (/loaded|complete/.test(node.readyState)) { callback() } } } node.async = true node.src = url //ie6下如果有base的script节点会报错, //所以有baseElement的时候不能用`head.appendChild(node)`,而是应该插入到base之前 baseElement ? head.insertBefore(node, baseElement) : head.appendChild(node) } |
主要就是动态生成一个script节点加载js,监听事件触发回调函数,没什么难度,算是一个工具函数,给下面的模块使用。
模块类定义
终于到了重头戏。我们需要引入一个模块类的概念。util,main这些都是一个模块。模块有自己的依赖,有自己的状态。
我们先定义一个模块类:
1 2 3 4 5 6 7 8 9 10 11 12 | function Module(uri,deps){ this.uri = uri this.dependencies = deps || [] this.factory = null this.status = 0 // 哪些模块依赖我 this._waitings = {} // 我依赖的模块还有多少没加载好 this._remain = 0 } |
1.uri代表当前模块的地址,一般是使用baseUrl(就是上面的loadderDir)+ id + ‘.js’
2.dependencies是当前模块依赖的模块。
3.factory就是我们定义模块时define的参数function(require, exports, module){}
4.status代表当前模块的状态,我们先定义下面这些状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | var STATUS = Module.STATUS = { // 1 - 对应的js文件正在加载 FETCHING: 1, // 2 - js加载完毕,并且已经分析了js文件得到了一些相关信息,存储了起来 SAVED: 2, // 3 - 依赖的模块正在加载 LOADING: 3, // 4 - 依赖的模块也都加载好了,处于可执行状态 LOADED: 4, // 5 - 正在执行这个模块 EXECUTING: 5, // 6 - 这个模块执行完成 EXECUTED: 6 } |
5._waitings
存放着依赖我的模块实例集合,_remain
则代表我还有多少依赖模块是处于不可用,也就是上面的小于LOADED的状态。
这个的作用是什么呢?
是这样的,比如A模块依赖B,C模块。那么A模块装载的时候会先去通知B,C模块把自己(A)加入到他们的_waitings
里面。当B模块装载好了,就可以通过遍历B自己的_waitings
去更新依赖它的模块比如A的_remain
值。B发现更新后A的_remain
后不为0,就什么也不做。直到C也好了,C更新下A的_remain
值发现为0了,就会调用A的完成回调了。
如果B,C有自己的依赖模块也是一样的原理。
而如果一个模块没有依赖的模块,就会立即进入完成状态,然后通知依赖它的模块更新_remain
值。他们处于最底端,往上一级级的去更新状态。
模块相互之间的通知机制就是这样,那么状态是如何变化的呢。
我们给模块增加一些原型方法:
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 | //用于加载当前模块所在文件 //加载前状态是STATUS.FETCHING,加载完成后状态是SAVED,加载完后调用当前模块的load方法 Module.prototype.fetch = function(){} //用于装载当前模块,装载之前状态变为STATUS.LOADING,主要初始化依赖的模块的加载情况。 //看一下依赖的模块有多少没有达到SAVED的状态,赋值给自己的_remain。另外对还没有加载的模块设置对应的_waitings,增加对自己的引用。 //挨个检查自己依赖的模块。发现依赖的模块都加载完成,或者没有依赖的模块就直接调用自己的onload //如果发现依赖模块还有没加载的就调用它的fetch让它去加载。如果已经是加载完了,也就是SAVED状态的。就调用它的load Module.prototype.load = function() {} //当模块装载完,也就是load之后会调用此函数。会将状态变为LOADED,并且遍历自己的_waitings,找到依赖自己的那些模块,更新相应的_remain值,发现为0的话就调用对应的onload。 //onload调用有两种情况,第一种就是一个模块没有任何依赖直接load后调用自己的onload. //还有一种就是当前模块依赖的模块都已经加载完成,在那些加载完成的模块的onload里面会帮忙检测_remain。通知当前模块是否该调用onload //这样就会使用上面说的那套通知机制,当一个没有依赖的模块加载好了,会检测依赖它的模块。发现_remain为0,就会帮忙调用那个模块的onload函数 Module.prototype.onload = function() {} /*===========================================*/ /*****下面的几个跟上面的通知机制就没啥关系了*****/ /*===========================================*/ //exec用于执行当前模块的factory //执行前为STATUS.FETCHING 执行后为STATUS.EXECUTED Module.prototype.exec = function(){} //这是一个辅助方法,用来获取格式化当前依赖的模块的地址。 //比如上面就会把 ['util'] 格式化为 [baseUrl(就是上面的loadderDir)+ util + '.js'] Module.prototype.resolve = function(){} //实例生成方法,所有的模块都是单例的,get用来获得一个单例。 Module.get = function(){} |
是不是感觉有点晕,没事我们一个个来看。
辅助函数
我们先把辅助函数实现下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | //存储实例化的模块对象 cachedMods = {} //根据uri获取一个对象,没有的话就生成一个新的 Module.get = function(uri, deps) { return cachedMods[uri] || (cachedMods[uri] = new Module(uri, deps)) } //进行id到url的转换,实际情况会比这个复杂的多,可以支持各种配置,各种映射。 function id2Url(id){ return loadderDir + id + '.js' } //解析依赖的模块的实际地址的集合 Module.prototype.resolve = function(){ var mod = this var ids = mod.dependencies var uris = [] for (var i = 0, len = ids.length; i < len; i++) { uris[i] = id2Url(ids[i]) } return uris } |
fetch与define的实现
实现fetch之前我们先实现全局函数define。
fetch会生成script节点加载模块的具体代码。
还记得我们上面模块定义的写法吗?都是使用define来定义一个模块。define的主要任务就是生成当前模块的一些信息,给fetch使用。
define的实现:
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 | var REQUIRE_RE = /"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\/\*[\S\s]*?\*\/|\/(?:\\\/|[^\/\r\n])+\/(?=[^\/])|\/\/.*|\.\s*require|(?:^|[^$])\brequire\s*\(\s*(["'])(.+?)\1\s*\)/g var SLASH_RE = /\\\\/g //工具函数,解析依赖的模块 function parseDependencies(code) { var ret = [] code.replace(SLASH_RE, "") .replace(REQUIRE_RE, function(m, m1, m2) { if (m2) { ret.push(m2) } }) return ret } function define (factory) { //使用正则分析获取到对应的依赖模块 deps = parseDependencies(factory.toString()) var meta = { deps: deps, factory: factory } //存到一个全局变量,等后面fetch在script的onload回调里获取。 anonymousMeta = meta } |
这边为了尽量展现原理,去掉了很多兼容的代码。
比如其实define是支持function (id, deps, factory)
这种写法的,这样就可以提前写好模块的id和deps,这样就不需要通过正则去获取依赖的模块了。一般写的时候只写factory,上线时会使用构建工具生成好deps参数,这样可以避免压缩工具把require关键字压缩掉而导致依赖失效。性能上也会更好。
另外,为了兼容ie下面的script标签不一定触发的问题。这边其实有个getCurrentScript()的方法,用于获取当前正在解析的script节点的地址。这边略去,有兴趣的可以去源码里看看。
1 2 3 4 5 6 7 8 9 10 | function getCurrentScript() { //主要原理就是在ie6-9下面可以查看script.readyState === "interactive"来判断当前节点是否处于加载状态 var scripts = head.getElementsByTagName("script") for (var i = scripts.length - 1; i >= 0; i--) { var script = scripts[i] if (script.readyState === "interactive") { return script } } |
下面是fetch的实现:
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 | Module.prototype.fetch = function() { var mod = this var uri = mod.uri mod.status = STATUS.FETCHING //调用工具函数,异步加载js request(uri, onRequest) //保存模块信息 function saveModule(uri, anonymousMeta){ //使用辅助函数获取模块,没有就实例化个新的 var mod = Module.get(uri) //保存meta信息 if (mod.status < STATUS.SAVED) { mod.id = anonymousMeta.id || uri mod.dependencies = anonymousMeta.deps || [] mod.factory = anonymousMeta.factory mod.status = STATUS.SAVED } } function onRequest() { //拿到之前define保存的meta信息 if (anonymousMeta) { saveModule(uri, anonymousMeta) anonymousMeta = null } //调用加载函数 mod.load() } } |
load与onload的实现
fetch完成后会调用load方法。
我们看下load的实现:
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 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 | Module.prototype.load = function() { var mod = this // If the module is being loaded, just wait it onload call if (mod.status >= STATUS.LOADING) { return } mod.status = STATUS.LOADING //拿到解析后的依赖模块的列表 var uris = mod.resolve() //复制_remain var len = mod._remain = uris.length var m for (var i = 0; i < len; i++) { //拿到依赖的模块对应的实例 m = Module.get(uris[i]) if (m.status < STATUS.LOADED) { // Maybe duplicate: When module has dupliate dependency, it should be it's count, not 1 //把我注入到依赖的模块里的_waitings,这边可能依赖多次,也就是在define里面多次调用require加载了同一个模块。所以要递增 m._waitings[mod.uri] = (m._waitings[mod.uri] || 0) + 1 } else { mod._remain-- } } //如果一开始就发现自己没有依赖模块,或者依赖的模块早就加载好了,就直接调用自己的onload if (mod._remain === 0) { mod.onload() return } //检查依赖的模块,如果有还没加载的就调用他们的fetch让他们开始加载 for (i = 0; i < len; i++) { m = cachedMods[uris[i]] if (m.status < STATUS.FETCHING) { m.fetch() } else if (m.status === STATUS.SAVED) { m.load() } } } Module.prototype.onload = function() { var mod = this mod.status = STATUS.LOADED //回调,预留接口给之后主函数use使用,这边先不管 if (mod.callback) { mod.callback() } var waitings = mod._waitings var uri, m //遍历依赖自己的那些模块实例,挨个的检查_remain,如果更新后为0,就帮忙调用对应的onload for (uri in waitings) { if (waitings.hasOwnProperty(uri)) { m = cachedMods[uri] m._remain -= waitings[uri] if (m._remain === 0) { m.onload() } } } } |
这样整个通知机制就结束了。
exec的实现
模块onload之后代表已经处于一种可执行状态。seajs不会立即执行模块代码,只有你真正require了才会去调用模块的exec去执行。这就是用时定义。
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 | Module.prototype.exec = function () { var mod = this if (mod.status >= STATUS.EXECUTING) { return mod.exports } mod.status = STATUS.EXECUTING var uri = mod.uri //这是会传递给factory的参数,factory执行的时候,所有的模块已经都加在好处于可用的状态了,但是还没有执行对应的factory。这就是cmd里面说的用时定义,只有第一次require的时候才会去获取并执行 function require(id) { return Module.get(id2Url(id)).exec() } function isFunction (obj) { return ({}).toString.call(obj) == "[object Function]" } // Exec factory var factory = mod.factory //如果factory是函数,直接执行获取到返回值。否则赋值,主要是为了兼容define({数据})这种写法,可以用来发jsonp请求等等。 var exports = isFunction(factory) ? factory(require, mod.exports = {}, mod) : factory //没有返回值,就使用mod.exports的值。看到这边你受否明白了,为什么我们要返回一个函数的时候,直接exports = function(){}不行了呢?因为这边取的是mod.exports。exports只是传递过去的指向{}的一个引用。你改变了这个引用地址,却没有改变mod.exports。所以当然是不行的。 if (exports === undefined) { exports = mod.exports } mod.exports = exports mod.status = STATUS.EXECUTED return exports } |
入口函数seajs.use
上面这套东西已经完成了整个模块之间的加载执行依赖关系了。但是还缺少一个入口。
这时候就是seajs.use出场的时候了。seajs.use用来加载一些模块。比如下面:
1 | seajs.use(["main"])
|
其实我们可以把它当作一个主模块,use的后面那些比如main就是它的依赖模块。而且这个主模块比较特殊,他不需要经过加载的过程,直接可以从load装载开始,于是use的实现就很简单了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | seajs = {} seajs.use = function (ids, callback) { //生成一个带依赖的模块 var mod = Module.get('_use_special_id', ids) //还记得上面我们在onload里面预留的接口嘛。这边派上用场了。 mod.callback = function() { var exports = [] //拿到依赖的模块地址数组 var uris = mod.resolve() for (var i = 0, len = uris.length; i < len; i++) { //执行依赖的那些模块 exports[i] = cachedMods[uris[i]].exec() } //注入到回调函数中 if (callback) { callback.apply(global, exports) } } //直接使用load去装载。 mod.load() } |
于是整个流程就变成了这样:
主入口函数use直接生成一个模块,直接load。然后建立好依赖关系。通过上面那套通知机制,从下到上一个个的触发模块的onload。然后主函数里面调用依赖模块的exec去执行,然后一层层的下去,每一层都可以通过require来执行对应的factory。整个过程就是这样。
结语
又是一个因为js本身的缺陷,然后后人擦屁股的事情。这样的例子已经数不胜数了。js真是让人又爱又恨。总之有了模块加载器,让js有了做大规模富客户端应用的能力。是前端工业化开发不可缺少的一环。