javascript 面向对象编程(二):模块 Modules

前言

JavaScript 不是一种模块化编程语言,ES5 不支持”类”(class),更遑论”模块”(module)了。ES6 正式支持”类”和”模块”,但还没有成为主流。JavaScript 社区做了很多努力,在现有的运行环境中,实现模块的效果。

模块是实现特定功能的一组属性和方法的封装。

下面就让我们来试着一步一步的来探索如何实现 javascript 的模块化?

注明:本文为作者的原创作品,转载需注明出处;

javascript 模块化编程的困难

假如,我们有一个模块 module 包含一个私有属性 _count 以及两个方法 _m1_ 和 _m2_,那么最直接的定义方式就是直接使用 javascript 写出如下的代码,

1
2
3
4
5
6
7
8
9
10
11
// module 包含一个私有变量 _count,和两个方法 m1 和 m2

var _count = 0;

function add() {
_count ++;
}

function del() {
_count --;
}

这样做,主要有三个问题,

  1. “污染”了全局变量,无法保证 _count、_m1_ 和 _m2_ 不和其它模块的属性定义发生命名冲突。
  2. 模块的边界不清晰,
    不是不清晰,边界根本就没有。
  3. 模块的属性和方法定义是全局的,任何第三方可以直接更改

基于上述原因,探寻一种可行的 javascript 的模块化编程势在必行。

使用对象封装模块

为了解决javascript 模块化编程的困难,我们来尝试第一种做法,就是把模块的属性和方法封装在一个对象(Object)当中,

1
2
3
4
5
6
7
8
9
10
11
12
13
var module = new Object({

_count = 0;

add : function(){
_count ++;
};

del: function(){
_count --;
}

});

这样,的确解决了javascript 模块化编程的困难所描述的前面两个问题,通过 module 对象所提供的命名空间解决了“污染”全局变量的问题,同样,module 对象提供了清晰的域空间,清晰的模块化划分。但是依然未能解决第三个问题,通过对象封装的方式,会直接将模块的成员暴露出去,内部的状态可以被外部直接改写。比如外部代码可以直接修改模块内部 _count 的值

1
2
3
4
5
> module._count = 5;
> module.add = function(){ return "hello world" };
['function'] // 哼,赋值成功,替换原有的 function
> module.add();
'hello world' // oh yeah, 返回了 hello world。

可见,模块内部的私有属性和方法是可以被直接赋值,所以是不安全的。那么下面我们首先试图来解决私有变量被串改的的问题,

封装私有变量:构造函数的写法

有两种封装的方式,

  1. 在函数体重声明私有变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function StringBuilder() {
    // 不声明为对象属性,这样,外部就不可以通过对象直接修改 _buffer。
    var _buffer = [];

    this.add = function (str) {
    _buffer.push(str);
    };

    this.toString = function () {
    return _buffer.join('');
    };

    }

    验证,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    > sb = new StringBuilder();
    // StringBuilder { add: [Function], toString: [Function] }
    > sb.buffer
    undefined
    > sb.add("hello");
    undefined
    > sb.toString();
    hello
    > typeof(sb._buffer)
    undefined

    可见,私有变量 buffer 得到了很好的保护,外部不能直接修改 buffer 属性;但是,这样做,有一个巨大的问题,因为没有使用thisbuffer 便不再是sb 对象实例的属性了,而只是 function() 的局部变量了;啊~,这还能将 buffer 称之为sb实例的私有变量吗?应该不能吧,不仅如此,这样做,还损失了 javascript 的一大特性,就是动态扩展;比如,我们想动态的为对象sb扩展一个方法 pop 方法

    1
    2
    3
    4
    > sb.pop = function(){ _buffer.pop(); }
    [Function]
    > sb.pop();
    ReferenceError: _buffer is not defined

    可以看到,扩展失败,_buffer is not defined,错误的原因就是 buffer 不是实例的属性;这应该违背了面向对象的编程规范,既是,对象里的任何属性都应该属于该对象实例。

  2. Ok,为了不违背面向对象的编程规范,我们严格按照构造对象的方式来声明私有变量 _buffer

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function StringBuilder() {
    this._buffer = [];
    }

    StringBuilder.prototype = {
    constructor: StringBuilder,
    add: function (str) {
    this._buffer.push(str);
    },
    toString: function () {
    return this._buffer.join('');
    }
    };

    这样,虽然符合了面向对象的规范;但是,私有变量仍然可以通过外部代码通过实例进行修改,

    1
    2
    3
    4
    5
    > var sb = new StringBuilder();
    > sb._buffer = "hello";
    > sb.add("world");
    TypeError: this._buffer.push is not a function
    at StringBuilder.add() (repl:4:14);

    可见,可以直接对象实例sb对私有属性进行更改,并不能很好的保护其私有的成员变量

