RequireJS使用心得

一直对于JS模块化开发心存敬畏,尽管很早之前就接触到了RequireJS(在微擎里面),实际项目中却仍旧采用的是最原始的在HTML文档中script标签里面写代码。一方面是因为后台的同学有时候需要自己写JS处理逻辑;另一方面是后台模板将共用的头部样式表和底部脚本都分离为单独文件,管理起来也不是很麻烦。因此一直没有去了解require.js。 最近一个多月一直在折腾新的博客,想着要不要试试require.js,然后一发不可收拾。

<!--more-->

貌似现在前端有了那么多开发环境和打包工具,require.js似乎也不是那么先进的样子,然而我想,模块化的思想终归是异曲同工的。下面就是使用require.js的一点心得。

基本上都是参考官网文档和几篇博客进行学习的,下面是链接:

1. 什么是模块

学习使用require主要是为了避免直接在页面标签中写JS代码导致的一些问题:

  • 由于存在由于全局变量的问题,在JS代码中很容易造成命名冲突,尽管可以使用IIFE(立即执行函数)缓解这个问题。
  • 不同页面之间JS代码的管理和复用,之前的做法是维护一个公共的脚本文件,并定义相关的函数,在页面中按需执行。导致的问题是不同页面的逻辑可能冗杂在同一个公共的文件中,维护起来比较困难。
  • JS脚本的按需加载,前面提到后台将所有脚本都分离在一个单独的文件并在视图模板引入,导致的问题是每个页面都包含了全部的脚本,而事实上大部分脚本都是不会执行的,无形中增加了多次没有意义的访问请求。

使用模块化开发可以解决这些问题。

所谓模块化,要求在编写代码的时候,按层次,按功能,将独立的逻辑,封装成可重用的模块,对外提供直接明了的调用接口,内部实现细节完全私有,并且模块之间的内部实现在执行期间互不干扰。

注意,模块化开发只是一种思想,这里要讨论的AMD规范是模块化开发的一种实现方式(并不是实现),也就是说,遵循AMD规范的开发方式,可以实现一种模块化开发。 我对于模块化的理解是:一个JS文件单独处理一个独立的逻辑,这样做的好处是便于管理和维护。一个页面上根据需要引入相关的JS文件。此外,在这个模块处理逻辑的时候,也允许使用依赖其他的模块;当然,也允许其他模块依赖该模块。类似于搭积木一样,实现整个项目,甚至于还可以在其他项目类似功能的地方使用同一个模块,大大减少重复代码的书写。PS:我的理解是如此浅陋,因此下面的"模块化"字段自动打上双引号 (/斜眼笑)。

实际上,在使用SCSS写CSS代码的时候,不知不觉中已经在使用这种模块化的思想了。在前面的那篇关于CSS代码维护的思考,为了应对客户无休止的页面需求更改,我将样式表拆分成了数个目录下不同的独立_xxx.scss文件(定义模块),然后使用目录的入口文件引入相应目录的颗粒样式表(加载模块),最后在main.scss中引入这几个入口文件,输出最终的样式表,这样在模块化的CSS文件上定位问题和替换样式就变得简单许多。

使用SCSS预编译的@import指令可以完成CSS的模块化管理,那么,这里谈到的JS模块化在浏览器中怎么实现呢?没错,就是使用require.js,下面就来介绍今天的主角。

2. Require使用

require.js可以帮助我们实现类似于遵循AMD规范的模块化代码。先忽略语法,我们最需要明白的是:require.js为我们提供了两个最主要的功能,一个是定义模块,一个是加载模块。有了这两个功能,我们就能在浏览器中实现JS模块化开发。跟官方文档一样,我们先了解如何加载模块,再学习如何定义模块

2.1. 目录结构

加载模块的基础应当是规划项目的目录结构,在学习语法之前,应当思考一下如何管理我们的代码结构,require.js官方推荐的目录结构是如下的扁平结构

html/

js/
-    app/
        sub.js
-    lib/
        jquery.js
        canvas.js
        app.js

其中lib存放第三方库文件,app文件夹用来存放项目的逻辑代码。实际中,我们又可以根据页面或者是项目逻辑将app细分成不同的文件夹,比如

-    lib/
        jquery.js
-    index/
        index.js
-    login/
        login.js
        signin.js
        validate.js
-    page1/
        page1.js
……

根据官方的建议:为了避免凌乱的配置,最好不要使用多级嵌套的目录层次来组织代码,而是要么将所有的脚本都放置到baseUrl中,要么分置为项目库/第三方库的一个扁平结构。

2.2. 加载模块

