论坛首页 Web前端技术论坛

AMD规范:简单而优雅的动态载入JavaScript代码

浏览 23638 次
精华帖 (0) :: 良好帖 (18) :: 新手帖 (0) :: 隐藏帖 (0)
作者 正文
   发表时间:2010-12-15  

JavaScript代码的动态载入一直是八仙过海,各显神通,每个框架都有自己的做法。或者动态插入script标记,或者通过XMLHttpRequest获取后eval执行。AMD规范定义了一种非常简单的API,用于此目的,个人以为很好很强大,翻译过来与大家分享。

 

本文翻译自http://www.sitepen.com/blog/2010/11/04/requirejsamd-module-forms/,并加入部分自己的解释。

 

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也实现了这个规范。

   发表时间:2010-12-15  
这种方式的依赖定义使用很简单,但如果依赖有多层,放在前端就会造成串联请求(只有请求到JS文件后才知道它又依赖哪些JS文件),对于规模大、引用关系复杂的系统来说就不是很合适了。

想要一次性获取所有需要加载的依赖JS,只能将依赖信息独立出来,全部存到前端。这种定义方法将依赖信息写到了代码逻辑中,而不是相对独立的声明,使得提取它的静态依赖也比较麻烦。(一个文件中可能有多个依赖语句,参数也可能是动态计算的,从而很难提取)

总之,没有万能的依赖管理方式,还是要看项目特点从简单与速度之间取舍(动态加载执行机制倒都差不多: ajax + script)
0 请登录后投票
   发表时间:2010-12-15  
clue 写道
这种方式的依赖定义使用很简单,但如果依赖有多层,放在前端就会造成串联请求(只有请求到JS文件后才知道它又依赖哪些JS文件),对于规模大、引用关系复杂的系统来说就不是很合适了。

想要一次性获取所有需要加载的依赖JS,只能将依赖信息独立出来,全部存到前端。这种定义方法将依赖信息写到了代码逻辑中,而不是相对独立的声明,使得提取它的静态依赖也比较麻烦。(一个文件中可能有多个依赖语句,参数也可能是动态计算的,从而很难提取)

总之,没有万能的依赖管理方式,还是要看项目特点从简单与速度之间取舍(动态加载执行机制倒都差不多: ajax + script)


文中提到了build工具,就是用于大型项目的。build工具会递归扫描所有依赖,并将它们打包到一个文件。从而解决你说的问题。dojo本身就自带了build工具。
0 请登录后投票
   发表时间:2010-12-15  
dojotoolkit 写道

文中提到了build工具,就是用于大型项目的。build工具会递归扫描所有依赖,并将它们打包到一个文件。从而解决你说的问题。dojo本身就自带了build工具。

并没有解决我说的问题,我的基本要求是按需加载,打包前合并必定不可能做到完全按需,有些公用模块已经加载过了,合并的文件要么有重复,要么就包含了不必要的东西。

这几种情况如何打包?
1. 动态参数(先假定JS模块名必须和路径匹配,即define第一个参数是取决于JS文件路径)
var deps = [];
deps.push("b");
// ...
define("a", deps, function(){});