写在该小节的最后,封装私有变量的目的是什么?就是不让模块的私有成员变量被其它公共代码所污染;而,这个与面向对象编程有何干系?通过构造函数,利用面向对象的方式,做到模块化?怎么总觉得这里文不达意呢?作者为什么考虑把面向对象来构建模块化呢?模块化的总体思想是,两块不相干的代码,从命名空间,私有属性访问上都是完全隔离的,就可以了。当然,如果是纯面向对象的语言,那么模块化以后,模块本身也一定是面向对象并且可以实例化的,但是如果不是面向对象的语言,例如 _C_,模块化以后,模块本身并不能实例化成对象;所以,面向对象与模块化是两个完全不等价的东西,那么利用面向对象的方式(既使用构造函数的方式)来解决 javascript 模块化的问题,本身”方向上”就出现了问题。所以,最后,我对作者(阮一峰)此小节的解决问题的方向上是持否定态度的。

再次写在本小节最后,其实,这里描述了这么多,也就是阐述了一个东西,javascript 的对象,不能保护其私有变量;因为 javascript 本身在面向对象的层面上就没有 private 这个概念。

建议,可以直接跳过此小节,直接进入下一小节。

封装私有变量:普通函数的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function module(){

var _count = 0;

function add(){
_count ++;
}

function del(){
_count --;
}

function get(){
return _count;
}

return {
add: add,
del: del,
get: get
}

}

实验一把,

1
2
3
4
5
6
7
8
9
10
11
12
> var m = module();
undefined
> m.add();
undefined
> m.get();
1
> m._count = "hello" // 试图改变 module 的私有变量 _count
'hello' // 貌似成功了
> m.add(); // 纳里,递增成功,看来 module 的私有变量 _count 没有被改变
undefined
> m.get();
2 // 嗯,的确没有被改变,那是为什么呢?

可以看到,即便是我们通过给m对象直接赋值的方式去修改 _count 属性,虽然赋值成功,但是并没有真正的修改到模块本身的私有变量 _count;这是为什么呢?下面,我们通过 debug 一步一步的来观察,
从 debug 的结果来看,当执行到 m.count = “hello” 的时候,对象 _m_ 的确被赋予了一个新的属性 _count = “hello”

但是这个属性并不影响 module 中方法 add()del() 以及 get() 的行为,它们依然使用的是最开始 module 函数 所定义 Number 类型 的 _count 属性,那这时怎么一回事呢?其实,这就是利用了函数闭包的特性,闭包 是一个内嵌函数的特性,它会记住外层函数的属性(通过新开辟一块私有内存存放),且该属性是不能被其它调用所更改的,只能被相对应的闭包函数进行更改,本例子中对应的闭包函数有 add(), del()get();通过 debug 的过程,我们可以清晰的发现,该变量 _count 是被保存在一个名叫 closure 的内存区域中的(我将其称为“闭包区域”);(有关闭包的内容,详情参考闭包章节)

于是,我们通过函数的闭包法则,成功的实现了模块的私有变量的作用范围;但是,它依然存在两个重要的问题,

  1. module 方法的引用可以被赋值进而被覆盖
  2. 内存泄露
    我们可以通过var m = module()的方式随时获取一个新的 module 对象,这种方式看似是非常好的,但是,要知道,每次新创建一个新对象 _m_,就会意味着新创建一个函数闭包,而闭包是需要新开拓一块内存区域 closure 来存储变量,这是相当耗费内存的 ➥ 这其实就构造了一个具有memory leak缺陷的模块代码,其它程序员在引用该模块的开发过程当中,容易因为这个原因造成内存泄露;那么,有没有什么办法能够避免呢?有的,答案就在下一章节,立即执行函数的写法。补充,为什么 javascript 会这么麻烦?是因为,没办法通过单例模式来生成 function 对象,因为只要一调用 function(),新的闭包必然生成;所以,需要一种变通的方式来模拟单例的生成,那就是下一章所介绍的 立即执行函数

封装私有变量:立即执行函数的写法

使用立即执行函数(Immediately-Invoked Function Expression,IIFE),将相关的属性和方法封装在一个函数作用域里面,可以达到不暴露私有成员的目的。

什么是立即执行函数?

其实就是使用符号(),比如我们有一个方法

1
2
3
function sayHello(){
console.log("hello world");
}

那么,

1
sayHello

表示的是该函数的 _引用_,而,

1
sayHello();

表示就是执行该函数;➥ 也就是立即执行叫法的由来;

那么,如果我们有一个匿名函数呢?如何立即执行一个匿名函数?很容易想到的是,类似于这样的写法

1
function(){ ... }()