清晰地组织相关页面的JS文件,在维护起来会十分方便,因为我们可以快速定位源文件。然而,平时我们不是可以直接根据<script>src属性寻找到相关的文件吗? 没错,上面按层次划分目录只是模块化的第一步,实际项目中一个页面肯定不仅仅只引入一个<script>标签,我们是根据脚本的先后顺序来确定插件之间的依赖关系的。换句话讲,页面上的脚本标签必须严格按照他们之间的依赖关系来书写,这里存在两个问题:

  • 脚本之间的依赖关系并不能十分明显地展示出来,比如jquery.jsbootstrap.js,尽管我们知道bootstarp.js依赖jquery.js,但是并不能直观地看出来;某些jquery插件使用jquery.xxx.js的方式来表明这是一个依赖于jquery的插件,然而总体来说,我们仍然需要一种能够清晰明了地查看脚本之间的依赖关系的方法
  • 同前一条,页面上也可能存在互不依赖的脚本,他们之间的加载顺序可以是任意的,而为了快速定位和调试问题我们也需要一种区分各脚本之间是否存在依赖关系的方法。

require.js提供的解决办法是:一个页面只使用一个<script>标签。

没错,一个页面只使用一个<script>标签,这个标签用于加载require.js文件;一个页面对应一个应用脚本文件(这里暂且先叫做page1.js),该文件的路径保存在这个<script>标签的data-main自定义属性上。

// data-main路径上可以不用书写.js后缀
<script src="js/require.js" data-main="js/page1/page1.js"></script>

这样,在运行到这个脚本的时候会自动加载page1.js,然后会自动加载page1.js中所声明依赖的脚本模块,然后执行相关逻辑。需要注意的是所加载的模块,在定义的时候已经指定了其所依赖的脚本模块,require.js在加载的时候会自动处理这些依赖关系并保证脚本正确执行,如何定义模块我们后面会详细了解。现在让我们看看如何在require.js中声明依赖的脚本模块(假设我们的目录组织如前面所说的那样)。

// page1.js

// 配置基础路径和依赖组件名称
require.config({
    baseUrl:'js/',
    paths:{
        'jquery':'lib/jquery-2.0.3'
    }
})

// 加载相关组件
require(['jquery'],function ($) {
    console.log($);
});

实际上require.js是通过module ID来加载依赖模块的,从上面代码可以看见这个页面应用脚本page1.js中分成了两部分:配置和加载。

2.2.1. 配置

require.config配置主要定义baseUrlpaths属性(另外还有一些其他比较重要的属性,稍等片刻)。这两个属性用来解析对应的模块路径。

基础路径baseUrl

baseUrl指定了一个目录,然后requirejs基于这个目录来寻找依赖的模块。 baseUrl的所表示的目录值,可为绝对路径,可为相对路径,绝对路径很容易理解,但是相对路径是相对于什么呢?

  • 在没有任何配置和data-main的情况下,baseUrl为引入require.js文件的那个HTML文件所在的目录
  • 如果设置了data-main,则baseUrl的值为该属性所表示的那个脚本文件所在的目录
  • 如果在require.config()中手动配置baseUrl的值,则baseUrl的值即为配置的值

上述值的优先级从上到下依次增大。

模块路径paths

如果仅仅根据baseUrl来加载模块,存在下面两个问题:

  • 每个模块基于baseUrl的路径名可能会很长
  • 模块的存放位置可能改变,导致维护十分困难

为了解决这两个问题,可以使用paths来配置每个模块的路径,键值可以直接具体到某个模块文件,也可以用来表示某个目录。 然后require.js就会根据baseUrl + paths来解析模块的module ID

模块IDmodule ID

所谓的module ID指的是在使用define()require()函数时声明的模块的别名,require.js会根据module ID所表示的路径来加载相应的组件。

// 'a''lib/b' 都是 'module ID'
require(['a','lib/b'],function(){
    // todo
})

这里需要理解的是,一个module ID所表示的就是一个模块文件(不带后缀名.js):

  • 如果某个paths代表的就是一个具体的模块文件,则可以直接将该键名作为module ID;否则需要将该paths所表示的目录下的某个模块文件名作为module ID
  • 即使不声明某个moduleID的paths,也可以直接根据baseUrl来加载模块,paths主要是为了减小module ID的长度,以及便于维护。

如果一个module ID符合下述规则之一,其ID解析会避开常规的baseUrl+paths配置,而是直接将其加载为一个相对于当前HTML文档的脚本:

  • 以 ".js" 结束.
  • 以 "/" 开始.
  • 包含 URL 协议, 如 "http:" or "https:".

注意第二点,并不是相对于根目录,而是当前引入require.js的HTML文档的所在目录!

2.2.2. 加载

配置好相关的module ID之后,需要显式地指定所依赖的模块,require.js才会进行加载。 require()函数的第一个参数是一个module ID的数组,声明需要加载的脚本文件(后面全部简称为模块)。前面提到module ID可以不需要配置paths,因此这里可以直接使用某个模块文件的路径作为module ID 的方式进行声明(这里也可以算作是目录不要嵌套太深的一个理由)。

