4.1 什么是模块化?(What)
我们先来看这样一个问题:
假设有一个module1.js文件里面提供了一个方法:
//module1.js
function foo(){
console.log("bar1");
}
但是由于实现的需要,我们必须引入另外一个文件module2.js,其中恰好也包含了一个函数foo的实现
//module2.js
function foo(){
console.log("bar2");
}
调用的时候,我们会先引入2个js文件然后直接执行foo方法:
//调用代码
foo();
那么当我们同时引用这两个文件的时候,foo函数的输出就取决于两个文件的加载顺序,这样就出现了命名冲突的情况,为了解决这样的问题,我们可能想到了通过封装成对象的方式来对每个模块加上命名空间:
//module1.js
var module1={
foo:function(){
console.log("bar1");
}
}
//module2.js
var module2={
foo=function(){
console.log("bar2");
}
}
这样我们调用的时候就可以分别使用module1和module2作为命名空间来隔离不同的foo函数
//调用代码
module1.foo();
module2.foo();
通过上述方式,我们定义了命名空间,对相同命名的函数起到了隔离作用。这就是模块化思想中的第一部分:冲突隔离
再来看另外一个问题:
假设我们要做一个网站,有不同的页面,每个页面可能需要不同的js文件,这些js之间存在依赖关系,比如bootstrap或者某些jquery插件可能是依赖jquery的,那么传统的做法是:
<script src="path/to/jquery/jquery.js">
<script src="path/to/bootstrap/bootstrap.js">
这样做很重要的一点是,我们必须按照正确的顺序来引入javascript,如果javascript文件比较少,这样做无可厚非。然而当一个系统规模越来越大,更极端的情况是当我们希望通过模块化的方式来做一个Web App的时候,所有的javascript文件都会被放到一个页面上,或者按照合理的顺序被压缩在一个文件中,这些javascript文件中的一个可能是控制某一个页面的,但是他的执行又依赖于其他一些模块或者第三方的库,必须等待这些模块或库提前被加载和初始化,这个时候问题会变得很复杂,我们需要手动的调整javascript文件的位置,以防止载入的时机不正确引起的问题。当文件数量比较多的时候,人肉的做法显然是不科学的。
我们如何去模块化地区分这一个个javascript文件,专业地说,我们如何解决这样的耦合问题?首先可以排除这样通过手动排列javascript的方式,因为这样的做法毫无模块化可言。再者如果考虑到团队合作,这样的程序迭代会让我们变得疯狂。
模块之间需要按顺序加载,这就是模块化思想的第二部分:依赖管理
在此,我们先提出一个概念,至于如何实现依赖管理,我们将在下文中进行叙述。
第三个问题,假设我们解决了命名冲突,解决了依赖管理。当我们有很多不同模块的时候,我们如何实现根据需要来进行加载。什么叫根据需要进行加载?就是对于某一个模块,加载的时候首先要加载一些依赖模块,而不加载一些冗余的模块。用我们熟悉的java来举个例子:
当我们需要使用到List类的时候,我们可能会在文件头上加上import语句
import java.util.List;
当然如果你是C#程序员你可能会这样写 :
using System.Collections.Generic;
无论你用什么语言这样做的目的是加载当前需要使用的包/命名空间,当你只需要List类的时候,你就不需要引入其他无关的包,如此一来,类的依赖显而易见。那么,javascript能不能像这些语言一样,也做到根据需要进行模块的加载呢?答案是,能,怎么做?且听下文。
这里就引入了模块化思想的第三部分:按需加载
小结
在这一节中,我们通过3个例子,引入了模块化的3个思想:冲突隔离、依赖管理和按需加载。如果通过本小节的阅读,若还是无法理解这3个思想,没关系,接下去还会继续围绕这3个思想进行深入探讨,各位姑且对这3个思想留下个印象,这也是本文后续章节的写作线索。
4.2 为什么要使用模块化?(Why)
-
假设一个不断扩展的系统,如何保证js的命名不冲突?
-
现在的项目使用越来越多的第三方js库,这些库之间又有复杂的依赖关系,如何友好的处理不同的库之间的依赖关系?
-
传统的Web Page功能不断丰富,开始转化为Web App,如何构建一个模块分明易维护的Web App?
-
过程式的写法不能适应不断扩展的网站功能, 因此出现了面向对象的Javascript,如何利用面向对象的特性, 构建高可维护性的前端代码?
为了解决上述的这些问题,答案就是,使用模块化。
4.3 如何使用模块化?(How)
第一小节中我们讨论了模块化的3个思想,留下了一个悬念,我们通过什么样的工具,可以做到依赖管理和按需加载呢?
其实我们想要的,是否是这样的一个方式:
var module1=require("/path/to/module1/module1.js");
module1.foo();
在Javascript模块化发展的过程中,慢慢的出现一些强大的模块化管理工具,就实现了这样的效果。关于前端模块化的发展历史,推荐阅读前端模块化开发那点历史,通过阅读这篇文章,我们可以对一下内容有一个基本的了解
- CommonJS模块化社区
- Modules/1.0和NodeJS的关系
- Modules/2.0和SeaJS的关系
- AMD/RequireJS的起源
- AMD与CMD的关系与区别
暂时不谈服务端NodeJS,目前前端模块化主要有两个流派:以RequireJS为代表的AMD流派和以SeaJS为代表的CMD流派。
CMD和AMD
CMD的意思就是通用模块定义(Common Module Definition),是SeaJS推广过程中对模块定义的规范化产出
AMD的意思就是异步模块定义(Asychronous Module Definition),是requreJS推广规程中对模块定义的规范化产出
想要更多地了解CMD和AMD,可以移步:
下面我们分别对这两个代表性的前端模块加载框架进行简单介绍:
4.4 Require.js简介
RequireJS is a JavaScript file and module loader. It is optimized for in-browser use, but it can be used in other JavaScript environments, like Rhino and Node. Using a modular script loader like RequireJS will improve the speed and quality of your code.
这是一段摘自RequireJS官方的简介
RequireJS是一个JavaScript文件与模块加载工具,它优化了浏览器中JavaScript的使用方式,但是它也可以被用在其它环境中比如Rhino和Node,通过使用像RequireJS这样的模块加载工具,可以显著提高你的网站速度和代码质量。
在此文中,我们只谈RequireJS的主要功能,即它在浏览器中的使用方式。
RequireJS使用的方式很简单,我们只需要关注3个方法:requirejs.config
方法define
方法和require
方法。
requirejs.config方法
require.config
做了3件事情:定义目录结构、定义依赖关系、定义非模块化JS的引用方式
requirejs.config({
baseUrl: 'js/common',//root目录,相对于当前文件的目录
waitSeconds: 60, //加载超时时间
paths: //文件目录,相对baseUrl/root的目录
{
model: '../model',
view: '../view',
template: '../template',
moduledir::'../modules'
},
shim: //非模块化js引用定义
{
'jquery':
{
exports: '$'
},
'backbone': {
deps: ['underscore', 'jquery', 'json2'],//定义依赖 exports: 'Backbone'
},
'underscore': {
exports: '_'
},
'json2': {
exports: 'JSON'
},
'jquery-ui': {
deps: ['jquery'],
exports: '$'
},
'colorpicker': {
deps: ['jquery']
},
'gmap3.min': {
deps: ['jquery']
}
}
});
这里重点解释一下非模块化JS代码
非模块化代码指的是没有根据模块化的要求进行封装的js代码, 其调用接口一般暴直接露在window对象中。
值得注意的是这里的所有js都没有.js
后缀,道理很简单,程序员就是喜欢少敲几下键盘
上面我们看到的requirejs.config
中的exports就是显式地告知当前文件暴露在window中的接口,这个 接口/引用对象 将被传入require
或者define
方法所在的函数作用域内。
define方法
define
的作用是定义一个模块,最简单的用法是:
//module2.js
define(
//可选参数,定义模块的名字
"module2",
//依赖列表
['moduledir/module1','jquery'],
//加载完依赖后的回调函数
function(module1){
var module2={
//定义一个模块...
};
//返回这个模块(重要)
return module2;
}
);
在上面的代码中,我们定义了一个模块module2,第二个参数指明了这个模块依赖于module1和jquery(不分先后顺序),所以在加载module2的时候,会首先递归地去把所有依赖的都加载完,等所有的模块都加载完后,会把这些依赖模块对应的引用传入第三个参数既回调方法中,然后在这个回调方法的作用域里面,我们就可以使用jquery和module1提供的功能了。
原理并不难,通过一个define方法,传入依赖列表,define方法通过异步加载模块的依赖,最后把每个依赖模块的引用传入到回调方法中。
有两点是值得我们注意的:
- 为什么是一个回调方法?因为这个过程是异步的,我们不希望这个过程中浏览器被阻塞
- define方法必须有一个返回值,来告诉外部,当前模块的对外接口是什么,这一点是经常被忽视的
require方法
让我们想象一下java程序、C++、Objective-C或者C#程序,他们执行的时候,都有一个Main函数,既程序的入口函数。使用requirejs的时候,入口函数就是require
方法。
//main.js
require(
//依赖列表
['app','jquery'],
//加载完依赖后的回调函数
function(app)
app.init();
}
);
对比一下require
方法和define
方法,可以发现区别是require
方法不需要返回值,很容易理解,require
方法只需要调用而不需要为其他模块提供接口
4.5 Sea.js简介
SeaJS提供了7个基本的API:
seajs.config
用来对 Sea.js 进行配置
seajs.config({
// 设置路径,方便跨目录调用
paths: {
'arale': 'https://a.alipayobjects.com/arale',
'jquery': 'https://a.alipayobjects.com/jquery'
},
// 设置别名,方便调用
alias: {
'class': 'arale/class/1.0.0/class',
'jquery': 'jquery/jquery/1.10.1/jquery'
}
});
seajs.use
用来在页面中加载一个或多个模块。
// 加载一个模块
seajs.use('./a');
// 加载一个模块,在加载完成时,执行回调
seajs.use('./a', function(a) {
a.doSomething();
});
// 加载多个模块,在加载完成时,执行回调
seajs.use(['./a', './b'], function(a, b) {
a.doSomething();
b.doSomething();
});
define
用来定义模块,遵循统一的写法:
define(function(require, exports, module) {
// 模块代码
});
require
, exports
和module
三个参数可酌情省略
require
require
用来获取指定模块的接口。
define(function(require) {
// 获取模块 a 的接口
var a = require('./a');
// 调用模块 a 的方法
a.doSomething();
});
require.async
用来在模块内部异步加载一个或多个模块。
define(function(require) {
// 异步加载一个模块,在加载完成时,执行回调
require.async('./b', function(b) {
b.doSomething();
});
// 异步加载多个模块,在加载完成时,执行回调
require.async(['./c', './d'], function(c, d) {
c.doSomething();
d.doSomething();
});
});
exports
用来在模块内部对外提供接口。
define(function(require, exports) {
// 对外提供 foo 属性
exports.foo = 'bar';
// 对外提供 doSomething 方法
exports.doSomething = function() {};
});
module.exports
与exports
类似,用来在模块内部对外提供接口。
define(function(require, exports, module) {
// 对外提供接口
module.exports = {
name: 'a',
doSomething: function() {};
};
});
4.6 RequireJS和SeaJS的对比与选择
通过上文的描述,如果你对NodeJS有所了解,那么你会更倾向于SeaJS的风格,SeaJS提供的接口毕竟继承了很多Module1.x的风格。
两者都是非常优秀的模块加载框架,伴随的争论也从来没有停止,我们在此便不过多的争论AMD和CMD的优劣,对于同样优秀的两个框架,我们不能持着先入为主的观念,实际上,在RequireJS2.x中,也加入了对CMD的支持,而SeaJS中也有异步的方式,两者正在互相取长补短,争论没有让两者越离越远,而是越来越接近。
所谓弱水三千只取一瓢饮,正因为他们越来越接近,所以我们无需担心会不小心做出错误的选择,那我们不妨先好好了解和使用其中一个。
结合恒天目前的项目经验,我们选择了RequireJS。
4.7 RequireJS最佳实践
关于RequireJS的基本用法,已经在前面进行了简单的描述,然而这些基本用法并不能帮我们解决很多问题,也并不能完全展现出RequireJS给我们带来的便捷与优点。下方罗列出的,可能是我们在项目中使用RequireJS后经常需要考虑的:
- 异步加载非js文件,如html模板
- 单页应用中使用RequireJS
- 多页应用中使用RequireJS
- 代码合并压缩
接下来的内容我们将根据以上的问题展开讨论
使用text插件加载非js文件
-
你是否经常需要在js中拼凑各种html结构?
那么我推荐你使用前端的模版引擎。
-
你是否在纠结在RequireJS中如何加载一个html模版?因为看上去RequreJS好像只是一个JavaScript模块加载器,默认是
.js
后缀,那么我们如何让它同时支持文件加载?答案是:使用text插件
无需做任何的配置,只需将text.js放在和require.js相同的目录下,require就瞬间被丰富了,我们可以通过使用这样的方式:
require(["some/module", "text!some/module.html", "text!some/module.css"],
function(module, html, css) {
//the html variable will be the text
//of the some/module.html file
//the css variable will be the text
//of the some/module.css file.
}
);
text!
命令表示后面的模块是一个文件,那么RequireJS将把整个文件读入一个字符串后作为一个模块传入。或许我们还有一个特殊的需求——只读取这个文件body中的内容,那么text.js也帮我们做了:
require(["text!some/module.html!strip"],
function(html) {
//the html variable will be the text of the
//some/module.html file, but only the part
//inside the body tag.
}
);
!strip
命令帮助我们快速的读取了我们真正需要的patial内容。
使用
strip
的好处是我们可以使用html进行模版的设计,在head中引入css文件,但是当它作为一个模版被加载时,可以忽略掉head中添加的样式文件的引用,因为这个样式文件往往是整个页面样式的集合。
在单页应用中使用RequireJS
在单页应用中,我们往往把配置 requirejs.config
方法和程序入口 require
方法放在同一个main.js文件中:
requirejs.config({
//By default load any module IDs from js/lib
baseUrl: 'js/lib',
//except, if the module ID starts with "app",
//load it from the js/app directory. paths
//config is relative to the baseUrl, and
//never includes a ".js" extension since
//the paths config could be for a directory.
paths: {
app: '../app'
}
});
// Start the main app logic.
require(['jquery', 'canvas', 'app/sub'],
function ($, canvas, sub) {
//jQuery, canvas and the app/sub module are all
//loaded and can be used here now.
});
在页面中通过以下方式调用:
<script data-main="scripts/main" src="scripts/require.js"></script>
上述代码中,浏览器加载require.js后,RequireJS会自动寻找data-main定义的入口,然后读取配置,接着执行require方法。
在多页应用中使用RequireJS
在多页应用中,RequireJS的使用没有太大的差别,我们可能碰到两个问题:
-
如何共用一套requirejs.config?
解决方案是把requirejs.config拆分到单独的js文件中,并且保证这个文件在main.js之前被加载,即保证require方法执行的时候,requirejs已经读取了目录的配置、模块的依赖等配置信息。
-
如何保证带Path信息的页面中baseUrl指向正确?
解决方案是在requirejs.config方法执行前定义一个module,这个module的作用是返回当前站点的root url,使用require来加载这个预定义的module,然后把这个root url配置到require的baseUrl中。
如在JAVA中,我们可以在Velocity模版中这样写:
<script type="text/javascript" src="$app/assets/js/require.js"></script>
<!-- Global variable module which will take variables from sever side -->
<script type="text/javascript">
define('global', {
context: "$app"
});
</script>
<script type="text/javascript" src="$app/assets/js/base.js"></script>
可以注意一下上述define方法的简洁写法,因为我们之前介绍过在define的回调中必须有返回值。这个简洁写法中我们定义了名为global的模块,这个模块返回当前站点的root url
base.js中,我们通过require方法加载global模块:
require(['global'], function(global){
require.config({
waitSeconds:30,
baseUrl: global.context + '/assets/js',
//...
path:{
//...
},
shim:{
//...
}
});
});
如此一来,即使页面的url中带有多个path信息,我们也可以保证requirejs中的js加载地址是正确的。
RequireJS插件
- 希望在文档加载完后再执行require的回调,我们可以使用domReady插件
- 希望构建多语言的应用,我们可以使用i18n插件
使用r.js进行文件合并压缩
RequireJS提供了r.js,供我们在发布产品时对代码进行合并压缩,r.js不仅仅适用于js和css文件,还可以打包整个工程。
具体使用非常简单,首先看一下调用的代码:
node r.js -o build.js
从上面的代码中我们可以看到,我们需要3样东西
- nodejs运行环境
- r.js
- build.js
第一步是安装NodeJS,只需要打开nodejs.org, 点击install就可以安装了,没有任何的依赖,需要注意的是安装的时候我们要有Admin权限
第二步是从RequireJS官网获取r.js
第三步是编辑build.js文件,build.js和require.config里面的内容几乎一模一样,来看一个Sample:
({
//需要压缩的工程的根目录
appDir: './',
//压缩的时候需要忽略的一些文件,可以用正则表达式
fileExclusionRegExp: /^(r|build)\.js$/,
//压缩css的方式
optimizeCss: 'standard',
//压缩的工程会被拷贝到dir指定的目录中
//注意:压缩并不破坏原有的工程,而是把原有的工程拷贝到dir指定的目录下
dir: './dist',
//需要压缩的模块入口
//这里我们有3个页面,对应modules中3个文件的3个require函数
modules: [
{
name: '../Main'
},
{
name:'../HomePageMain'
},
{
name:'../Help'
}
],
baseUrl: 'js/common',
paths:
{
model: '../model',
view: '../view',
template: '../template'
},
shim:
{
'jquery':
{
exports: '$'
},
'backbone': {
deps: ['underscore', 'jquery', 'json2'],
exports: 'Backbone'
},
'underscore': {
exports: '_'
},
'json2': {
exports: 'JSON'
},
'jquery-ui': {
deps: ['jquery'],
exports: '$'
},
'gmap3.min': {
deps: ['jquery']
}
}
})
只有前5个参数和requirejs.config
中的不同,请看注释。
r.js的文件压缩功能需要调用NodeJS环境提供的方法,它做的是:
-
拷贝当前
appDir
(未压缩的工程目录)定义的目录下的所有文件到dir
(输出目录)定义的目录中, 拷贝过程中排除fileExclusionRegExp
定义的文件 -
进入输出目录中,此时输出目录文件其实未经过压缩
-
根据modules定义的文件为入口, 分别寻找里面的require方法, 根据配置信息递归的读取依赖文件,使用uglify提供的功能进行文件的压缩,最后合并到当前module中
4 同时r.js会遍历目录下的所有文件资源(如css,html),对其进行压缩。
以Main.js模块为例,执行 r.js -o build.js
后的效果是,运行时只有一个Main.js,不再需要长时间的等待多个文件的异步加载,所有Main.js依赖的模块,包括模块依赖的html模板、js全部被压缩在Main.js中,所有页面的文件,包括css,都经过了gzip压缩。
这样做的好处是:
-
减少请求, 原来加载Main.js的时候需要递归加载所有依赖的文件,这个步骤在压缩后就不需要了,只有一个Main.js文件,并且代码经过混淆
-
提高响应速度,所有的文件经过gzip的方式压缩后,显著提高了传输效率
-