Javascript模块化编程(三):模块化编程实战,试用SeaJS
文章源自:http://www.diguage.com/archives/82.html
Javascript模块化编程(三):模块化编程实战,试用SeaJS
前段时间转载了阮一峰老师的两篇讲解Javascript模块化编程的文章:“JavaScript模块化编程(一):模块原型和理论概念详解”,介绍了Javascript模块原型和理论概念;Javascript模块化编程(二):模块化编程实战,require.js详解,介绍了在实战中,如何利用RequireJS库,进行模块化编程。
在这两篇文章发布出来之后,在和网友的交流讨论中,了解到了SeaJS,这个由国人玉伯自己创建的模块化编程库。然后,我就想学习学习,再写篇文章给大家介绍一下。
背景介绍
官网的资料是最靠谱的。在SeaJS的官网上发现,有一个“5分钟上手SeaJS”的例子,然后就从这个例子的开始学习。不过,只看明白了个六六七七。我没看明白和我平时的JavaScript编程有啥区别。另外,我没有动手实践,心里面不踏实。所以,动手写个程序,玩味一下。后来,就想到了“猜手机号游戏”!
由于官网已经有使用SeaJS的教程,我就不重复这方面的工作了,而且我也觉得我我肯定没有官网写的好。由于我不清楚,使用SeaJS进行“模块化编程”和我平时不进行“模块化编程”的区别。所以,我准备从另外一个角度来介绍SeaJS:将一个没有进行模块化编程的程序,改造成使用SeaJS进行“模块化编程”的程序。由于这个想法的跨度比较大,信息量也比较多。所以我把我的想法组织成了三篇文章:第一篇文章,“给哥三十五次机会,哥就能猜中你的手机号”,通过一个小游戏,来吸引大家的兴趣;第二篇,“‘猜手机号游戏’的源码分析:二分查找+面向对象”,来讲解在没有进行模块化编程时,程序的实现细节;然后,这是第三篇,在没有进行模块化编程的基础上,将原来的程序改造成一个使用SeaJS进行模块化编程的例子。
在阅读这篇文章之前,请阅读前两篇,尤其是“‘猜手机号游戏’的源码分析:二分查找+面向对象”。同时,还建议阅读一下“JavaScript模块化编程(一):模块原型和理论概念详解”和Javascript模块化编程(二):模块化编程实战,require.js详解,规范、系统一下关于Javascript模块化编程的知识。
CMD模块定义规范介绍
想享受模块化编程带来的良好封装,就必须遵循模块化编程的规范。在 SeaJS 中,所有 JavaScript 模块都遵循 CMD(Common Module Definition) 规范。该规范确定了模块的基本书写格式和基本交互方式。所以,使用SeaJS之前,必须阅读一下SeaJS所要求遵循的规范。
鉴于规范覆盖的东西比较多,看多了头大。所以,我把这个规范提炼简化一下,只关注我们需要用到的。至于,更详细的CMD模块定义规范,等先把例子跑通,理解了整个流程,然后再回头看规范,梳理、规范这部分知识。
在介绍简化版规范之前,D瓜哥提两个也许大家都回“纳闷”的问题:
- 如何定义模块?
- 如何获取外部依赖的模块?
CMD模块定义规范中的主要内容正是回答这两个问题。下面请看经D瓜哥简化的规范如下:
- 定义、封装模块的方法。(CMD模块定义规范中有好多定义方法。简单起见,目前只考虑使用如下这一种方式。)如下:
define(function(require, exports, module) { // The module code goes here });
这里需要特别说明一下,向参数传递的三个参数名必须按照代码所示中那样写,不能简写,或者使用其他字符串代替;同时,在函数 内,exports不能被改写成其他值;可以把exports看成对象添加属性,如exports.key,然后对其复制,又如exports.key = “dValue”。
- 对外提供模块接口。在上一步中,我们在函数内定义了模块,但是这是在函数内定义的,在函数外部不容易访问到。该怎么向外提供模块接口呢?定义方法如下:
define(function(require, exports, module) { // 实用这种方式向外提供模块接口 module.exports = { foo: 'bar', doSomething: function() {}; }; // 或者。由于,D瓜哥将模块封装成了一个对象,所以,本例中,使用这个方式。 module.exports = yourFunctionName; });
传给 factory 构造方法(就是define(function(){})方法参数中那个函数,称为factory。函数只是factory的一种形式,其他形式以后再补 充。)的 exports 参数是 module.exports 对象的一个引用。只通过 exports 参数来提供接口,有时无法满足开发者的所有需求。 比如当模块的接口是某个类的实例时,需要通过 module.exports 来实现。D瓜哥这里就是一个对象,所以只能使用module.exports 。
- 获取外部依赖模块。模块定义要了,需要使用的时候,就可以使用require函数获取外部依赖。具体代码如下:
define(function(require, exports, module) { // 使用require函数获取外部依赖 var a = require('./a'); a.doSomething(); });
require函数的参数是a.js文件的相对路径。后缀名可以省略,在SeaJS加载模块的时候会自动加上的。另外,这里可以执行回调函数。不过,我们的任务是跑起来。因为不需要回调函数,所以这部分先略过了。
总结一下:define函数,定义模块;module对象,保存模块信息;require函数,获取外部依赖模块。
看到这里,估计大家还是一头雾水。没关系,慢慢往下看,下面的例子跑起来的时候,你再回头看就会明白的。
模块化改造
先声明一下,下面的改造过程会参考“5分钟入门”的说明。所以,建议大家先看看。当然,一起看也可以。
通过看”5分钟入门”的例子可以看出,SeaJS的目录结构还是有点复杂的。所以,最简单的方法就是,把她的例子下载下来,在她的基础之上修改:“5分钟入门”例子下载。
目录结构
下载完成后,解压到任意目录下。请看一下目录,
- hello-seajs/下放我们的html文件;
- hello-seajs/assets/sea-modules下存放的是我们需要用到的第三方模块块;
- hello-seajs/assets/main,这个目录可以说最重要,是存放我们自己编写的JavaScript和CSS文件的地方。下面还有四个子目录及一个文件:
- src存放正常的代码;
- test存放测试代码;
- docs存放文档;
- examples存放示例代码;
- package.json是打包的配置文件;
“改造”模块代码
下面,我们开始改造我们的模块。
首先,把我GuessNumber.js放到hello-seajs/assets/main/src/下。然后,按照“第1条规范”的要求改 造这个文件中代码。由于整个文件就是GuessNumber对象的定义。同时,这个JavaScript文件又没有引用其他模块。所以,只需要在文件的第 一行增加define,在最后一行增加括号分号就行。具体代码如下:
define(function(require, exports, module){ /** * numberScope 需要猜测的数字范围 */ function GuessNumber(numberScope){ // 为了突出修改的代码,我把一些相同的代码省略了, // 完整代码请看:http://www.diguage.com/archives/80.html } GuessNumber.prototype = { constructor: GuessNumber, // 完整代码请看:http://www.diguage.com/archives/80.html } });
其次,目前我们已经定义为一个模块。但是外部如何访问这个GuessNumber?所以,我们要向外部提供一个接口,提供方式参考“第2条规范”。具体代码见第18行:
define(function(require, exports, module){ /** * numberScope 需要猜测的数字范围 */ function GuessNumber(numberScope){ // 完整代码请看:http://www.diguage.com/archives/80.html } GuessNumber.prototype = { constructor: GuessNumber, // 完整代码请看:http://www.diguage.com/archives/80.html } module.exports = GuessNumber; });
这时,一个接口已经全部定义完成。下面,我们书写调用这个模块的例子。
在“规范”的第三条中,我们说明了加载外部依赖模块的方法,我们只需要按说明照做就行。另外,还需要补充一下模块加载时需要注意的地方。具体请看代码注释:
define(function(require) { // 这是引入jQuery类库,我们下面说明为什么这样下。 var $ = require('jquery'); // 引入GuessNumber模块,也就是GuessNumber.js文件。 // 参数中传递的是GuessNumber.js文件的相对路径。 // .js的后缀名可以省略,SeaJS在加载的时候会自动加上。 var GuessNumber = require("./GuessNumber"); // 完整代码请看:http://www.diguage.com/archives/80.html //格式化显示结果 function formatResult(num, type) { //…… } // …… $("#initButton").click(function(){ guess.start(scopeArr[type].min, scopeArr[type].max); showResult(); }); });
从上面的代码中,可以看出,main.js文件的改造,只是把原来的
$(document).ready(function(){ // 主要的业务代码 });
改造成了,
define(function(require) { // 这是引入jQuery类库,我们下面说明为什么这样下。 var $ = require('jquery'); // 引入GuessNumber模块,也就是GuessNumber.js文件。 // 参数中传递的是GuessNumber.js文件的相对路径。 // .js的后缀名可以省略,SeaJS在加载的时候会自动加上。 var GuessNumber = require("./GuessNumber"); // 和原文件相同的业务代码 });
另外,加了两行倒入必要关联模块的代码。仅此而已。
main.js与GuessNumber.js不同的还有一点,main.js不需要向外提供访问接口。这点也要注意一下。
到这里所有的JavaScript都已经修改完毕了。下面,我们修改一下如何在HTML中的引入方式。
在页面中加载模块
原来的写法是,按顺序使用<scrip>标签把jQuery、GuessNumber.js以及main.js文件引入到HTML 页面中即可。如果使用SeaJS,则需要先加载SeaJS的类库,然后使用JavaScript通过SeaJS的接口来加载所需的模块,也就是模块对应的 JavaScript文件。具体代码如下:
<!-- 首先,首先我们需要引入 sea.js --> <script src="assets/sea-modules/seajs/1.3.1/sea.js"></script> <script type="text/javascript"> seajs.config({ alias: { // 指定使用的jQuery版本以及说明jQuery的路径 // 请注意:这里知名了jQuery的路径,所以,我们 // 在引入jQuery库时,只需要填写jquery即可。 'jquery': 'gallery/jquery/1.8.2/jquery' } }); // 然后SeaJS通过 use 方法来加载模块,以后打包后也是修改这里 // 也许你会疑问为什么不加载GuessNumber.js文件, // 这个在使用require引入依赖时,SeaJS自动加载需要的外部文件 // 另外,这里的.js后缀名也可以省略,SeaJS会自动补全。 seajs.use('./assets/main/src/main'); </script> <!-- 这里只展示了和JavaScript引入相关的代码 --> <!-- 完整代码请看:http://www.diguage.com/archives/80.html 中的HTML代码 -->
到此,改造工作就全部完成了。你可以打开一下inde.html文件,看看效果了。
打包部署
根据“高性能网站的十四条黄金法则”中的实践,我们在实际项目上线时,为了提高页面的加载速度,必定要压缩一下JavaScript文件。这些,SeaJS也考虑到了,甚至做得更好:还做了文件合并。
这里,需要先介绍一下,SPM,一个基于命令行的前端项目管理工具。 SPM 和 SeaJS 关系密切,你甚至可以认为SPM是为SeaJS专门打造的工具。首先,请“安装教程”安装好这个工具。按照过程可能会有一个问题,请参考下面的“出现的问题”。
使用SPM打包,需要修改一下打包的配置文件。配置文件是:hello-seajs/assets/main/package.json。打开后内容如下:
{ "name": "main", "version": "1.0.0", "dependencies": { "jquery": "gallery/jquery/1.8.2/jquery" }, "root": "hello-seajs", "output": { "main.js": ".", "main.css": "." }, "spmConfig": { "build": { "to": "../sea-modules/{{root}}/{{name}}/{{version}}" } } }
不过,这个需要根据我们的实际情况来修改。root属性,由于我们的模块是“猜数”,所以将其修改为GuessNumber;output属性,我们只需 要输出JS,所以删除main.css。另外,需要注意,第十四行,这个是打包后的输出路径。好了,开始打包。打包需要执行如下指令:
$ cd hello-seajs/assets/main $ spm build ... BUILD SUCCESS! $
打包结束后,在hello-seajs/assets/中就会发现多了一个GuessNumber文件夹,那个就是打包输出出来。
这里说明一下:D瓜哥只在Linux下执行了这么命令。不知在Windows是否好使。为了方便大家测试运行,打包结果已提交,下载的代码中包含打包结果。
观察这个结果,大家会发现只有一个main.js和main-debug.js;顾名思义,main.js是用于生产部署的,经过压缩的文 件;main-debug.js是为测试使用的,只是合并了代码并没有压缩,使用的时候直接引用这个两个文件中的一个就行,直接把seajs.use() 中的路径改一下就OK。GuessNumber.js哪里去了啊?大家可以打开main-debug.js看看(main.js也行,只是压缩过来,可读 性不好),原来,GuessNumber.js已经合并到了main.js中了。SPM把两个文件合并成一个文件了,这样在浏览器访问网页时,就可以减少 一个HTTP请求,提高网页的加载速度。
另外,大家也可能会注意到在原来main.js中定义的define()函数,在新的main.js有了一些变化,多了两个参数:第一个参数模 块的ID,主要是为了方便区别一个文件中的各个模块;第二个参数是模块依赖的外部模块的路径,因为依赖的模块可能有多个,所以这个参数是一个数组。第三个 参数是原来的function,也就是factory。更详细的解释请看:为什么要用 spm 来压缩 CMD 模块?
懒人要把懒进行到底!打包后还要修改SeaJS的加载路径,这点其实还可以使用如下代码来避免:
// 这个路径只有在部署到服务器上才行,直接打开文件不好使。 seajs.use(location.host === 'localhost' ? './assets/main/src/main' : 'GuessNumber/main/1.0.0/main');
如果是非静态页面,也可以使用变量来配置。
折腾中出现的问题
折腾这么个玩意,难免出现一些问题,D瓜哥遇到了三个问题。这些问题主要集中在SPM环境搭建过程中。给大家分享一下。
第一个问题:按照seajs时,提示info.json不存在的错误。终端显示如下:
d@dPC:~/Dev/hello-seajs/assets$ spm install seajs Start installing ... success create global config.json to /home/d/.spm Downloading: http://modules.spmjs.org/info.json [ERROR] Caught exception: Error: not found config http://modules.spmjs.org/info.json
大家可以在浏览器地址中打开http://modules.spmjs.org/info.json,会发现可以打开。这是怎么回事呢?
我查阅了一下SeaJS论坛,里面有类似的问题。其中的一个回复,我拿过来当作解答吧:这段时间是举国同庆的日子,网络不稳定。至于原因,你懂得。估计等过了这段时间就没事了。所以,既然浏览器可以访问,则内容就可以访问到。遇到这个问题,多试两次就可以了。
第二个问题:按照jquery库时,提示Error: ALREADY_EXISTS。终端显示如下:
d@dPC:~/Dev/hello-seajs/assets$ spm install gallery.jquery Start installing ... Downloading: http://modules.spmjs.org/gallery/info.json Downloaded: http://modules.spmjs.org/gallery/info.json Downloading: http://modules.spmjs.org/gallery/jquery/1.8.2/jquery.tgz Downloaded: http://modules.spmjs.org/gallery/jquery/1.8.2/jquery.tgz ** This module already exists: /home/d/Dev/hello-seajs/assets/sea-modules/gallery/jquery/1.8.2 Turn on --force option if you want to override it. [ERROR] Caught exception: Error: ALREADY_EXISTS
其实,问题正如反馈信息所示,jQuery库已经存在,不需要再次下载了。我们在hello-sea这里例子的源代码中构建,这个源代码中已经包含了jQuery了,在这里这步可以忽略。
第三个问题:修改了package.json后,重新编译报错。终端显示如下:
[WARN] http://modules.spmjs.org/GuessNumber/config.json null
这个不影响编译,直接忽略就行了。另外说明一下,在第一次打包时,没见这个错误;第二次会出现。
代码下载
为了方便大家下载代码,我把代码托管到了Github上,大家可以去Github上下载、提交您的修改。Github页面:GuessNumber;不想去Github上下载的,也可以直接点击下载:点击下载。
深入学习
上面的例子只是简要把一个例子跑起来了,给大家一个比较形象的认知。但是,这个例子实在是太简单了。我还需补充我们刚才为了易于理解而简化的一些知识。为了更深入的了解SeaJS,请继续阅读“SeaJS 使用文档”。另外,这里有几个需要重点阅读,具体如下:
把这个列表中的东西看完,SeaJS的学习应该就可以出师了。有好的资料请给我推荐,我再补充上来。
遗留问题
经过上面这些折腾,我们已经成功运行起来一个使用SeaJS进行模块化编程的例子。但是,我们还是有很多的疑问。具体疑问如下:
- D瓜哥在main.js中,并没有使用$(document).ready();等DOM加载完再运行,并也没有讲JS放到HTML文件的最后,为啥还能顺序执行呢?莫非SeaJS有什么内部机制,保证在DOM加载完成后再执行我们自己编写的JavaScript代码?
- 这里例子很小,并没有很多很多的模块。在模块很多的情况下,如果组织模块?这个还需要写更多的例子,实验一下。
- 同样,在很多模块的情况下,难道要建很多目录准备很多的main.js,让众多的HTML分别加载吗?
刚刚D瓜哥开窍了一下,main.js只是一个例子,可以根据自己的组件名称命名,然后在组件中加载相对应的JavaScript文件即可。另 外,在配置package.json时,突然觉得,在/assets/main/src/下每个目录应该算是一个模块,都有一个打包的配置文件 package.json,用于配置该模块的必要信息。不知这样理解是否正确?这个还有待考证。