require()函数的第二个参数是一个回调函数,在加载相关的模块之后就会执行该函数。前面提到,一个模块应当返回一个可以调用的接口供外部使用,那我们怎么在这个回调函数中调用前面加载的模块的接口呢,这里有两种办法:

// 根据`module ID`数组,依次定义相关模块的输出变量,并按顺序作为这个回调函数的参数
require(['jquery','testA','testB'],function ($, a, b) {
    console.log($);
    console.log(a);
    console.log(b);
});

// 使用require(module ID)的方式
require(['jquery','testA','testB'],function () {
    var $ = require('jquery');
    var a = require('testA');
    var b = require('testB');
});

相比之下我更喜欢第二种方式调用模块提供的接口。

2.2.3. 备份地址

我们不仅可以声明使用本地模块,也可以引用第三方库文件,只需要在Paths中正确配置路径即可。 但是我们从CDN中引入依赖脚本,又必须处理引入失败的情形,以前的处理办法是检测window.jQuery是否存在,然后创建<script>标签。 在require中,可以在配置paths的时候可以使用一个数组,require会在前面的路径加载错误的时候去尝试加载后面的路径。但是需要注意的是这种配置只能用于module ID表示具体的脚本文件的情形。

paths:{
    'jquery':[
            'http://cdn.bootcss.com/jquery/3.1.1/jquery',
            'lib/jquery-2.0.3'
        ]
}

如果第一个文件请求失败或者超时,就会加载本地文件,是不是很方便...

2.3. 定义模块

现在我们知道了如何加载模块(尽管还有一些需要注意的地方没有找到),那么如何编写合适的模块呢? 前面提到,定义的模块可以显式地列出其依赖关系,并以函数参数或者require(module ID)的形式使用这些依赖构造当前模块,而无需引用全局变量,避免全局名称空间污染。

2.3.1. 模块返回

根据模块的功能,有下面几种定义模块的方式。

// 模块仅包含值对且没有依赖
define({
    test:function () {
        console.log(1);
    }
});

//如果一个模块没有任何依赖,但需要一个处理工作的函数,则在define()中定义该函数
define(function(){
    var test = function () {
        console.log(1);
    };

    return {
        test: test
    }
});

如果模块存在依赖:则第一个参数是依赖的名称数组;第二个参数是函数,这里就跟定义页面应用脚本类似 这里也可以使用require.config()来配置模块依赖文件的路径,这样就不需要再页面应用脚本去声明依赖了,各个模块之间的依赖关系一目了然。

define(['jquery'],function($){
    console.log($);
    var test = function () {
        console.log(1);
    };

    return {
        test: test
    }
});

这里的难点是:如果定义模块A依赖于jquery,然后页面应用脚本依赖于A,那么这个jquery的相对路径到底是相对于模块文件A,还相对于页面应用脚本呢?

2.3.2. 模块依赖

我在页面应用脚本和模块A文件中分别定义了两个不同的jquerypaths(但是声明的module ID均是jquery),得到的测试结果是,

  • 只要这两个文件的某一个文件配置了jquerybaseUrl + paths,则均可以正确加载
  • 如果两个文件都配置了module ID(页面应用脚本没有声明加载),则会加载模块A的那个module ID所指定的路径;
  • 如果页面应用脚本也声明加载了jquery模块,则require.js只加载页面应用脚本声明的那个jquery文件。

这里让我困惑的是如果模块A对jquery的依赖有版本限制的话,上面的情形就可能导致出现错误,尽管可以声明不同的module ID进行处理,但并不是很完美的解决办法。

另外如果定义多个模块都需要依赖某个文件(这里还是用jquery举例),考虑下面几种情形:

  • 根据这些模块文件的位置声明不同的module ID路径(尽管路径最终的文件都是同一个jquery.js文件,但是每个模块的存放目录不同,导致最终解析的module ID也不同)
  • 在页面应用脚本配置对应的module ID表示具体的juqery.js,然后在定义的模块中直接声明使用jquery作为module ID即可(似乎还不错,但是思考一下,一个网站应该存在多个页面应用脚本,使用这种方法则需要在每个页面应用脚本上都配置一次关于jquerymodule ID,与上面的直接在定义的模块文件中进行声明也没有多大区别)。

为了处理这种问题,我的解决办法是,单独声明一个config.js的模块文件,直接配置整个项目全部的模块paths,表示具体的模块文件,然后在每个页面应用脚本先加载该配置文件,然后再加载该页面的依赖脚本。

// config.js
require.config({
    baseUrl:'js/',
    paths:{
        'jquery':'http://cdn.bootcss.com/jquery/3.1.1/jquery',
        'math':'base/math'
    }
});

