什么是模块化(一)

本文深入讲解模块化的概念及其在解决JavaScript文件依赖与命名冲突问题中的应用。覆盖CommonJS、AMD、CMD、UMD及ES6 Modules等规范,探讨各自的优缺点,并通过具体示例展示如何在实际项目中实现模块化。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

模块化用于解决引入多个js文件时的命名冲突和文件依赖问题。

关键字:

  • CommonJS:一个同步模块规范。这种方式通过一个叫做require的方法,同步加载依赖,然后返导出API供其它模块使用,一个模块可以通过exports或者module.exports导出API。
    CommonJS规范中,一个单独的文件就是一个模块。每一个模块都是一个单独的作用域,在一个文件中定义的变量,都是私有的,对其他文件是不可见的。
    服务端Node.JS就是用的这种方式。
  • AMD:异步模块定义,定义了一套 JavaScript 模块依赖异步加载标准,来解决同步加载的问题。通过define关键字定义模块及回调函数。
  • CMD:同步模块定义。
  • UMD:通用模块定义,规范类似于兼容 CommonJS 和 AMD 的语法糖,是模块定义的跨平台解决方案。
  • ES6 Modules:ES6标准提出的原生模块定义。

    模块化构建工具:

  • browerify:用来打包CommonJS模块以便其在浏览器里运行的模块构建工具
  • RequireJS:加载AMD的模块化构建工具
  • SeaJS:加载CMD的模块化构建工具
  • webpack:模块化构建工具,兼容CommonJS, AMD, ES6各类规范

什么是模块化?

一个页面可能会引入多个js文件,可能会有 jQuery、Bootstrap、一些插件以及一些业务代码。

<script src="jquery.js"></script>
<script src="bootstrap.js"></script>
<script src="plugin-A.js"></script>
<script src="plugin-B.js"></script>
<script src="app.js"></script>

当我们在多个 JavaScript 文件之间进行通讯时,我们可能会把一个变量挂到 window 上,变成一个全局的变量。当项目变得越来越复杂,这些全局变量也会变得越来越多,很容易出现命名冲突的情况。

而且,在很多时候,我们多个 JS 文件之间是有依赖关系的,比如说我们图中的 plugin-B。js 如果依赖了 plugin-A.js,当别人想使用 plugin-B.js 但是没有引入 plugin-A.js 的时候,那么 plugin-B.js 就不能正常运行了。所以我们遇到了一个比较繁琐的 文件依赖 问题。

所以如何解决命名冲突和文件依赖的问题呢?

首先,对于命名冲突问题,其根本原因在于所有的.js文件时共享作用域的,而且它们可能定义了一些全局变量。
解决方法就是:限制作用域,并且移除全局变量

其次,针对依赖混乱问题,我们可以通过规定一些特殊的语法,来在代码中声明依赖关系,再开发一个工具来自动化处理文件之间的依赖,就可以解决依赖混乱的问题了。

而解决以上两个问题的做法就是模块化。

模块模式

我们把每一个 .js 文件都视为一个 模块模块内部有自己的作用域,不会影响到全局。并且,我们约定一些关键词来进行依赖声明和 API 暴露。在js中有几种用于实现模块的方法和规范:

  • 对象字面量表示法
  • Module模式

以下是一些模块化规范,括号内为对应的脚本加载器

  • CMD(SeaJS)
  • AMD(RequireJS)
  • CommonJS(NodeJS)
  • ES6 Module (ECMAScript 2015)

比较有名模块化规范的是 CMD、AMD、CommonJS 和 ES6 Module,它们都是为了实现在浏览器端模块化开发的目的。前面两个规范分别来自 SeaJS 及 RequireJS,这两个规范现在基本已经很少人用了;CommonJS 由于是被 NodeJS 所采用的,所以很多人用;而 ES6 Module 自然是来自去年正式发布的 ECMAScript 2015 所采用的了,以后会逐渐成为最主要的模块化规范。

