JS高阶七(JS模块化下)
Posted 稻香Snowy
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JS高阶七(JS模块化下)相关的知识,希望对你有一定的参考价值。
在上一篇中我们已经讨论了什么是模块,为什么要使用模块以及多种实现模块化的方式。
这次,我们来说一下什么是模块打包,为什么要打包模块,模块打包的方式工具,还有它当前在Web开发中的运用。
什么是模块打包?
粗俗点来讲,模块打包就是把它一小块一小块的代码粘成一大块。
实际操作起来的时候当然还需要关注一些细节。
为什么打包模块?
一般来讲,我们用模块组织代码的时候都会把模块划分在不同的文件和文件夹里,也可能会包含一些诸如React和Underscore一类的第三方库。
而后,所有的这些模块都需要通过script标签引入到你的html文件中,然后用户在访问你网页的时候他才能正常显示和工作。每个独立的script标签意味着,它们要被浏览器分别一个个地加载。
这就有可能导致页面载入时间过长。
为了解决这个问题,我们就需要进行模块打包,把所有的模块合并在一个或几个文件中,一次来减少HTTP请求数。这也可以被称作是从开发到上线前的构建环节。
还有一种提升加载速度的做法叫做代码压缩(混淆)。其实就是除去代码中不必要的空格、注释、换行符一类的字符,来保证在不影响代码正常工作的情况下压缩其体积。
更小的文件体积就意味着更短的加载时间。要是你仔细对比过带有.min后缀的例如jquery.main.js和jquer.js的话,应该会发现压缩版的文件相比之下要小很多。
Gulp和Grunt一类的构建工具可以很方便地解决上述的需求,在开发的时候通过模块来组织代码,上线的时候再合并压缩提供给浏览器。
打包模块的方法有哪些?
如果你的代码是通过之前介绍过的模块模式来组织的,合并和压缩它们其实就只是把一些原生的JS代码合在一起而已。
但如果你使用的是一些浏览器原生不支持的模块系统(例如CommonJS或ADM,以及ES6模块的支持现在也不完整),你就需要使用一些专门的构建工具来把它们转换成浏览器支持的代码。这类工具就是我们经常听说的Browserify,RequireJS,Webpack等等模块化构建、模块化加载工具了。
为了实现模块化构建或载入的功能,这类工具提供许多诸如在你改动源代码后自动重新构建(文件监听)等一系列的功能。
下面我们就一起来看一些实际的例子吧。
打包CommonJS
在上一篇教程中我们了解到,CommonJS是同步载入模块的,这对浏览器来说不是很理想。其实下面介绍的模块化构建工具Browserify在上一篇也提到过。它是一个专门用来打包CommonJS模块以便在浏览器里运行的构建工具。
举个例子,假如你在main.js文件中引入了一个用来计算平均数的功能模块:
var myDependency=require('myDependency');
var myGrades=[93,95,88,0,91];
var myAverageGrade=myDependecy.average(myGrades);
var myAverageGrade=myDependency.average(myGrades);
在这个实例中,我们只有一个名为myDependency的模块依赖。通过下面的命令,Browserify会依次把main.js里引入的所有模块一同打包到一个名为bundle.js的文件里:
browerify main.js -o bundle.js
Browerify首先会通过抽象语法树(AST)来解析代码中的每一个require语句,在分析完所有模块的依赖和结构之后,就会把所有的代码合并在一个文件中。然后你在HTML文件里引入一个bundle.js就够了。
多个文件和多个依赖也只需要再稍微配置一下就能正常工作了。
之后你也可以使用一些例如Minify-JS的工具来压缩代码。
打包AMD
假如你使用的是AMD,你会需要一些例如RequireJS或Curl和AMD加载器。模块化加载工具可以在你的应用中按需加载模块代码。
需要提醒一下,AMD和CommonJS的最主要的区别是AMD是异步加载模块的。这就意味着你不是必须把所有的代码打包到一个文件里,模块加载不影响后续语句的执行,逐步加载的模块也不会导致页面阻塞无法响应。
不过在实际应用中,为了避免用户过多的请求服务器造成压力。大多说的开发者还是选择用RequireJS optimizer,r.js一类的构建工具来合并压缩AMD的模块。
总的来说,AMD和CommonJS在构建中最大的区别是,在开发过程中,采用AMD的应用直到正式发布之前都不需要构建。
Webpack
webpack是新推出的构建工具里最受欢迎的。它兼容CommonJS,AMD,ES6各类规范。
也许你会质疑,我们已经有诸如Browserify或RequireJS的工具了,为什么还需要Webpack呢?究其原因之一,Webpack提供许多例如code splitting(代码分割)的有用功能,它可以把你的代码分割成一个个的chunk然后按需加载优化性能。
举个例子,要是你的Web应用中的一些代码只在很少的情况下才会别用到,把它们全都打包到一个文件里是很低效的做法。所以我们就需要code splitting这样的功能来实现按需加载。而不是把哪些很少人才会用到的代码一股脑儿全部都下载到客户端去。
code splitting只是WebPack提供的众多强大功能之一。当然,网上也为这些模块化构建工具吵的不可开交。
ES6模块
假如你采用ES6模块,在不远的将来对那些构建工具的需求可能会小一些。首先我们还是看看ES6模块是怎么加载的吧。
ES6模块和CommonJ,AMD一类规范最主要的区别是,当你载入一个模块时,载入的操作实际在编译时执行的————也就是在代码执行之前。所以去掉那些不变要的exports导出语句可以优化我们应用的性能。
有一个经常会被问到的问题:去除exports和冗余代码消除(UglifyJS一类工具执行后的效果)之间有什么区别?
答案是这个要具体情况具体分析。
让ES6模块冗余代码消除不同的一种做法叫做Tree shaking的技术。Tree shaking其实恰好是冗余代码消除的反向操作。它只加载你需要调用的代码,而不是删掉不会被执行的代码。我们还是用一个具体的例子说明吧:
假设我们有如下使用ES6语法,名为utils.js的函数:
exports function each(collection,iterator){
if(Array.isArray(collection)){
for(var i=0;i<collection.length;i++){
iterator(collection[i],i,collection);
}
}else{
for(var key in collection){
iterator(collection[key],key,collection);
}
}
}
export function filter(collection, test) {
var filtered = [];
each(collection, function(item) {
if (test(item)) {
filtered.push(item);
}
});
return filtered;
}
export function map(collection, iterator) {
var mapped = [];
each(collection, function(value, key, collection) {
mapped.push(iterator(value));
});
return mapped;
}
export function reduce(collection, iterator, accumulator) {
var startingValueMissing = accumulator === undefined;
each(collection, function(item) {
if(startingValueMissing) {
accumulator = item;
startingValueMissing = false;
} else {
accumulator = iterator(accumulator, item);
}
});
return accumulator;
}
现在我们也不清楚到底需要这个函数的哪些功能,所以先全部引入到main.js中:
//main.js
import * as Utils from './utils.js';
之后我们再调用一下each函数:
//main.js
import * as Utils from './utils.js';
Utils.each([1,2,3],function(x){
console.log(x);
})
通过“tree shaken”之后的main.js看起来就像下面这样:
function each(collection, iterator) {
if (Array.isArray(collection)) {
for (var i = 0; i < collection.length; i++) {
iterator(collection[i], i, collection);
}
} else {
for (var key in collection) {
iterator(collection[key], key, collection);
}
}
};
each([1, 2, 3], function(x) { console.log(x) });
注意到这里只导出了我们调用过的 each 方法。
再如果我们只调用 filter 方法的话:
//main.js
import * as Utils from './utils.js';
Utils.filter([1, 2, 3], function(x) { return x === 2 });
"Tree shaken" 之后就会变成这样:
function each(collection, iterator) {
if (Array.isArray(collection)) {
for (var i = 0; i < collection.length; i++) {
iterator(collection[i], i, collection);
}
} else {
for (var key in collection) {
iterator(collection[key], key, collection);
}
}
};
function filter(collection, test) {
var filtered = [];
//注意在filter中调用了each,所以两个方法都会被引入
each(collection, function(item) {
if (test(item)) {
filtered.push(item);
}
});
return filtered;
};
filter([1, 2, 3], function(x) { return x === 2 });
很神奇不是么?
构建ES6模块
现在我们已经了解到ES6模块载入的与众不同了,但我们还没有聊到底该怎么构建ES6模块。
因为浏览器对ES6模块的原生支持还不够完善,所以现阶段我们做一些补充工作。
让ES6模块在浏览器中顺利运行的常用方法有以下几种:
1.使用语法编辑器(Babel或Tracer)来把ES6语法的代码编译成ES5或者CommonJS,AMD,UMD等其他形式。然后通过Browserify或Webpack一类的构建工具来进行构建。
使用Rollup.js,这其实和上面差不多,只是Rollup还会捎带的利用“tree shaking”技术来优化你的代码。在构建ES6模块时,Rollup优于Browserify或Webpack的也正是这一点,它打包出来的文件体积会更小。Rollup也可以把你的代码转化成包括ES6,CommonJS,AMD,UMD,IFFE在内的各种格式。其中IFFE和UMD可以直接在浏览器里运行,AMD,CommonJS,ES6等还需要你通过Browserify,Webpack,RequireJS一类的工具才能在浏览器中使用。
小心采坑
这里有一些坑还需要和大家说明一下。转换语法优雅的ES6代码以便在浏览器里运行并不是一件令人舒爽的事情。
问题在于,什么时候我们才能免去这些多余的工作。
令人感动的答案是:“差不多快了。”
ECMAScript目前包含一个名为ECMAScript 6 module loader API的解决方案。简单来说,这个解决方案允许你动态加载并缓存。举例说明:
myModule.js
export class myModule{
constructor(){
console.log('hello I am a module');
}
hello(){
console.log('hello');
}
goodbye(){
console.log('goodbye');
}
}
main.js
System.import('myModule').then(function(myModule){
new myModule.hello();
});
//‘Hello!, I am a module!’
同样,你可以在script标签上设置type=module的属性来直接定义模块:
<script type="module">
//loads the 'myModule' export from 'mymodule.js'
import {hello} from 'mymodule';
new Hello();// 'Hello, I am a module!'
</script>
如果你现在就想测试这个解决方案的话,我在这里也安利一下 SystemJS. SystemJS支持在浏览器端和Node动态加载之前介绍过所有格式的模块(ES6 modules, AMD, CommonJS等),通过把已加载的模块还存在"module registry"里来避免重复加载。它也同样支持转换ES6的代码至其他格式。
我们已经有了原生ES6模块,还需要那些乱七八糟的玩意儿么?
越来越多的人使用ES6模块产生了一些有趣的影响:
HTTP/2 出现之后,模块化构建工具是不是都该被淘汰了?
HTTP/1中,一次TCP连接只允许一个请求,所以我们需要通过减少载入的文件数来优化性能。而HTTP/2改变了这一切,请求和响应可以并行,一次链接也允许多个请求。
每次请求的消耗也会远远小于HTTP/1,所以载入一堆模块就不再是一个影响性能的问题了。所以许多人认为打包模块完全就是多余的了。这听起来很合理,但我们也需要具体情况具体分析。
其中有一条,模块化构建决绝了一些HTTP/2解决不了的问题。例如除去冗余的代码以压缩体积。要是你开发的是一个对性能要求很高的网站,模块化构建从长远上考虑会给你带来更多好处。当然,要是你不那么在意性能问题,以后完全就可以省略这些麻烦的步骤了。
总之,我们离所有的网站都采用HTTP/2传输还有相当一段时间。短时期内模块构建还是很有必要的。
CommonJS,AMD,UMD这类标准会过时么?
一旦ES6成为了模块化的标准,我们还需要这些非原生的东西么?
这点还值得商榷。
在javascript中采用统一标准,通过import和export来使用模块,省略所有繁杂的多余步骤确实很爽。不过到底要多久ES6才能成为真正的模块化标准呢?
反正不会很快。
并且开发者也有各自的偏好,“唯一的解决方案”永远也不会存在。
扩展:AMD和CMD规范
时下流行的模块规范主要有CommonJS、AMD和CMD规范。比方说,CommonJS规范实现代表Node.js,AMD规范的实现代表是RequireJS,SMD规范的实现代表是Sea.js。
1.CommonJS规范
Node.js应用由模块组成,采用CommonJS规范,通过全局方法require来加载模块,实例代码如下:
var http=require('http');//引入http模块
var server=http.createServer(function(req,res){//用http模块提供的方法创建一个服务
res.statusCode=200;//返回状态码为200
res.setHeader('Content-Type','text/plain');//指定请求和响应的HTTP内容类型
res.end('Hello World\n');
});
server.listen(3000,'127.0.0.1',function(){//监听的端口和主机名
console.log('Server running at http://127.0.0.1:3000');//服务器启动成功之后控制台打印息
});
从上面的实例代码中可以看出,首先通过require方法引入http模块,接着调用http模块的createServer方法创建一个服务,最后给这个服务指定端口号和主机名,一个简单的HTTP服务器就创建好了。这就是CommonJS规范模块的实际应用了,那么如何编写一个CommonJS规范的模块呢?这就需要用到Module对象了。
Node.js内部提供了一个Module构建函数,所有模块都是Module的实例。每个模块内部都有一个Module对象,代表当前模块,包含如下属性:
id:模块的识别符,通常是带有绝对 路径的模块文件名
filename:模块的文件名,带有绝对路径
loader:返回一个布尔值,表示模块是否已经完成加载
parent:返回一个对象,表示调用该模块的模块
children:返回一个数组,表示该模块要用到的其他模块。
exports:表示模块对外输出的值
其中exports属性是编写模块的关键,其表示当前模块对外输出的接口。其他文件加载该模块,其实读取的是module.exports。编写CommonJS规范的模块,实例代码如下:
module.exports=function(params){
console.log(params);
}
使用方法如下:
var moduleA=require('./moduleA');
moduleA();
为了方便,Node.js为每个模块提供一个exports变量指向module.exports,那么moduleA的写法也可以这样编写,代码如下:
//moduleA.js
exports.moduleA=function(){
console.log(params);
}
注意:不要把值直接赋给exports,因为这样等于切断了exports与module.exports的联系
总结CommonJS模块的特点如下:
所有模块都有单独作用域,不会污染全局作用域
重复加载模块只会加载一些,后面都从缓存读取
模块加载的顺序按照从代码中出现的顺序
模块加载是同步的
2、AMD规范
CommonJS模块采用同步加载,适合服务器端却不适合浏览器。ADM规范支持异步加载模块,规范中定义了一个全局变量define函数,描述如下。
define(id,dependencies,factory)
第一个参数id,为字符串类型,表示模块标识,为可选参数。若不存在则模块表示默认定义为在加载器中被请求脚本的标识。如果存在,那么模块标识必须为顶层或者一个绝对的标识。
第二个参数dependencies,定义当前所依赖模块的数组。依赖模块必须根据工厂方法优先级执行,并且执行的结果按照依赖数组中的位置顺序以参数的形式传入(定义中模块的) 工厂方法中。
第三个参数factory,为模块初始化要执行的函数或对象。如果为函数,只被执行一次。如果是对象,此对象应该为模块的输出值如工厂方法返回一个值(对象,函数或任意强制类型转换为true的值),应该设置为该模块的输出值。
创建一个标准的AMD模块
创建的模块标识是“alpha”的模块,依赖于内置的“require”和“exports”模块和外部标识为“beta”的模块。require函数取得模块的引用,从而即使模块美誉作为参数定义,也能够被使用。exports是定义的alpha模块的实体,在其上定义的任何属性和方法也就是alpha模块的属性和方法,例子中简单调用模块beta的verb方法。如下:
define('alpha',['require','exports','beta'],function(require,exports,beta){
export.verb=function(){
return beta.verb();
//或者:
return require('beta').verb();
}
});
创建一个匿名模块
define方法允许省略第一个参数,当省略第一个参数定义模块时,模块文件的文件名即模块标识。该模块为匿名模块。定义一个依赖于alpha模块的匿名模块,代码如下:
define(['alpha'],function(alpha){
return{
verb:function(){
return alpha.verb()+1;
}
}
})
仅有一个参数的define
define的前面两个参数都是可以省略的。第三个参数有两种情况,一种是javascript对象,另一种是函数。
如果参数是一个对象,那么可能是一个包含方法的对象,也可能仅仅提供数据,也可能都在。
define({
name:'add',
add:function(x,y){
return x+y;
}
})
如果参数是一个函数,其用途是快速开发实现,适用于小型的应用,代码如下:
define(function(){
//使用math-util这个模块
var mathUtil=require(math-util);
return mathUtil.add(1,2);
});
局部require和全局require 局部require可以被解析成一个符合AMD工厂函数规范的require函数,实例代码如下:
define(['require'],function(){
//...
});
//或者
define(function(require,exports,module){
//...
});
局部require也支持其他标准实现的API。全局require函数作用于全局,和define函数类似。全局require和局部require有着相同的行为,均包含这些特征:模块ID应该认为是一个绝对的模块名称,而不是相对于另一个模块的ID:只有在异步的时候,才可以使用require(id,callback)的回调形式。因为异步加载模块的方式是先发出一个异步请求,然后等主线程代码执行完毕才能进行异步回调并处理加载完毕的模块。
实际中,经常会遇到一些阻塞模块加载的依赖,如果交互次数过多,需要大量的模块加载,应该采用全局依赖的形式去加载顶层模块。
RequireJS介绍
说到AMD规范就不得不说RequireJS,RequireJS库能够把AMD规范应用到实际浏览器Web端的开发中。其主要解决了两个问题:实现javascript文件的异步加载,避免网页失去响应:管理模块之间的依赖性,便于代码的编写和维护。
首先,官方下载最新版require.js文件,在页面底部引入,实例代码如下:
<script src="js/require.js"></script>
加载实际逻辑的主模块文件,代码如下:
<script src="js/require.js" data-main="js/main"></script>
data-main属性定义Web程序的主模块,在这里“js/main”即主模块,省略了后缀“.js”,RequireJS在加载脚本引用时会为其默认添加。主模块也称为入口文件,类似于C语言的main函数,所有代码都从这里开始运行。main.js的实例代码如下:
require(['require','underscore','backbone'],function($,_,Backbone){
//业务代码
});
RequireJS会依次加载jQuery库、Undersore和Backbone.js,然后再运行回调函数。使用require.config()方法,开发者可以对模块的加载路径进行自定义,假设这些库文件都在和main.js同级的lib文件夹下,实例代码如下:
require.config({
paths:{
'jquery':'lib/jquery.min',
'undersore':'lib/undersore.min',
'backbone':'lib/backbone.min'
}
});
或者使用属性baseUrl定义基础路径,代码如下:
require.config({
baseUrl:'js/lib',
paths:{
'jquery':'jquery.min',
'undersore':'undersore.min',
'backbone':'backbone.min'
}
})
RequireJS支持加载非AMD规范的模块,支持使用require.config方法来定义一些特征,比如Undersore和Backbone.js这两个库(非ADM规范),实例代码:
require.config({
shim:{
'undersore':{
exports:'_'
},
'backbone':{
deps:['undersore','jquery'],
exports:'Backbone'
}
}
});
代码中的shim属性,专门用来配置不兼容的模块,具体来说,每个模块要定义:exports值(输出名的变量名),表明这个模块外部调用时的名称;deps数组,表明该模块的依赖。
二、CMD规范
CMD规范全称为CommonJS Module Definition,下面介绍该规范实现的关键函数。
define函数
在CMD规范中,一个模块就是一个文件,书写格式如下:
define(factory)
define是一个全局函数,用来定义模块,接受factory参数,factory可以是一个函数,也可以是一个对象或字符串。当factory参数为对象,字符串时,表示模块的接口就是该对象、字符串。比如,定义一个JSON数据作为factory参数,代码如下:
define({
'foo':'bar'
})
也可以通过字符串定义模板模块,代码如下:
define(`I am a template , my name is {{name}}`);
factory为函数时表示模块的构造方法。执行该构造方法,可以得到模块向外提供额接口。factory方法在执行时,默认会传入三个参数:require,exports和module,代码如下:
define(factory(require,exports,module){
//模块执行
})
define也可以接收两个以上的参数,语法如下:
define(id?,deps?,factory)
字符串id表示模块的标识,数组deps是模块依赖,实例代码如下:
define('hello',['jquery'],function(require,exports,module){
//模块代码
})
define.cmd 方法可用来判定当前页面是否有CMD模块加载器,实例代码如下:
if(typeof define==="function" && define.cmd){
//有Sea.js等CMD模块加载器存在
}
require函数
require是一个方法,接收模块标识作为唯一参数,用来获其他模块提供的接口。实例代码如下:
define(function(require,exports){
var a=require('.a/');//获得模块a的接口方法
a.doSomething();//调用a模块中的方法
});
exports对象
exports对象,用来向外提供模块接口,实例代码如下:
define(function(require,exports){
exports.foo='bar';//对外提供foo属性
exports.doSomething=function(){};//对外提供方法
});
除了给exports对象增加成员之外,可以使用return语句直接向外提供接口,实例代码如下:
define(function(require){
return {
foo:'bar',
doSomething:function(){}
};
});
如果return语句是模块中的唯一代码可以简化如下代码:
define({
foo:'bar',
doSomething:function(){}
})
Sea.js作为CMD规范的经典实现,追求简单、自然的代码书写和组织方式,具有以下几个核心的特性。
简单友好的模块定义规范,Sea.js遵循CMD规范,可以像Node.js一样书写模块代码
自然直观的代码组织方式,依赖自动加载,配置简洁清晰可以让开发者更好到享受编码的乐趣。
seajs.config({
//Seajs在解析顶级标识时,会相对base路径来进行解析的
base:'../sea-modules',
//当模块标识很长时,使用alias简化
alias:{'jquery','jquery/jquery/jquery/1.10.1/jquery.js'}
})
//加载入口模块
seajs.use('../src/main')
main是程序的入口文件,实例代码如下:
define(function(require,exports,module){
//通过require引入依赖
var $=require('jquery');
//通过exports对外提供接口
exports.doSomething=...
//或通过module.exports对外提供整个接口
module.exports=...
});
以上是关于JS高阶七(JS模块化下)的主要内容,如果未能解决你的问题,请参考以下文章