// page1.js
require(['js/config.js'],function(){
    // 由于加载是异步的,必须等待config.js文件加载完成才能正确解析其他module ID
    require(['jquery','math'],function () {

        var math = require('math');
        math.test()
    });
});

// 模块文件
// 直接使用config.js里面配置的paths作为module ID即可
define(['jquery'],function($){
    console.log($);
    var test = function () {
        console.log(1);
    };

    return {
        test: test
    }
});

这样就可以将所有的模块统一在一个文件中进行管理,即使以后目录结构改变,或者模块文件重命名,也只需要更改config.js文件中的路径即可,这也正是我目前使用的require.js的方式。

3. 文件打包

尽管正确使用require.js可以让我们很方便地在目录结构上管理我们的代码,良好的模块化必定依赖于多个细分的文件,这样做的后果就是如果不加以处理,一个页面中可能会请求数十个脚本文件,尽管require.js在加载文件的时候使用了asyncdefer属性,但仍旧需要等待异步和延迟的脚本加载并运行完毕之后浏览器才会进入事件驱动阶段。 因此,在编写代码的时候,处于便于维护和管理的目的,我们应该使用模块化管理文件;但是在项目完成即将上传的时候,处于减轻服务器负担的目的,我们应该将零散的模块文件打包,依此减少浏览器请求数量,提高页面加载效率。 require.js提供了一个相关的打包工具,叫做r.js,经过一番尝试之后,重新折回来记录一点使用方法和心得。

3.1. 使用方法

r.js是一个在node环境下的脚本,通过我们自定义的配置文件,将某个使用require的应用脚本文件与他的相关依赖模块整合到一个文件中,前面提到的配置文件就是用来声明该模块的文件的相关依赖文件的路径的。 这里假设我们的目录还是上面的page1那样 首先,将r.js移动到需要打包模块的目录下,也就是js/page1/下,新建一个build.js的文件,该文件用于配置相关参数。

// build.js
({
    //基础路径
    baseUrl: '../../js',
    // 依赖模块的路径
    paths: {
        a: 'lib/a',
        b: 'lib/b',
        marked:'empty',
    },
    // 需打包应用文件的名称
    name: "page1/page1",
    // 输出文件名
    out: "dist/page1-built.js"
})

关于build.js还有一些其他的参数,暂时还没有去了解。刚看见这个文件的时候,心想这不跟前面提到的config.js很像吗?

在配置好build.js之后,就可以打开控制台,输入

node r.js -o build.js

然后就会在指定的out路径生成对应打包好的文件了,打开输出文件可以看见,确实是将相关的依赖文件都整合到了这一个文件中,并进行了压缩(但是有时候我发现文件虽然打包在一起,但并没有进行整合)。

哈,不会就这么简单吧!上面只是最顺利的理想状态,实际上,在使用的时候我遇见了很多的坑。

  • build.js中的baseUrl并不是require.js中的基础路径,貌似是相对于需要打包的应用文件的相对路径,后面的pathsnameout都是根据这个配置的baseUrl来寻找的
  • r.js会尝试着对合并后的文件进行压缩,但是某些ES6的语法特效却不被支持,诸如let局部变量,action(){}对象方法简写等书写都会在控制台报错,这个就有点坑了,需要在合并之前进行babel转义,这个我并不是很了解,还得进行进一步的尝试才行
  • 之前使用了一个config.js来管理整个项目的模块,应用脚本文件先引入该配置文件之后再加载相关依赖,这里特别需要注意这里声明的config.js文件的moduleID。根据依赖关系,打包之后config.js里面的配置require.config()会出现在最前面,而后面的模块文件中使用的require(['config'])也是依赖于config.js中配置的baseUrl,这里会非常坑。实际上有了build文件,可以直接将require.config()放在data-main的应用脚本文件上,但是考虑到一个项目可能存在的多个应用脚本,统一管理模块还是十分有意义的。
  • 如果需要保留某些引入的模块路径,而不是将全部的文件都压缩到一个文件中去,可以build.js的paths属性对应moduleID赋值为"empty:",这样,在打包的时候就会保留对应模块的加载路径而不是无脑打包全部的文件了,注意这个empty:后面有个冒号~

4. 小结

关于如何管理和维护代码(CSS和JS),过去的几个月里我进行着反复的尝试和探索,未来也会进行下去。工作不应该仅仅只是实现某个功能就结束,草草了事会给自己或是后面的同事带来无数的坑。 写代码是一件很幸福的事情,写出能够维护的代码更是成就感爆棚的事情,回顾之前的代码,有的地方耦合性太强(甚至还有$(".t").parent().parent()...),有的代码重复粘贴在好几个地方,有的地方还存在各种潜在的BUG,所以,功夫尚浅,继续学习吧!接下来可以去尝试学习一下webpack了。