模块的基本写法

对象字面量

在对象字面量表示法中,一个对象被描述为一组包含在大括号中、以逗号分隔的名值对。

var person = {
    name:"gigi",
    sayName:function(){
        alert(this.name);
    }
}

对象字面量不需要使用new操作符进行实例化。

使用对象字面量有助于封装和组织代码,即将所有属性和方法封装在一个变量中。

var myModule = {
    //属性
    myProperty:"someValue",
    //一些用于配置的属性
    myConfig:{
        language:"en",
        useCaching:true
    },
    //基本方法
    myMethod:function(){
        //……
    },
    //用于输出配置的方法
    myMethod2:function(){
        concole.log(this.myConfig.useCaching);
    },
    //用于设置配置的方法
    myMethod3:function(newConfig){
        this.myConfig = newConfig;
    }
}
myModule.myMethod();
myModule.myMethod2(); //true
myModule.myMethod3({
    language:"fr",
    useCaching:false
});

Module(模块)模式

Module模式用于进一步模拟类的概念,通过这种方式,能够使一个单独的对象拥有公有/私有变量和方法,从而构成自己的一个私有作用域,减少命名冲突的可能性。

这一部分的内容也可以参考js学习笔记:函数——私有变量

var myModule = (function(){
    var counter = 0;
    return {
        incrementCounter:function(){
            counter++;
        },

        resetCounter:function(){
            counter = 0;
        }
    };
})();
myModule.incrementCounter();
myModule.resetCounter();
  • 使用一个返回值为对象的立即调用函数。结果就是myModule对象就是函数返回的那个对象。
  • 使用闭包来封装私有变量和方法,防止其泄露至全局作用域、发生命名冲突。

通过该模式,只需返回一个公有API,而其他的一切则都维持在私有闭包里。
这为我们提供了一个屏蔽处理底层事件逻辑的解决方案,同时只暴露一个接口供应用程序和其他部分使用。

需要理解的是,在js没有真正意义上的私有,我们只是用函数作用域来模拟这个概念。
在Module模式内,由于闭包的存在,声明的变量和方法只在该模式内部可用。但在返回对象上定义的变量和方法,则是对外部可见的。

在这个例子中,代码的其它部分无法直接调用incrementCounter()和resetCounter()。
并且counter变量实际上是完全与全局作用域隔离的,因此它表现的就像是一个私有变量。他的存在被局限在模块的闭包内,因此唯一能够访问其作用域的代码就是这两个函数。

下面是一个包含命名空间、公有和私有变量的Module模式的例子:

vat myNamespace = (function(){
    //私有计数器变量
    var myPrivateVar = 0;

    //私有函数
    var myPrivateMethod = function(foo){
        console.log(foo);
    };

    return {
        //公有变量
        myPublicVar:"foo",

        //调用私有变量和方法的公有函数
        muPublicFunction:function(bar){
            myPrivateVar++;
            myPrivateMethod(bar);
        }
    }
})()

可以将全局变量作为参数传递给匿名函数并在模块中使用,如jQuery。在引入的同时也能给予其本地命名。

var myModule = (function($){
    function privateMethod1(){
        $(".container").html("test");
    }
    return {
        publicMethod:function(){
            privateMethod1();
        }
    }
})(jQuery)
myModule.publicMethod();
  • 优点
    Module模式支持私有数据,并且只有公有部分能够接触这些私有数据。

  • 缺点
    由于访问公有和私有成员的方式不同,当我们想改变成员的可见性时需要修改每一处使用该成员的代码。
    同时也无法为私有成员创建自动化单元测试。

模块规范

上面介绍了js模块的基本写法,下面介绍如何规范地使用模块。

模块规范用于统一模块的写法,方便互相使用。

目前,通行的Javascript模块规范共有两种:CommonJS和AMD。

AMD规范

概览

