Javascirpt模块化
匿名函数与模块化
在JavaScript里最令人懊恼的事情是变量没有使用范围。任何变量,函数,数组,对象, 只要不在函数内部,都被认为是全局的,这就是说,这个页面上的其它脚本也可以访问它, 而且可以覆盖重写它。解决办法是,把你的变量放在一个匿名函数内部,定义完之后立即 调用它。
例如,下面的写法将会产生三个全局变量和两个全局函数:
var name = 'Chris'; var age = '34'; var status = 'single'; function createMember(){ // [...] } function getMemberDetails(){ // [...] }
如果这个页面上的其它脚本里也存在一个叫status
的变量,麻烦就会出现。如果我们把
它们封装在一个myApplication
里,这个问题就迎刃而解了:
var myApplication = function(){ var name = 'Chris'; var age = '34'; var status = 'single'; function createMember(){ // [...] } function getMemberDetails(){ // [...] } }();
但是,这样一来,在函数外面就没有什么功能了。如果这是你需要的,那就可以了。你还 可以省去函数的名称:
(function(){ var name = 'Chris'; var age = '34'; var status = 'single'; function createMember(){ // [...] } function getMemberDetails(){ // [...] } })();
如果你想在函数外面也能使用里面的东西,那就要做些修改。
为了能访问createMember()
或getMemberDetails()
,需要把它们变成myApplication
的属性,从而把它们暴露于外部的世界:
var myApplication = function(){ var name = 'Chris'; var age = '34'; var status = 'single'; return{ createMember:function(){ // [...] }, getMemberDetails:function(){ // [...] } } }(); //myApplication.createMember() 和 //myApplication.getMemberDetails() 就可以使用了。
这被称作module
模式或singleton
。Douglas Crockford 多次谈到过这些,YUI(Yahoo
User Interface Library)里对此有大量的使用。但这样一来让我感到不便的是,我需要
改变句式来使函数和变量能被外界访问。更甚者,调用时我还需要加上myApplication
这个前缀。我更愿意简单的把需要能被外界访问的元素的指针导出来。这样做后,反倒简化
了外界调用的写法:
var myApplication = function(){ var name = 'Chris'; var age = '34'; var status = 'single'; function createMember(){ // [...] } function getMemberDetails(){ // [...] } return{ create:createMember, get:getMemberDetails } }(); //现在写成 myApplication.get()和 myApplication.create() 就行了。
我把这个称作revealing module pattern
典型的模块化规范有CMD规范与AMD规范两套,先来看一下基本的概念。
通过闭包建立模块
Douglas Crockford在:《Javascript: The Good Parts》一书中提出的 「Module Pattern」利用Javascript的闭包技术来模拟模块的概念,防止名字冲突和 全局变量的使用:
var moduleName = function () { // Define private variables and functions var private = ... // Return public interface. return { foo: ... }; }();
模块之间的依赖与自动加载
CommonJS组织 定义了AMD规范 方便开发者显示指定模块之间的依赖关系,并在需要时加载依赖的模块。 RequireJS是AMD规范的一个比较流行的实现。
首先我们在a.js
中定义模块A
.
define(function () { return { color: "black", size: 10 }; });
然后定义模块B
依赖模块A
.
define(["a"], function (A) { // ... });
当模块B执行时RequireJS保证模块A
已被加载。具体细节可参考RequireJS官方文档。
脚本加载
最简单的脚本加载方式是放在<head>
加载。
<head> <script src="base.js" type="text/javascript"></script> <script src="app.js" type="text/javascript"></script> </head>
其缺点是:
-
加载和解析是顺序是同步执行的,先下载
base.js
然后解析和执行, 然后再下载app.js
; -
加载脚本时还会阻塞对
<script>
之后的DOM元素的渲染。
为了缓解这些问题,现在的普遍做法是将<script>
放在<body>
的底部。
<script src="base.js" type="text/javascript"></script> <script src="app.js" type="text/javascript"></script> </body>
但并不是所有的脚本都可以放在<body>
的底部,比如有些逻辑要在页面渲染时执行,
不过大多数脚本没有这样的要求。
将脚本放在<body>
底部仍然没有解决顺序下载的问题,一些浏览器厂商也意识到了
这个问题并开始支持异步下载。HTML5也提供了标准的解决方案:
<script src="base.js" type="text/javascript" async></script> <script src="app.js" type="text/javascript" async></script>
标上async
属性的脚本表明你没有在里面使用document.write
之类的代码。浏览器
将异步下载和执行这些脚本,并且不会组织DOM树的渲染。但是这会导致另一个问题:
由于是异步执行,app.js
可能在base.js
之前执行,如果它们之间有依赖关系这将
导致错误。
讲到这里从开发者角度来看我们其实需要的是这些特性:
- 异步下载,不要阻塞DOM的渲染;
- 按照模块的依赖关系解析和执行脚本。
所以脚本的加载其实需要与模块化编程问题结合起来解决。RequireJS
不仅记录了模块
之间的依赖关系,并且提供了根据依赖关系的按需加载和执行(详情请参考 RequireJS
官方文档)。
关于脚本加载的更多方案请看这里
异步模块加载简单实现
既然是模块化加载,想办法把模块内容拿到当然是重头戏,无论是script还是css文件的 加载,一个script或者link标签就可以搞定问题,不过我这里采用的是ajax,目的是为了 拿到script的代码,也是为了照顾后面要说的CMD规范。
var require = function(path){ var xhr = new XMLHttpRequest(), res; xhr.open("GET", path, true); xhr.onreadystatechange = function(){ if(xhr.readyState == 4 && xhr.status == 200){ // 获取源码 res = xhr.responseText; } } xhr.send(); };
创建script便签加载脚本不会存在跨域问题,不过拿到的脚本会被浏览器立马解析出来, 如果要做同异步的处理就比较麻烦了。没有跨域的文件我们就通过上面的方式加载,如果 脚本跨域了,再去创建标签,让文档自己去加载。
// 跨域处理 if(crossDomain){ var script = document.createElement("script"); script.src = path; (document.getElementsByTagName("head")[0] || document.body).appendChild(script); }
解析模块的层次依赖关系
模块之间存在依赖关系是十分正常的,如一个工程的文件结构如下:
project/ ├── css/ │ └── main.css ├── js/ │ ├── require.js │ └── modlues/ │ ├── a.js │ ├── b.js │ └── c.js └── index.html
而这里几个模块的依赖关系是:
┌> a.js -> b.js index.html -| └> c.js
代码:
// a.js require("./js/test/b.js"); // b.js console.log("i am b"); // c.js console.log("i am c");
我们要从index.html
中利用require.js
获取这一连串的依赖关系,一般采用的方式就是
正则匹配。如下:
先拿到function的代码,然后正则匹配出第一层的依赖关系,接着加载匹配到关系的代码, 继续匹配。
// index.html <script type="text/javascript" src="./js/require.js"></script> <script type="text/javascript"> function test(){ var a = require("./js/modlues/a.js"); var c = require("./js/modlues/c.js"); } // toString 方法可以拿到 test 函数的 code start(test.toString()); </script>
整个函数的入口是start
,正则表达式为:
var r = /require\((.*)\)/g; var start = function(str){ while(match = r.exec(str)) { console.log(match[1]); } };
由此我们拿到了第一层的依赖关系,
["./js/modlues/a.js", "./js/modlues/c.js"]
接着要拿到a.js
和b.js
的文件层次依赖,之前我们写了一个require函数,这个函数
可以拿到脚本的代码内容,不过这个require函数要稍微修改下,递归去查询和下载代码。
var cache = {}; var start = function(str){ while(match = r.exec(str)) { console.log(match && match[1]); // 如果匹配到了内容,下载 path 对应的源码 match && match[1] && require(match[1]); } }; var require = function(path){ var xhr = new XMLHttpRequest(), res; xhr.open("GET", path, true); xhr.onreadystatechange = function(){ if(xhr.readyState == 4 && xhr.status == 200){ res = xhr.responseText; // 缓存文件 cache[path] = res; // 继续递归匹配 start(res); } } xhr.send(); };
上面的代码已经可以很好地拿到文件递归关系了。
添加事件机制,优化管理代码
但是我们有必要先把 responseText 缓存起来,如果不缓存文件,直接 eval 得到的 responseText 代码,想想会发生什么问题~ 如果模块之间存在循环引用,如:
┌> a.js -> b.js index.html -| └> b.js -> a.js
那start和require将会陷入死循环,不断的加载代码。所以我们需要先拿到依赖关系, 然后解构关系,分析出我们需要加载哪些模块。值得注意的是,我们必须按照加载的顺序去 eval代码,如果a依赖b,先去执行a的话,一定会报错!
有两个问题我纠结了半天,上面的请求方式,何时会结束?用什么方式去记录文件依赖关系 ?
最后还是决定将start和require两个函数的相互递归修改成一个函数的递归。用一个对象, 发起请求时把URL作为key,在这个对象里保存XHR对象,XHR 对象请求完成后,把抓取到的 新请求再用同样的方式放入这个对象中,同时从这个对象中把自己删除掉,然后判断这个 对象上是否存在key,如果存在说明还有XHR对象没完成。
var r = /require\(\s*"(.*)"\s*\)/g; var cache = {}; // 文件缓存 var relation = []; // 依赖过程控制 var obj = {}; // xhr 管理对象 //辅助函数,获取键值数组 Object.keys = Object.keys || function(obj){ var a = []; for(a[a.length] in obj); return a ; }; // 入口函数 function start(str){ while(match = r.exec(str)){ obj[match[1]] = new XMLHttpRequest(); require(obj[match[1]], match[1]); } } // 递归请求 var require = function(xhr, path){ //记录依赖过程 relation.push(path); xhr.open("GET", path, true); xhr.onreadystatechange = function(){ if(xhr.readyState == 4 && xhr.status == 200){ var res = xhr.responseText; // 缓存文件 cache[path] = res; // 从xhr对象管理器中删除已经加载完毕的函数 delete obj[path]; // 如果obj为空则触发 allLoad 事件 Object.keys(obj).length == 0 ? Event.trigger("allLoad") : void 0; //递归条件 while(match = r.exec(res)){ obj[match[1]] = new XMLHttpRequest(); require(obj[match[1]], match[1]); } } } xhr.send(); };
上面的代码已经基本完成了文件依赖分析,文件的加载和缓存工作了,我写了一个,有兴趣 可以看一看。这个demo的文件结构为:
project/ ├── js/ │ ├── require.js │ └── test/ │ ├── a.js │ ├── b.js │ ├── c.js │ ├── d.js │ └── e.js └── index.html
文件依赖关系为:
┌> a.js -> c.js index.html -| | ┌> d.js └> b.js ->-| └> e.js
Demo程序的位置:../code/js.module
。注意我们是通过Http异步请求加载的模块,所以
不能用file协议打开文件,要在文件目录下启动Http服务器。比较方便的方法是用:
python -m SimpleHTTPServer 8080
CMD 规范的介绍
上面写了一大堆内容,也实现了模块加载器的原型,但是放在实际应用中,他就是个废品, 回到最开始,我们为什么要使用模块化加载。目的是为了不去使用麻烦的命名空间,把复杂 的模块依赖交给 require 这个函数去管理,但实际上呢,上面拿到的所有模块都是暴露在 全局变量中的,也就是说,如果 a.js 和 b.js 中存在命名相同的变量,后者将会覆盖前者 ,这是我们不愿意看到的。为了处理此类问题,我们有必要把所有的模块都放到一个闭包中 ,这样一来,只要不使用 window.vars 命名,闭包之间的变量是不会相互影响的。我们 可以使用自己的方式去管理代码,不过有人已经研究处理一套标准,而且是全球统一,那就 拿着用吧~
关于 CMD 规范,我这里就不多说了,可以去看看草案,玉伯也翻译了一份。 https://github.com/seajs/seajs/issues/242
每一模块有且仅有一个对外公开的接口 exports,如:
define(function(require, exports) { // 对外提供 foo 属性 exports.foo = 'bar'; // 对外提供 doSomething 方法 exports.doSomething = function() {}; });
剩下的工作就是针对 CMD 规范写一套符合标准的代码接口,这个比较琐碎,就不写了。
额外的话题
上面的代码中提到了关于 Event 的事件管理。在模块全部加在完毕之后,需要有个东西 告诉你,所以顺手写了一个 Event 的事件管理器。
// Event var Event = {}; Event.events = []; Event.on = function(evt, func){ for(var i = 0; i < Event.events.length; i++){ if(Event.events[i].evt == evt){ Event.events[i].func.push(func); return; } } Event.events.push({ evt: evt, func: [func] }); }; Event.trigger = function(evt){ for(var i = 0; i < Event.events.length; i++){ if(Event.events[i].evt == evt){ for(var j = 0; j < Event.events[i].func.length; j++){ Event.events[i].func[j](); } return; } } }; Event.off = function(evt){ for(var i = 0; i < Event.events.length; i++){ Event.events.splice(i, 1); } };
我觉得 seajs 是一个很不错的模块加载器,如果感兴趣,可以去看看他的源码实现,代码 不长,只有一千多行。模块的加载它采用的是创建文本节点,让文档去加载模块,实时查看 状态为 interactive 的 script 标签,如果处于交互状态就拿到他的代码,接着删除节点 。当节点数目为 0 的时候,加载工作完成。
本文没有考虑 css 文件的加载问题,我们可以把它当做一个没有 require 关键词的 js 文件,或者把它匹配出来之后另作处理,因为他是不可能存在模块依赖关系的。
然后就是很多很多细节,本文的目的并不是写一个类似 seajs 的模块管理工具,只是稍微 说几句自己对这玩意儿的看法,如果说的有错,请多多吐槽!
AMD规范
CommonJS 提出了一种用于同步或异步动态加载JavaScript代码的API规范,非常简单却很 优雅,称之为AMD( Modules/AsynchronousDefinition )。RequireJS和NodeJS的Nodules已经实现了这个API,而Dojo也将马上完全支持( Dojo1.6)。规范本身非常简单,甚至只包含了一个API:
define([module-name?], [array-of-dependencies?], [module-factory-or-object]);
通过参数的排列组合,这个简单的API可以从容应对各种各样的应用场景,如下所述。
匿名模块
在这种场景下,无需输入模块名,即省略第一个参数,仅包含后两个参数:依赖模块的列表 以及回调函数,例如一个简单的匿名模块可以用如下代码定义:
define(["math"], function(math){ return { addTen: function(x){ return math.add(x, 10); } }; });
在这里,第一个参数表示依赖的模块列表,即math模块。一旦所有依赖的模块被载入完成, 那么第三个参数定义的回调函数将被执行,依赖模块的引用作为参数传递给回调函数。
如例子中所示,如果模块名被省略不写,那么这是一个匿名模块。通过这种强大的方式, 模块的源代码与它的标识可以做到不相关。从而可以在不改变模块代码的情况下移动源码 文件的位置。这个技术遵循了基本的DRY(Don't Repeat Yourself)原则,避免了模块标识 的多次存储(文件名/路径信息不会在代码中重复)。这不仅使得模块的开发变得更加容易 ,而且为模块的重用提供了极大的灵活性。
下面我们看如何从一个Web页面载入这个模块。我们假设上面的模块存储在文件adder.js中 。使用RequireJS,我们可以用下面方式来载入这个模块:
<script src="require.js"></script> <script> require(["adder"], function(adder){ // ready to use adder }); </script>
一旦代码被执行,RequireJS将会自动去调用adder模块所有的依赖模块。载入完毕之后, 我们就可以通过回调函数的adder参数来使用前面定义的匿名模块。例子中可以看到, adder.js里存储的是定义的匿名模块,实际上我们可以用任何文件/路径来包含这个模块, 为模块的重用提供了方便(Java中的文件名/路径和类名/包的必须一致性实际上就为类级别 的重用造成了不便)。require函数用于载入任何一个模块,后面将多次使用。
对于匿名模块的使用有一些注意事项。比如每个文件中只能包含一个匿名模块,而且匿名 模块只能被载入器载入,即只能用require来载入。也可以这么理解,实际上匿名模块 并不是没有名字,而是在使用时进行命名的模块,例子中就是adder。
数据封装:新的JSON-P
对于一些仅仅提供数据或者独立方法(不依赖于其它模块的方法)的模块,可以简单的用 如下方式来定义:
define({ name:"some data" });
这个和JSON-P非常像,但是却有一个显著的优点:它使得JSON-P数据可以现在静态文件中, 而并不需要动态的回调过程。这也使得内容是可cache的,而且是CDN友好的。
封装CommonJS模块
CommonJS也是一套RIA框架,其中的模块可以通过AMD来进行封装,从而可以用define的方式 很容易的进行异步装载,在这里我们可以省略前2个参数,仅包含回调函数,但回调函数的 第一个参数是require方法,第二个参数是exports对象,它定义了模块本身,回调函数里的 require的使用将被自动进行动态加载。例如:
define(function(require, exports){ //math是标准CommonJS模块: var math = require("math"); exports.addTen = function(x){ return math.add(x, 10); }; });
需要注意这种形式要求模块载入器扫描require函数。require调用必须写成
require("...")
的形式才能被正确识别从而正常工作。这在一些浏览器不能正常工作(
例如默写版本的Opera移动版,以及PS3)。当然,如果在部署前对代码进行了build,这将
完全不成问题。你也可以封装CommonJS模块,并手动的指定依赖,这种方式使得我们也可以
引用CommonJS变量,从而我们可以包含标准的require和exports变量:
define(["require", "exports", "math"], function(require, exports) { // standard CommonJS module: var math = require("math"); exports.addTen = function(x){ return math.add(x, 10); }; });
完整的模块定义
一个完整的模块定义包含了模块名,依赖,以及回调函数。这种形式的优点是模块可以包含 在另外的文件中,或者可以用script标记载入的地址中。这是build工具自动生成的规范 模式,使得多个依赖可以被打包在同一个文件中,这种格式的例子如下:
define("adder", ["math"], function(math){ return { addTen: function(x){ return math.add(x, 10); } }; });
最后,我们来看有模块id,但没有模块依赖的情况。这种情况用于你想指定模块id,但是
这个模块不依赖于其它模块。这时的参数默认是require
、exports
和module
。从而
我们可以这样创建adder模块。
define("adder", function(require, exports){ exports.addTen = function(x){ return x + 10; }; });
通过这种方式定义的模块可以被RequireJS载入,也可以作为其它模块的依赖被载入,或者
直接用require()
的形式载入。
综上所述,这种API看似简单,却提供了一种极其灵活的方式来定义模块,适用于各种
应用场景,从可被自由移动的匿名模块,到构建后的可被<script>
标记载入的模块。当前
RequireJS和Dojo实现了这套规范,而JavaScript的Web Server框架NodeJS的Nodules也
实现了这个规范。