万神劫

万物天地为剑,神鬼妖邪为剑
劫波万渡,宇宙苍穹尽为剑
是为万神劫!

19条评论 2013-03-28

为什么 SeaJS 模块的合并这么麻烦

引子

最近看到身边很多同学开始抛弃传统的 <script> 而改用 SeaJS 这样的 JS 模块加载器了,这是件好事,也是一种趋势。
但是任何事物都有两面性,使用模块加载器虽然对于代码的可维护性带来了较大的提升,但是也引入了更多的复杂度,所以肯定会给某些方面带来麻烦——比如这篇文章要探讨的 JS 文件合并。
不少人知道 SeaJS 有个配套的文件压缩合并工具 Spm,可是这个工具似乎一直各种调整、跳票,而且目前版本的使用、配置也很复杂,很多人对此怨声载道,比如我见过有人提 issue 说: “我感觉 SeaJS 非常轻量、好用,可是那个 Spm 怎么搞的那么复杂呢?”
其实 Spm 的复杂,有一部分原因正是由于 SeaJS 造成的,请往下看

传统的 JS 合并

如果采用 <script> 标签的话,JS 合并非常简单,比如

<script src="/js/util.js"></script>
<script src="/js/index.js"></script>
// util.js
function add (a,b) {
  return a + b;
}

// index.js
var c = add(1, 2);
alert(c);

这时候要合并的话,只需要按照 html 上 JS 文件的引入顺序,将相应的文件拼合即可,这一步甚至可以通过一些工具来自动化实现,比如启用 nginx 的 concat 模块后可以这样

<script src="/js/??util.js,index.js"></script>
// 合并后的 JS
function add(a,b) {
  return a + b;
}
var c = add(1,2);
alert(c);

使用 SeaJS 后的合并

上面的例子改用 SeaJS 的话会是这样

<script src="/js/sea.js"></script>
<script>
seajs.use("/js/index");
</script>
// util.js
define(function (require, exports) {
    exports.add = function (a, b) {
        return a + b;
    };
});

// index.js
define(function (require) {
    var util = require('./util');
    var c = util.add(1, 2);
    alert(c);
});

这时候如果要做 JS 合并的话,该怎么弄呢?
很多人会觉得这有什么难搞的,就跟传统的方式一样呗,而且更简单了,因为我们都不用手工指定该怎么合并,只要通过对 index.js 的内容进行分析就可以了,有 require 关键词嘛

// 合并后的 JS,替换原来的 index.js
define(function (require, exports) {
    exports.add = function (a, b) {
        return a + b;
    };
});
define(function (require) {
    var util = require('./util');
    var c = util.add(1, 2);
    alert(c);
});

现在请问,上面这个合并后的 index.js 如果通过 seajs.use 加载进来的话能正常执行吗?
答案是,在 seajs 1.3.1 版本下,可以正常执行,但是如果抓包的话,会发现浏览器在加载了这个合并后的 index.js 之后,还是会再加载同目录下的 util.js
所以如果你把这个新的 index.js 换个目录存放并且相应修改 seajs.use 的模块路径,那么会发现这个页面没能蹦出预期中的 “3”,因为那个新的目录下没有 util.js

也就是说,这个合并的策略并不能奏效

CMD 规范

这是为啥呢?其实也很简单,翻开 SeaJS 的 CMD 规范 ,开头就说了:一个模块就是一个文件。
换句话说,一个文件里面只能定义一个 CMD 模块,而刚才那个文件里面定义了两个,所以出现异常也不奇怪了。
再来分析一下刚才的例子,我们发现,当一个文件出现了多个 CMD 模块时,其实只有最后那个被 SeaJS 识别了,所以执行时依然需要去再加载 util.js 这个文件。
如果继续深入 SeaJS 源码的话,就知道,CMD 模块其实是“匿名”模块,也就是说开发者没有显式地指定该模块的 id,对于匿名的模块,SeaJS 会用这个 JS 文件的 URL 作为它的 id ,并缓存 id 与 模块之间的关系(你可以理解为“识别”)。
所以只有最后一个定义的 CMD 模块会被识别,因为前面定义的模块都被它覆盖了

Transport 格式