但是这样写,编译报错,因为,编译器在编译过程中,发现 _行首_ 是以 function 打头的时候,会认为这是一个语句,既是在定义一个 function,而使用这样的写法来定义一个 function语法上是错误的,所以编译不通过;所以,我们需要换成另外一种写法,用(开头,并以)结尾,让编译器不认为是在定义一个 function 语句,而是在执行一个完整的function表达式;所以,我们有了下面这种写法,

1
(function(){...}())

或者

1
(function(){...})()

两种方式定义匿名立即执行函数都是是等价的,只要让编译器不认为是在定义一个函数语句即可。当然,因为我们是在调用一个匿名函数,理所当然是可以通过()传递参数的,

1
2
3
(function(){...}( args... ));
// 或者写成
(function(){...})( args... );

更多详细的介绍参考立即执行函数章节

总结,所谓的立即执行,就是在声明方法的同时执行

使用立即执行函数实现模块的单例模式

立即执行模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var module = (function () {
 var _count = 0;

 var add = function() {
_count ++;
 };

 var del = function() {
_count --;
 };

var get = function(){
return _count;
};

 return {
  add : add,
del : del,
get : get
 };
})();

通过这样的方式,你就没有任何机会再去调用构造 module 的匿名函数了,因为该匿名函数在刚初始化完成后,就被立即调用,将结果赋值给了 module 对象,每次调用模块对象 module 实例得到的就是这个匿名函数首次执行的结果对象;➥ 通过这样的方式,也就实现了 javascript单例模式,也就避免了闭包带来的内存泄露的隐患;其实,写到这里,给我的感受就是,好麻烦,但的确又没有其它更好的办法….

Agumentation 俗称放大模式

如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用“放大模式”(augmentation);

动态扩展原有模块

我们为 module 动态扩展两个新的方法 sayHello()set(count)

1
2
3
4
5
6
7
8
9
var module = (function (mod){
mod.sayHello = function(){
return "hello world!";
};
 mod.set = function (count) {
_count = count;
 };
 return mod;
})(module);

实现的核心就是,将 module 作为参数传递进入 IIFE 匿名方法,达到模块动态可扩展的目的,这样,如果一个模块过大,就可以将其拆分成几个小的模块来实现和扩展。
我们来试验一把,首先,我们来试试 sayHello

1
2
> module.sayHello();
'hello world!'

成功,我们再来试试 set(count)

1
2
3
4
5
6
> module.set(10);
undefined
> module.add();
undefined
> module.get()
1

失败,我们试图通过扩展的方式去修改原 module 对象中的 _count 为 _10_,期望累加后得到 _11_,但是,可以看到,输出为 _1_,表示这里 _count 值的设置没有达到我们理想中的预期的结果。为什么呢?其实很好理解,这里的函数 set闭包的上下文是该新的匿名函数,与之前创建 module 的匿名函数是不相同的,因此两者闭包所对应的上下文也就不同,因此,闭包所使用的内存区域也就不同,那么两个变量 _count,虽然名称相同,然则各自在不同的内存区域中,是两个截然不同的对象;所以,这里无法通过 _count = count 在新的匿名函数中来给原 module 的私有变量 count 进行赋值。

所以,这里要注意的是,通过 _放大模式_ 来进行模块的扩展是不能放大私有变量的

引用第三方模块

1
2
3
4
5
6
var module = (function ($, window, doc) {
 //...
return {
...
}
})(jQuery, window, document);

在定义模块的时候,可以将第三方模块作为参数传入并使用。

弊端

依然不能解决 module 方法的引用可以被赋值进而被覆盖的问题;这个是这门在语法上不太严格的 javascript 的通病。

写在最后

javascript 由于其原始,所以够折腾,很多面向对象的方式需要人工去创建;难怪一些非常痴迷于技术的人,非常喜欢一天到晚的去折腾 javascript,因为它太灵活了,它本身虽然不是一个具备模块化、面向对象的语言,但是你可以去试图定义你自己想要的模块化方式甚至于面向对象的方式来实现模块化和面向对象…….;够折腾! Oh Yeah~ 感觉真好~~!

与 Java 模块化的对比与思考

要能够全方位的掌握并牢记一门语言的特性,做对比,是一件再好不过的事情了;

Java,噢,JavaJava语言想比于javascript就严谨多了,通过类的包路径 ( package path )提供了模块的 namespace,也就在你定义一个模块,也就是的时候,天生的就不会“污染”全局变量了;class定义类的时候,成员变量天生就具备 privatepublic,甚至还有 protected 还有个什么?忘了… 嗯,是的,javascript 到现在,其类对象都不能保护自己的 private 变量,参考封装私有变量:构造函数的写法,不是它不想做,而是它如果要按照类和对象的标准定义的的方式来做,做不到;另外,由于本身不具备面向对象的属性,因此 javascript 定义单例模式的方式也就显得很奇怪。

References

JavaScript Modules: A Beginner’s Guide