Seaport,极致之后再进一步

时间:2015-8-9 作者:剧中人

小白的seaJS之路

小剧客栈一直使用Sea.js实现模块化。开发模式下碎片化加载各个资源,极大的方便了代码的调试,无需过多顾虑模块的增删。

本地开发模式下这样处理固然效率很高,然而在发布上线之后,若再以碎片化文件加载模块,大量的并发数会明显拖慢页面加载速度。因此绝大多数同学会选择使用spm、grunt或gulp等工具来合并碎片资源。

显然,在页面碎片化资源体积的总和并不是很大的情况下,这样的好处极为明显,因为JS资源变成两个,一个Sea.js文件,起着大总管的作用,一个合并后的文件。

聪明的同学们可能会发现,项目里可能会有一些基础类库,如jquery,如果一股脑的合并起来,每次发布新版本都会重新加载这部分代码,而这部分代码一般是固化不会被修改的。于是优化后文件结构变成了另外两个打包后的文件,一个是基础类库 + 大总管Sea.js,另一个则是项目开发的碎片文件总和。既避合理利用了浏览器的缓存,又能相对高效的处理版本的更替。

这样子就够了么?

相信做到这样,项目已经比较令人满意的运行了。此时引发了小剧去思考另一个问题。线上的Sea.js 起到的作用是什么?

A Module Loader for Web。

Sea.js 官网是这样介绍自己的,整理下来就是模块管理、模块加载。

然而在一个模块已经完全合并的项目里,模块加载这部分功能本质上已经完全是冗余的。有什么方式做改进么?

既然模块加载已经不需要了,那我们是不是只要实现模块管理这一部分就可以替代Sea.js在项目中的作用了呢?

貌似是可以的,那我们研究一下经过抽出(transport)、合并之后的代码长什么样子。下面是两个模块的例子。

//Module navigation
define("public/js/navigation", [], function(require, exports,module) {
    //......
    return nav;
});
//Module page blog
define("public/js/blogList", [ "public/js/stick", "public/js/tie" ], function(require, exports,module) {
    //......
    exports.page = page;
});
//Module pagination
define("public/js/pagination", [], function(require, exports,module) {
    //......
    module.exports= pagination;
});

经过分析会发现,每个标准的Sea.js模块都被转化为上面这种格式,每个模块皆是调用define来实现自身定义。

抡起大锤造轮子!

Sea.js意为海洋,我给自己这个阉割版的Sea.js取名叫Seaport 即海港。造轮子之前还要搞明白一件事情,就是一个模块是如何被定义的?

Seaport 定义了一个全局的define方法,用来根据模块定义接收到的各个参数完成模块初始化。具体步骤为:

既然发现了模块定义的模式,那么就可以开始实现模块管理的核心部分了。这里主要需要实现这么几点:

1、如何获取模块初始化后的结果?

用过Sea.js的小伙伴了解,模块定义的时候有三种提供模块接口的方式。

根据这三种模块返回方式,即可编写出下面的接收模块初始化结果,id即为path,factory为传入define参数的function。

//缓存模块对象
var modules = {};
/**
 * 初始化模块(依赖全部加载完毕方可执行)
 **/
function initModule (id,factory){
  var module = {
        exports: {}
      },
      returns = factory(require,module.exports,module);
  //优先使用return传递的模块接口
  modules[id] = returns || module.exports;
  emitModuleInit(id);
}

2、如何监听模块初始化事件

每个模块的初始化,都必须延迟到依赖的模块初始化完成,因而需要监听所需模块是否全部初始化完成。

这一步小剧创建了一个事件集moduleInitEvents,用来存储所有的监听模块初始化事件。当有模块初始化完成即调用emitModuleInit方法通知模块已经初始化完成,onceModuleInit用来监听对应模块初始化完成事件。

//模块加载完成回调事件集合
var moduleInitEvents = [];
//注册模块加载完成监听(仅触发一次)
function onceModuleInit(id,callback){
  moduleInitEvents.push([
    id,
    callback
  ]);
}
//通知模块加载完成,并立即删除监听
function emitModuleInit(id,args){
  for(var i = moduleInitEvents.length-1;i!=-1;i--){
    if(moduleInitEvents[i][0] == id){
      moduleInitEvents[i][1].apply(this,args);
      moduleInitEvents.splice(i,1);
    }
  }
}

3、define实现

因为Sea.js加载模块可以省略.js后缀名,为了统一模块名称定义方式,ID一律过滤.js。根据前面分析模块是如何被定义可以把这一步拆解为这么几步:

/**
 * 接收模块定义的主函数
 **/
function define(id,depends,factory){
  id = id.replace(/\.js$/,'');
  var need_load = depends.length;
  //等待依赖加载完毕,初始化模块
  for(var index=depends.length-1;index>=0;index--){
    if(modules[id]){
      need_load--;
    }else{
      onceModuleInit(depends[index],function(){
        need_load--;
        if(need_load == 0){
          initModule(id,factory);
        }
      });
    }
  }
  if(need_load == 0){
    initModule(id,factory);
  }
}

4、require实现

Sea.js中引用模块绝大多数是通过require方法来拿到模块的接口,在Sea.js中可以说是除了define之外最重要的一个方法。然而因为前面几点的实现,这一步变得异常简单,因为我们已经可以直接从缓存中取到模块的接口。

//从缓存中读取模块
function require(id){
  id = id.replace(/\.js$/,'');
  return modules[id] ? modules[id] : null;
}

这样子轮子就能跑起来了?

这样的确已经完成了模块间的相互通信及依赖关系的管理,但是项目一般是通过seajs.use一个主文件,因此Seaport也提供了此接口,只是此方法并不等同于Sea.js里的seajs.use,具体原因下文会说明。

Sea.js中常会通过map配置文件版本,Seaport也增加了对应的支持。项目基础目录base的配置也纳入了进来。

只要项目满足以下要求,Seaport这枚轮子就能圆润的滚起来了。

目测已经可以替代Sea.js了!

错,首先正面回答,Seaport完全替代不了Sea.js。因为Sea.js实际使用过程中,有着很多被我们忽略掉的细节。

那我要你有什么用?

哈哈,就知道你会这样反问,实话说Seaport也只是小剧在小剧客栈上精炼代码走的最后一公里,并不适合所有情况,再把上面这段话摘抄下来,如果你的项目同时满足下面的这些条件,还是可以一试的。


PS:实现Seaport的过程解开了小剧对模块化内部实现的郁积很久的小疑问,同时也了解到了模块化管理的一些精妙之处,如果你对此项目感兴趣,欢迎前来蹂躏小剧的代码:https://github.com/bh-lay/seaportjs