如果 SeaJS 只支持 CMD 模块的话,我们就没法实现 JS 文件的合并了,所以其实 SeaJS 还支持一种 Transport 格式
建议看看玉伯在知乎上的这个回答:CommonJS 的 Modules/Transport 和 Modules/Wrappings 规范有什么区别?
我摘录一下重点

SeaJS 里,推崇的 Modules/Wrappings 规范是 CMD 规范:define(function(){ })
直接是由开发者手写的,写完后,可直接不经过任何构建工具就在浏览器上加载运行。
但 CMD 模块在正式上线前,依旧需要通过构建工具先转换为 Modules/Transport 格式:

define("id", ["dep-1", "dep-2"], function(require, exports, module) {
  // source code
})

转换成 Transport 格式后,才能进一步压缩、合并等。

可以看到,Transport 格式其实就是加上了名字的 CMD 模块,SeaJS 在遇到这种模块时就直接通过定义的 id 来缓存模块了
看到这里,你可能会想,这步转换也没啥难的嘛,我们给文件里面两个模块分别加上 id 就 OK 了啊,比如分别叫做 util 和 index

define('util', [], function (require, exports) {
    exports.add = function (a, b) {
        return a + b;
    };
});
define('index', [], function (require) {
    var util = require('./util');
    var c = util.add(1, 2);
    alert(c);
});

实验一下你会发现,浏览器不会再发起对 util.js 的请求了,但是页面也没有蹦出“3”
这又是为啥呢?

firstModuleInPackage

问题好像越来越复杂,但是从 SeaJS 的角度来想,其实很简单:
当调用 seajs.use('/js/index') 时,如果对应的 JS 文件中有两个 Transport 格式的模块,哪一个模块才是调用者想要的(哪个才是 js/index)?
答案相信大家都能想到,根据模块的 id 呗!SeaJS 也正是这么做的,它会比较模块的 id 与 use() 方法的参数(其实是相应 JS 文件的 URL),选用匹配的那个

可是这个“匹配”的规则该怎么定义呢?比如设想这样一个稍微复杂的情况:
util.js 文件中的两个模块 id 分别叫做 text/util 和 util ,当调用 seajs.use('/js/util') 时,究竟哪个模块才是我们需要的呢?
我想这时候大家都会想到这个万无一失的方案:把模块 ID 转换为完整的 URL 再匹配!没错,这也是 SeaJS 的做法:
将所有的模块 id 都转为完整的 URL ,然后选取与当前这个 JS 文件的 URL 完全匹配的那个模块
转换的规则就不细说了,不过看到这里我们大概知道上一步为啥有问题了,因为上一步的 id 只是取了个名字,完全没有考虑 URL ,我们略作修改

//  http://localhost/js/index.js 的内容
define('http://localhost/js/util', [], function (require, exports) {
    exports.add = function (a, b) {
        return a + b;
    };
});
define('http://localhost/js/index', [], function (require) {
    var util = require('./util');
    var c = util.add(1, 2);
    alert(c);
});

id 直接用完整的 URL ,这样都不需要转换,这时候执行一下,总算 OK 了
那么上一个例子里面是什么情况呢?这涉及到 SeaJS 中的 firstModuleInPackage 策略
简单来说,上面例子中,所有模块的 id 都与当前 JS 的 URL 不匹配,这时候 SeaJS 会使用文件中第一个模块,所以实际上页面中只是执行了那个 add 方法的定义
关于 firstModuleInPackage 可以看看这篇讨论,这个特性是 1.2.1 引入的,但是玉伯又决定在 2.0 里面去掉这个特性,改为如果没有匹配时不执行任何模块

结语

看到这里,相信大家应该大致了解 SeaJS 文件该如何合并了,它相比传统方式的 JS 合并要复杂许多,原因也不难理解
一是 SeaJS 引入了额外的复杂度,原来简单的文件合并方式不会奏效
二来 SeaJS 相比 RequireJS 简化了模块书写,导致合并时需要做模块格式的转换,比如自动加上 id

SeaJS 虽好,但是不可能没有缺点,当你在一个方面获得巨大的好处时,通常会在其他方面付出代价,所以我们在做选择时一定要做好权衡。
另外,希望这篇文章能让大家更理解 SeaJS 与 Spm,事情没有想象中的那么简单,少一些抱怨,多一些建设性意见!

comments powered by Disqus