组件模块化最佳实践

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) {

  // 模块代码

});   

requireexportsmodule三个参数可酌情省略

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插件
  1. 希望在文档加载完后再执行require的回调,我们可以使用domReady插件
  2. 希望构建多语言的应用,我们可以使用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环境提供的方法,它做的是:

  1. 拷贝当前appDir(未压缩的工程目录)定义的目录下的所有文件到dir(输出目录)定义的目录中, 拷贝过程中排除fileExclusionRegExp定义的文件

  2. 进入输出目录中,此时输出目录文件其实未经过压缩

  3. 根据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压缩。

这样做的好处是:

  1. 减少请求, 原来加载Main.js的时候需要递归加载所有依赖的文件,这个步骤在压缩后就不需要了,只有一个Main.js文件,并且代码经过混淆

  2. 提高响应速度,所有的文件经过gzip的方式压缩后,显著提高了传输效率


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值