AMD规范则是Async Module Define,它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

  • 如果是在服务器端,模块文件一般都已经存在于本地,所以加载起来比较快,不用考虑异步加载的方式,所以CommonJS规范比较适用。
  • 如果是在浏览器环境,要从服务器端加载模块,这是就必须采用异步模式,因此浏览器端一般采用AMD规范

define

AMD规范使用define方法定义模块:

define("myModule",['package/lib'], function(lib){
  function foo(){
    lib.log('hello world!');
  }

  return {
    foo: foo
  };
});
  • 第一个参数是模块id。
  • 第二个参数是依赖的模块,这些模块都会在后台无阻塞地加载
  • 第三个参数则作为加载完毕的回调函数。回调函数将会使用载入的模块作为参数。

require

require用于加载模块。可以用于动态加载依赖。
但是与commonjs的用法不同,要求两个参数:要加载的模块和回调函数。

//foo和bar是两个外部模块,两个模块加载以后的输出作为回调函数的参数传入(foo和bar)。
require(["foo","bar"],function(foo,bar){
    foo.doSomething();
})

下面是一个动态加载依赖的示例:

define(function(require){
    var isReady = false;
    var fooBar;

    //动态加载依赖
    require(["foo","bar"],function(foo,bar){
        isReady = true;
        foobar = foo()+bar();
    })

    //依然返回一个模块
    return {
        isReady:isReady,
        fooBar:fooBar
    }

})

目前,主要有两个Javascript库实现了AMD规范:require.js和curl.js。
使用RequireJS加载AMD模块:

require(["app/myModule"],function(myModule){
    //开始主模块
    var module = new myModule();
    module.doStuff();
})

除了RequireJS还可以使用Curl来加载AMD模块

curl(["app/myModule.js"],function(myModule){
    //开始主模块
    var module = new myModule();
    module.doStuff();
})

AMD优点

AMD展现的不仅仅是一个典型的Module模式,它具有许多优点:

  • 封装模块定义,避免全局命名空间污染
  • 不依赖于服务器端工具,适用于浏览器端(与CommonJS相比)

commonjs模块规范

概览

根据这个规范,每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类、都是私有的,对其他文件不可见。
与AMD不同,这种模块周围没有函数封装器(所以我们看不到define那种用法)。

CommonJs规范规定,每个模块内部,module变量代表当前模块

module.exports

module对象的exports属性是对外的接口。加载某个模块,其实是加载该模块的module.exports属性值。

//example.js
var x = 5;
var addX = function (value) {
  return value + x;
};
module.exports.x = x;
module.expotrs.addX = addX;

上面代码通过module.exports输出变量x和函数addX。

require用于加载其他模块。

var example = require('./example.js');
console.log(example.x);
console.log(example.addX(1));

exports变量

为了方便,Node为每个模块提供一个exports变量,指向module.exports
这等同在每个模块头部,有一行这样的命令:

var exports = module.exports;

造成的结果是,在对外输出模块接口时,可以直接向exports对象添加方法。

exports.area = function (r) {
  return Math.PI * r * r;
};

exports.circumference = function (r) {
  return 2 * Math.PI * r;
};

注意,不能直接将exports变量指向一个值,因为这样等于切断了exports与module.exports的联系。

//不要这样!!
exports = function(x) {console.log(x)};

上面这种写法就导致exports不再指向module.exports了。

下面的这种写法也是无效的:

exports.hello = function() {
  return 'hello';
};

module.exports = 'Hello world';

上面代码中,由于module.exports被重新赋值了,因此hello函数是无法对外输出的。

这意味着,如果一个模块的对外接口,就是一个单一的值,不能使用exports输出,只能使用module.exports输出。

module.exports = function (x){ console.log(x);};

如果你觉得,exports与module.exports之间的区别很难分清,一个简单的处理方法,就是放弃使用exports,只使用module.exports。

require命令

基本用法

Node使用CommonJS模块规范,内置的require命令用于加载模块文件
require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象。如果没有发现指定模块,会报错。

