JavaScript模块化
当前的网页已经开始逐渐变成"互联网应用程序", 在网页中插入的JavaScript代码越来越庞大, 越来越复杂. 网页越来越像桌面程序了, 需要一个团队分工协作, 进度管理, 单元测试等等......开发者不得不使用软件工程方法, 管理网页的业务逻辑.
JavaScript模块化编程, 已经成为一个迫切的需求. 理想情况下, 开发者只需要实现核心的业务逻辑, 其他都可以加载别人写好的模块.
但是JavaScript不是一种模块化的编程语言, 它不支持"类", 更不用说"模块"了.
JavaScript社区做了很多努力, 在现有的运行环境下, 实现"模块"的效果. 这里总结了当前JavaScript模块化编程的最佳实践, 说明如何投入使用.
原始写法
模块就是特定功能的一组方法.
只要把不同的函数简单的放在一起, 就算是一个模块.
function a1(){
// code here
}
function a2(){
// code here
}
上面的函数a1()和a2(), 组成一个模块, 使用的时候, 直接调用就行了.
但是这样的做法有一个很明显的缺点: "污染"了全局变量, 无法保证不与其他模块发生变量名冲突, 而且模块成员之间看不出直接关系.
对象写法
为解决上面的问题, 可以把模块写成一个对象, 所有的模块成员都放到这个对象里面.
var module = new Object({
_count: 0,
a1: function(){},
a2: function(){},
});
上面的函数a1()和a2(), 都封装在module对象中, 使用的时候, 直接调用这个对象的属性.
module.a1();
但是, 这样的写法会暴露所有模块成员, 内部状态也可以被外部随意改写. 比如, 外部代码可以直接改变内部的_count变量的值, 也可以更改a1函数的定义.
module._count = 13;
立即执行函数写法
为了解决上面的问题, 可以使用立即执行函数的写法, 来解决不暴露私有成员的目的.
var module = (function(){
var _count = 0;
var a1 = function(){};
var a2 = function(){};
return {
a1: a1,
a2: a2
}
})();
使用上面的写法, 外部代码无法读取到内部的_count变量.
这种写法就是JavaScript模块的基本写法. 但还需要再对这种写法进行一些加工才能算是真正的模块化编程.
放大模式
如果一个模块很大, 必须分成多个部分, 或者一个模块需要继承另一个模块, 这时就有必要采用"放大模式".
var module = (function(m)(){
m.a3 = function(){};
return m;
})(module);
上面的代码为module模块添加一个新方法a3, 然后返回新的module模块.
宽放大模式
在浏览器环境中, 模块的各个部分通常都从网络中获取的, 获取的这部分资源你可能无法确定何时以及哪个文件先加载到, 如果采用上面的写法, 第一个执行的部分有可能加载一个不存在的空对象, 这时就要采用"宽放大模式".
var module = (function(m)(){
m.a3 = function(){};
return m;
})(window.module || {});
与"放大模式"相比, "宽放大模式"就是"立即执行函数"的参数可以是空对象.
输入全局变量
独立性是模块的重要特点, 模块内部最好不与程序的其他部分直接交互.
为了在模块内部调用全局变量, 必须显式地将其他变量输入模块.
var module = (function($, backbone){
// code here
})(jQuery, Backbone);
上面的module模块需要使用jQuery和Backbone库, 就把这两个库当作参数输入module, 这样做除了保证模块的独立性, 还使得模块之间的依赖关系变得明显.
模块规范
想一下, 为什么模块很重要?
因为有了模块, 我们就可以更方便地使用别人的代码, 想要什么功能, 就加载什么模块.
但是这样的做得有一个前提条件, 就是大家都必须以同样的方式编写模块, 否则就会乱套.
目前, 通行的JavaScript模块规范共有两种: CommonJS和AMD(CMD与AMD很相似, 就不介绍, 有需要的, 可以去了解一下sea.js).
CommonJS
2009年, 美国程序员Ryan Dahl创造了Node.js项目, 将JavaScript语言用于服务器端编程.这标志"JavaScript模块化编程"正式诞生. 因为在浏览环境下, 没有模块也不是特别大的问题, 毕竟网页程序的复杂性有限; 但是在服务器端, 一定要有模块, 与操作系统和其他应用程序互动, 否则根本没法编程.
Node.js的模块系统, 就是参照CommonJS规范实现的. 在CommonJS中, 有一个全局性方法去require(), 用于加载模块. 假到有一个数学模块math.js, 就可以像下面这样加载:
var math = require('math');
然后, 就可以调用模块提供的方法了
var math = require('math');
math.sub(3,2);
本文主要讲浏览器端的JavaScript编程, 不涉及Nodejs, 所以对CommonJS不做过多的介绍, 有兴趣的话可以去搜索下CommonJS规范.
在这里大家只要晓得, require()用于加载模块就行了.
浏览器环境
有了服务器模块后, 大家可能会想要让客户端也有模块. 而且最好两者能够兼容.
但是, 由于一个重大的局限, 使得CommonJS规范不适用于浏览器环境, 还是上一节的代码, 如果在浏览器中运行, 会是一个很大的问题.
var math = require('math');
math.sub(3,2);
第二行的math.sub(3, 2), 在第一行require('math')之后运行, 因此必须等到math.js加载完成, 也就是说, 如果加载时间过长, 整个应用可能都停在那里.
这对服务器来说, 不是一个问题, 因为所有的模块都存放在本地硬盘中, 可以同步加载完成, 等待时间也就是硬盘的读取时间. 但, 对于浏览器来说, 这却是一个很大的问题, 因为模块都放在服务器端, 等待时间完成取决于网速的快慢, 可能要等很长时间, 浏览器处于"假死"状态. 因此, 浏览器端肯定是不可以用"同步加载"的方式, 只能采用"异步加载". 这也就是AMD规范诞生的背景.
AMD
AMD是"Asynchronous Module Definition"的缩写, 即"异步模块定义'. 它采用异步方式加载模块, 模块的加载不影响它后面语句的执行. 所有依赖这个模块的语句, 都定义在一个回调函数中, 等加载完成之后, 这个回调函数才会运行.
AMD也采用require()语句加载模块, 但是不同于CommonJS, 它要求两个参数:
require([module], callback);
第一个参数[module], 是一个数组, 里面的成员就是要加载的模块, 第二个参数callback, 则是加载成功之后的回调函数, 如果前面的代码改写成AMD形式, 就是下面这样的:
require(['math'], function(math){
math.sub(3, 2);
});
math.sub()与math模块加载不是同步的, 浏览器不会发生"假死", 很显然, AMD比较适合浏览器环境.
目前, 主要有两个JavaScript库实现了AMD规范: require.js和curl.js.
下面对require.js进行详细的介绍, 如果对curl.js有兴趣的话, 大家可以自行去问问度娘.
require.js加载模块
为什么要使用require.js?
最初, 所有的JavaScript代码都写在一个文件里面, 只要加载一个文件就够了. 可是后来, 代码越来越多, 一个文件显然不行, 必须拆分, 依次加载. 下面的网页代码, 你肯定不会陌生:
<script type="text/javascript" src="a1.js"></script>
<script type="text/javascript" src="b1.js"></script>
<script type="text/javascript" src="c1.js"></script>
<script type="text/javascript" src="d1.js"></script>
<script type="text/javascript" src="e1.js"></script>
<script type="text/javascript" src="f1.js"></script>
<script type="text/javascript" src="g1.js"></script>
<script type="text/javascript" src="h1.js"></script>
上面的代码依次加载多个js文件, 但这样的写法有很大的缺陷:
- 加载js文件时, 浏览器会停止网页渲染, 加载文件越多, 网页失去响应的时间就会越长.
- 由于js文件之间是有依赖关系的, 必须严格保证加载顺序. 依赖性最大的模块一定要最后加载, 当依赖关系很复杂的时候, 代码的编写和维护会变得很困难.
require.js就是为了解决这些问题而产生的.
- 实现js文件的异步加载, 避免网页失去响应.
- 管理模块之间的依赖性, 便于代码的编写和维护.
加载require.js
在官网下载最新版的require.js, 下载地址: http://www.requirejs.org.
下载后, 假定把它放在js/libs目录下, 用下面的代码加载:
<script type="text/javascript" src="js/libs/require.js"></script>
在加载 这个文件本身也可能因为网络或服务器问题, 不能成功加载, 而造成网页失去响应. 解决的方法有两个, 一个是把它放在网页的底部加载, 另一个是写成下面这样:
<script type="text/javascript" defer async="true" src="js/libs/require.js"></script>
async属性表明文件需要异步加载, 避免网页失去响应. IE不支持这个属性. 只支持defer, 所以把defer也写上.
自定义主模块
加载require.js后, 就要加载自己的代码了. 假设main.js是你的主"模块", 也放在js/libs目录下, 那么只需要写成下面这样的:
<script type="text/javascript" src="js/libs/require.js" data-main="js/libs/main.js></script>
data-main属性的作用是, 指定网页程序的主模块. 在上例中, 就是js/libs目录下的main.js. 这个文件会第一个被require.js加载, 并且文件具有最高优先级的调用. 由于require.js默认的文件后缀名是.js, 所以可以把main.js简写成main.
main.js是"主模块", 意味着是整个网页的js入口代码, 有点像C语言的main()函数, 所有代码都从这儿开始运行.
如果我们的代码不依赖任何其他模块, 那么可以直接写入JavaScript代码.
要这么写, 也就没法有必要要用require.js. 实际的情况是, 主模块依赖于其他模块, 这就需要使用AMD规范定义的require函数了.
//main.js
require(['ModuleA', MmoduleB', 'ModuleC'], function(modulea, moduleb, modulec){
// some code here;
});
require函数有两个参数, 其中:
- 第一个参数, 是一个数组, 表示所依赖的模块.即"ModuleA", "ModuleB, "ModuleC".
- 第二个参数, 是一个回调函数, 当指定的所有模块都加载成功后, 才会被调用. 加载的模块以参数形式传入该函数, 从而在回调函数中使用这些模块.
这样, require异步加载ModuleA, ModuleB, ModuleC, 浏览器不会失去响应; 指定回调函数, 只有当所有模块都加载成功才会运行, 解决了js依赖性的问题.
假设, 主模块依赖jquery, underscore和backbone三个模块, main.js可以这样写:
require(['jquery', 'underscore', 'backbone'], function($, _underscore, Backbone){
// some code here;
});
自定义加载
默认情况下, require.js假设你的模块与main.js在同一目录, 文件名分别为jquery.js, underscore.js和backbone.js, 然后自动加载.
使用require.config方法, 我们可以自定义对模块加载的行为. require.config写在主模块(main.js)的顶部. 其参数是一个paths对象, 指定各个模块的加载路径, 如下所示:
require.config({
baseUrl: 'static/js',
debug: true,
paths: {
jquery: 'jquery-1.8.3.min',
underscore: 'underscore.min',
backbone: 'backbone.min'
}
});
上面的代码表明你的三个模块文件与main.js在同一个js目录里, 如果这些模块在其他目录, 如js/lib目录, 则有两种写法, 要么直接指定基目录baseUrl. 当标明data-main属性但不特别配置baseUrl时, 根目录baseUrl的值默认为data-main指定的文件所在的目录, 当data-main和baseUrl都未指定时根目录的值默认为加载require.js的html文件所在目录, 否则将以配置的baseUrl作为根目录.
require.config({
baseUrl: 'static/js',
paths: {
jquery: 'lib/jquery-1.8.3.min',
underscore: 'lib/underscore.min',
backbone: 'lib/backbone.min'
}
});
或
require.config({
baseUrl: 'static/js/lib',
paths: {
jquery: 'jquery-1.8.3.min',
underscore: 'underscore.min',
backbone: 'backbone.min'
}
});
如果某个模块在另一台主机上, 也可以直接指定它的网址, 如:
require.config({
paths: {
jquery: 'https://ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min',
}
});
require.js要求, 每个模块是一个单独的js文件, 但这样, 加载多个模块就会发出多个HTTP请求, 影响网页的加载速度. 因此, require.js提供了一个优化工具, 当模块部署完毕以后, 该工具将多个模块合并在一个文件中, 减少HTTP请求次数.
加载AMD规范的模块
require.js加载的模块必须采用AMD规范, 具体来说, 模块必须采用define函数来定义.
- 定义简单键值对
如果模块不依赖于任何模块, 同时只是传递一些简单的name/value, 那么只需传递一个原始的对象给define().
// inside file demo.js
define({
color: "black",
size: "32"
});
- 定义函数
// inside file demo.js
define(function(){
return {
color: "black",
size: "32"
}
});
- 定义带依赖的函数
如果模块有依赖, 那么第一个参数应该是依赖名称的数组集合, 第二个参数应该是一个定义的函数. 一旦所有的依赖都已经加载好, 那么将调用这个函数定义的模块. 这个函数应该返回一个定义的模块的对象. 这些依赖将作为参数传递给定义的参数, 同时按照依赖顺序在参数中列出来.
//demo.js
define(['./cart', '.inventory'], function(cart, inventory){
return {
color: 'blue',
size: 'large',
addToCart: function(){
inventory.decrement(this);
cart.add(this);
}
}
});
在上述案例中, demo模块已经创建, 它依赖于cart和inventory两个模块. 上述函数调用中指定了两个参数, cart和inventory, 这些代表了cart和inventory模块, 上述函数直到cart和inventory模块加载完成后才会被调用, 它接收模块作为cart和inventory参数.
- 定义一个模块作为一个函数
模块没有必要一定要返回对象. 函数中任何返回值都是允许的. 下面是一个返回一个函数作为它的模块定义的模块示例:
define(['my/cart', 'my/inventory'], function(cart, inventory){
return function(title){
return title ? (window.title = title) : inventory.storeName + "" + cart.name;
}
});
- 加载非AMD规范的模块
理论上, require.js加载的模块必须符合AMD规范, 即用define函数定义的模块. 但实际情况是, 虽然已经有一部分流行的函数库(如jQuery)符合AMD规范, 更多的库并不符合. 那么require.js是否能够加载非规范的模块呢? 当然可以, 加载非规范模块前, 要先用require.config方法, 定义它们的一些特征.
例如, 上面的三个模块: jquery.js, underscore.js和backbone.js, 其中, jQuery.js符合规范, 而underscore和backbone这两个库不符合. 如果要加载它们的话, 必须先定义它们的特征:
require.config({
shim: {
'underscore': {
exports: '_'
},
'backbone': {
deps: ['underscore', 'jquery'],
exports: 'Backbone',
}
}
});
require.config接受一个配置对象shim, 专门用来配置不兼容的模块. 具体来说, 每个模块要定义:
- exports值(输出的变量名), 表明这个模块外部调用时的名称.
- deps数组, 表明该模块的依赖性
比如, jQuery插件可以这样定义:
require.config({
shim: {
'underscore': {
exports: '_'
},
'backbone': {
deps: ['underscore', 'jquery'],
exports: 'Backbone',
},
'jquery.scroll': {
deps: ['jquery'],
exports: 'jQuery.fn.scroll'
}
}
});
本文只是介绍require.js的简单用法, 若想对require.js有更深入的了解, 可以去github中查看源码及官方文档.