组件模块化最佳实践

来源:互联网 发布:web前端开发 薪资知乎 编辑:程序博客网 时间:2024/06/06 00:55

4.1 什么是模块化?(What)

我们先来看这样一个问题:

假设有一个module1.js文件里面提供了一个方法:

//module1.jsfunction foo(){  console.log("bar1");}

但是由于实现的需要,我们必须引入另外一个文件module2.js,其中恰好也包含了一个函数foo的实现

//module2.jsfunction foo(){  console.log("bar2");}

调用的时候,我们会先引入2个js文件然后直接执行foo方法:

//调用代码foo();

那么当我们同时引用这两个文件的时候,foo函数的输出就取决于两个文件的加载顺序,这样就出现了命名冲突的情况,为了解决这样的问题,我们可能想到了通过封装成对象的方式来对每个模块加上命名空间:

//module1.jsvar module1={  foo:function(){    console.log("bar1");  } }//module2.jsvar 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,可以移步:

  • 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.jsdefine(    //可选参数,定义模块的名字    "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.jsrequire(     //依赖列表           ['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的方式压缩后,显著提高了传输效率


原创粉丝点击