// example.js
var invisible = function () {
  console.log("invisible");
}

exports.message = "hi";

exports.say = function () {
  console.log(message);
}

运行下面的命令,可以输出exports对象。

var example = require('./example.js');
example
// {
//   message: "hi",
//   say: [Function]
// }

如果模块输出的是一个函数,那就不能定义在exports对象上面,而要定义在module.exports变量上面。

module.exports = function () {
  console.log("hello world")
}
require('./example2.js')()

上面代码中,require命令调用自身,等于是执行module.exports,因此会输出 hello world。

加载规则

require命令用于加载文件,后缀名默认为.js

var foo = require('foo');
//  等同于
var foo = require('foo.js');

模块的加载机制

CommonJS模块的加载机制是,输入的是被输出的值的拷贝
也就是说,一旦输出一个值,模块内部的变化就影响不到这个值

下面是一个模块文件lib.js:

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

上面代码输出内部变量counter和改写这个变量的内部方法incCounter。

然后,加载上面的模块。

// main.js
var counter = require('./lib').counter;
var incCounter = require('./lib').incCounter;

console.log(counter);  // 3
incCounter();
console.log(counter); // 3

上面代码说明,counter输出以后,lib.js模块内部的变化就影响不到counter了。

AMD与CommonJS

二者都是有效的模块格式,有着不同的最终目标。

AMD采用浏览器优先的开发方法,选择异步行为,但是没有任何I/O的概念。
AMD支持对象、函数、构造函数、字符串、JSON以及很多其他类型的模块,在浏览器中原生运行,使用非常灵活。

另一方面,CommonJS采用服务器优先的方法,采用同步行为,同时支持非包装模块,得以摆脱AMD的define包装器。

CMD

另一种优秀的模块管理工具是 Sea.js,它的写法是:

define(function(require, exports, module) {
    var foo = require('foo'); // 同步
    foo.add(1, 2); 
    ...
    require.async('math', function(math) { // 异步
        math.add(1, 2);
    });
});

Sea.js 也被称为就近加载,从它的写法上可以很明显的看到和 Require.js 的不同。我们可以在需要用到依赖的时候才申明。

Sea.js 遇到依赖后只会去下载 JS 文件,并不会执行,而是等到所有被依赖的 JS 脚本都下载完以后,才从头开始执行主逻辑。因此被依赖模块的执行顺序和书写顺序完全一致。

由 Sea.js 引申出来的规范被称为 CMD(Common Module Definition)。

ES6 Modules

在JS的最新规范ECMAScript 6 (ES6)中,引入了模块功能。
ES6 的模块功能汲取了CommonJS 和 AMD 的优点,拥有简洁的语法并支持异步加载,并且还有其他诸多更好的支持。
之前在CommonJS中讲过,其输出的是输入的复制,因此模块内部的任何变化都不会再影响输出。

但在ES6 Module中,通过 import 语句,可以引入实时只读的模块:

//counter.js
export let counter = 1;
export function increment(){
    counter++;
}
export function decrement(){
    counter--;
}
//main.js
import * as counter from 'counter';
console.log(counter.counter); //1
counter.increment();
console.log(counter.counter); //2

UMD

UMD 希望提供一个前后端跨平台的解决方案(支持AMD与CommonJS模块方式)。
UMD的实现原理就是:

  • 先判断是否支持AMD(define是否存在),如果存在就使用AMD方式加载模块。
  • 再判断是否支持CommonJS(exports是否存在),如果存在则使用CommonJS
  • 如果前两个都不存在,则将模块公开到全局。
(function(root, factory){
    if(typeof define == "function" && define.amd){
        define([],factory);
    }else if(typeof exports == "object"){
        module.exports = factory();
    }else{
        root.returnExports = factory();
    }
}(this,function(){
    ……
    return {
        ……
    };
}));

参考链接:

JavaScript 模块化入门Ⅰ:理解模块

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值