Jade Dungeon

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>

其缺点是:

  1. 加载和解析是顺序是同步执行的,先下载base.js然后解析和执行, 然后再下载app.js
  2. 加载脚本时还会阻塞对<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之前执行,如果它们之间有依赖关系这将 导致错误。

讲到这里从开发者角度来看我们其实需要的是这些特性:

  1. 异步下载,不要阻塞DOM的渲染;
  2. 按照模块的依赖关系解析和执行脚本。

所以脚本的加载其实需要与模块化编程问题结合起来解决。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.jsb.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,但是 这个模块不依赖于其它模块。这时的参数默认是requireexportsmodule。从而 我们可以这样创建adder模块。

define("adder", function(require, exports){
  exports.addTen = function(x){
      return x + 10;
  };
}); 

通过这种方式定义的模块可以被RequireJS载入,也可以作为其它模块的依赖被载入,或者 直接用require()的形式载入。

综上所述,这种API看似简单,却提供了一种极其灵活的方式来定义模块,适用于各种 应用场景,从可被自由移动的匿名模块,到构建后的可被<script>标记载入的模块。当前 RequireJS和Dojo实现了这套规范,而JavaScript的Web Server框架NodeJS的Nodules也 实现了这个规范。