// 或者
var module = xxxx;
define("a", [module], ...

以上情况属于接口过于灵活导致我前面说的难以静态解析的问题,可以通过规范约定只能用常量,但毕竟因为本身就是JS语法,提取还是存在难度。如果不是用的Dojo,没有现成的工具提取,那真的会很麻烦。

2. 公用但不常用文件:
define("大模块1", ["a"], ...

define("大模块2", ["a"], ...

// 难道两个大模块都合并起来?分开的话a就有重复。合并的话,如果关系再复杂点,岂不是整个项目都合并了?

这就是我说的为什么合并文件做不到完全按需加载
P.S. 这点我记得以前和你争论过,谁也说服不了谁


上个回复只是补充,并不是想否定这种方式。
我提的方式是,用工具解析依赖数据,交给前端用于计算出需要加载的文件,然后一次性加载过来(复杂,不重复,并行加载)
原文中define函数最简单的原生的加载方式是递归请求(简单,不重复,串行加载)
你回帖提的build是发布前直接合并(dojo内置,简单,粒度不好控制)
各有各的特点
0 请登录后投票
   发表时间:2010-12-15  
呵呵,你说的没错。完全灵活的动态一次加载,理论上是n!种组合,确实无需build,也无法build。但我相信一定程度的冗余带来的好处也许多出多下载几k几十k代码的体积的坏处。比如一个10k的dialog在50%情况下会被用户打开,那么我就会让它包含在初始加载中。比如Gmail在打开时就加载了1MB的代码,也没法做到完全按需又不影响性能。

1.动态参数
这一点build规范明确提出不支持。无法打包。

2. 公用但不常用文件
这看上去是个build阶段的问题,但个人以为更大程度上是设计阶段的问题。当然是在一定程度上可以在设计阶段解决,而没法完全解决。

关于你提出的完全按需加载,按找我的理解,有几个问题需要考虑:
1. 需要build过程为每个文件保存所有依赖文件的按序一维列表(请求时扫描代价较大)
2. 每次动态请求需要告诉服务器当前客户端所有文件的完整列表。
为什么没法前端计算,因为有可能依赖的文件还没有被加载。你不知道被依赖的文件依赖于哪些文件。
如果完整的依赖数据交给前端,这个数据本身的代码会比较大,多个文件依赖于A,A就会被存储多次。
3. 服务器需要读取所有文件内容并合并为一个文件。
4. 客户端开始加载合并得到的文件。

关于define和build。可能我没解释清楚。define用于开发调试阶段,build用于部署阶段。build工具会扫描所有的define语句来整理依赖关系,从而打包成一个文件。

如果我们回到按需加载的初衷。那是为了提高初次载入速度,所以我们需要权衡按需加载带来的复杂度和减少的初次加载时间之间的矛盾。对很多应用而言可以用进度条来提升用户体验。
另外,如果在用户的一次正常使用下,按需加载了10次以上的代码。我个人会认为很多按需是不必要的,因为他延长了用户可察觉的总等待时间。

PS:我没认为在争论什么。。Just discussion

clue 写道
dojotoolkit 写道

文中提到了build工具,就是用于大型项目的。build工具会递归扫描所有依赖,并将它们打包到一个文件。从而解决你说的问题。dojo本身就自带了build工具。

并没有解决我说的问题,我的基本要求是按需加载,打包前合并必定不可能做到完全按需,有些公用模块已经加载过了,合并的文件要么有重复,要么就包含了不必要的东西。

这几种情况如何打包?
1. 动态参数(先假定JS模块名必须和路径匹配,即define第一个参数是取决于JS文件路径)
var deps = [];
deps.push("b");
// ...
define("a", deps, function(){});

// 或者
var module = xxxx;
define("a", [module], ...

以上情况属于接口过于灵活导致我前面说的难以静态解析的问题,可以通过规范约定只能用常量,但毕竟因为本身就是JS语法,提取还是存在难度。如果不是用的Dojo,没有现成的工具提取,那真的会很麻烦。

2. 公用但不常用文件:
define("大模块1", ["a"], ...

define("大模块2", ["a"], ...

// 难道两个大模块都合并起来?分开的话a就有重复。合并的话,如果关系再复杂点,岂不是整个项目都合并了?

这就是我说的为什么合并文件做不到完全按需加载
P.S. 这点我记得以前和你争论过,谁也说服不了谁


上个回复只是补充,并不是想否定这种方式。
我提的方式是,用工具解析依赖数据,交给前端用于计算出需要加载的文件,然后一次性加载过来(复杂,不重复,并行加载)
原文中define函数最简单的原生的加载方式是递归请求(简单,不重复,串行加载)
你回帖提的build是发布前直接合并(dojo内置,简单,粒度不好控制)
各有各的特点

0 请登录后投票
   发表时间:2010-12-15  
1. 依赖可以独立开定义,另可以在发布时提取合并,并且因为格式明确独立,很容易处理。

2. 所有的依赖数据也不会很多,它是线性增长的。
就以平均一个模块5条依赖,自身有200行代码算,1000模块/20W行规模的项目依赖数据也只有5000条,以每条20字符共计也只有100K。
并且它是纯数据对象,解析几乎不花什么时间。
对一个模块取未加载的依赖模块列表,时间在数毫秒级。

3. 服务器合并文件速度也非常快,本地读取嘛... 相信比访问一次论坛查询数据库花的时间更少
0 请登录后投票
   发表时间:2010-12-15  
内容太高端了

得慢慢阅读

感谢无私翻译
0 请登录后投票
   发表时间:2010-12-15   最后修改:2010-12-15
这种方式本质上与“JavaScript代码的动态载入一直是八仙过海,各显神通,每个框架都有自己的做法。或者动态插入script标记,或者通过XMLHttpRequest获取后eval执行。”有何区别?
最终还是需要载入的,没觉得比直接引入<script>有什么高明的地方,反而还多了一个无用的规范,还混淆了本来该关注的代码,开发的不爽,维护的更累,无疑是在增加项目成本.
0 请登录后投票
   发表时间:2010-12-15  
depravedangel 写道
这种方式本质上与“JavaScript代码的动态载入一直是八仙过海,各显神通,每个框架都有自己的做法。或者动态插入script标记,或者通过XMLHttpRequest获取后eval执行。”有何区别?
最终还是需要载入的,没觉得比直接引入<script>有什么高明的地方,反而还多了一个无用的规范,还混淆了本来该关注的代码,开发的不爽,维护的更累,无疑是在增加项目成本.

如果有个项目,总共JS文件超过100个,你也静态script引入?
0 请登录后投票
   发表时间:2010-12-15  
1. 发布时提取意味着没法动态参数载入。。也就是你先提到的问题。
2. 如果有一个小页面只需要3个模块,共50k,也需载入完整列表。
3. 这个性能确实也许可以忽略。。不太清楚


clue 写道
1. 依赖可以独立开定义,另可以在发布时提取合并,并且因为格式明确独立,很容易处理。

2. 所有的依赖数据也不会很多,它是线性增长的。
就以平均一个模块5条依赖,自身有200行代码算,1000模块/20W行规模的项目依赖数据也只有5000条,以每条20字符共计也只有100K。
并且它是纯数据对象,解析几乎不花什么时间。
对一个模块取未加载的依赖模块列表,时间在数毫秒级。

3. 服务器合并文件速度也非常快,本地读取嘛... 相信比访问一次论坛查询数据库花的时间更少

0 请登录后投票
论坛首页 Web前端技术版

跳转论坛:
Global site tag (gtag.js) - Google Analytics