附录A JavaScript的高级特性 - Node.js开发指南

返回介绍

附录A JavaScript的高级特性

发布于 2017-06-21 字数27240 浏览 996 评论 0

长久以来,JavaScript 总是被广大的专业开发者轻视,不少人以为 JavaScript 是像VBScript 一样的雕虫小技,或者说是给非专业的网页设计者用的简易工具。而早期的因特网上也恰恰流传着大量低质量的 JavaScript 代码,很多都是可视化网页设计工具生成的,复杂而混乱,更加深了人们对它的不良印象。在当时,JavaScript 的一个主要作用是在网页上显示出花哨的效果,譬如弹出令人厌烦的广告窗口。

早期的 JavaScript 运行效率低下、浏览器兼容性问题严重。就连 JavaScript 之父 BrendanEich 都觉得它很烂,从来没有想过它能够发展成今天的样子。后来随着以 Gmail 为代表的Web 2.0 应用的兴起,人们开始重新认识 JavaScript。

JavaScript 经历了一个十分纠结的发展过程,因为 ECMAScript 新标准总是在提出后若干年才会被浏览器开发商陆续实现,所以开发者不得不忍痛割爱放弃许多 JavaScript 优美的新特性,以保持浏览器之间的兼容性。

值得庆幸的是,这些问题在 Node.js 中已不复存在,我们可以放心地享受 JavaScript 的全部特性给我们带来的便利了。这些特性大多已经是现代编程语言共有的理念,例如面向对象、函数式编程思想、lambda 演算、闭包、动态绑定等。

我假设你了解 JavaScript 的基本语法,并且对面向对象的语言有一定的理解,如果你还知道函数式编程(functional programming),那么你将可以很容易地理解闭包。本附录通过大量的示例帮你了解 JavaScript 众多特性,理解 JavaScript 背后的机制。我们以作用域、闭包和对象为线索,介绍 JavaScript 编程中常用到的特性和技巧。

A.1 作用域

作用域(scope)是结构化编程语言中的重要概念,它决定了变量的可见范围和生命周期,正确使用作用域可以使代码更清晰、易懂。作用域可以减少命名冲突,而且是垃圾回收的基本单元。和 C、C++、Java 等常见语言不同,JavaScript 的作用域不是以花括号包围的块级作用域(block scope),这个特性经常被大多数人忽视,因而导致莫名其妙的错误。例如下面代码,在大多数类 C 的语言中会出现变量未定义的错误,而在 JavaScript 中却完全合法:

if (true) {
    var somevar = 'value';
}
console.log(somevar); // 输出 value

这是因为 JavaScript 的作用域完全是由函数来决定的,if、for 语句中的花括号不是独立的作用域。

A.1.1 函数作用域

不同于大多数类 C 的语言,由一对花括号封闭的代码块就是一个作用域,JavaScript 的作用域是通过函数来定义的,在一个函数中定义的变量只对这个函数内部可见,我们称为函数作用域。在函数中引用一个变量时,JavaScript 会先搜索当前函数作用域,或者称为“局部作用域”,如果没有找到则搜索其上层作用域,一直到全局作用域。我们看一个简单的例子:

var v1 = 'v1';
var f1 = function() {
    console.log(v1); // 输出 v1
};
f1();
var f2 = function() {
    var v1 = 'local';
    console.log(v1); // 输出 local
};
f2();

以上示例十分明了,JavaScript 的函数定义是可以嵌套的,每一层是一个作用域,变量搜索顺序是从内到外。下面这个例子可能就有些令人困惑:

var scope = 'global';
var f = function() {
    console.log(scope); // 输出 undefined
    var scope = 'f';
}
f();

上面代码可能和你预想的不一样,没有输出 global,而是undefined,这是为什么呢?这是 JavaScript 的一个特性,按照作用域搜索顺序,在 console.log 函数访问 scope 变量时,JavaScript 会先搜索函数 f 的作用域,恰巧在 f 作用域里面搜索到 scope 变量,所以上层作用域中定义的 scope 就被屏蔽了,但执行到 console.log 语句时,scope 还没被定义,或者说初始化,所以得到的就是 undefined 值了。

我们还可以从另一个角度来理解:对于开发者来说,在访问未定义的变量或定义了但没有初始化的变量时,获得的值都是 undefined。于是我们可以认为,无论在函数内什么地方定义的变量,在一进入函数时就被定义了,但直到 var 所在的那一行它才被初始化,所以在这之前引用到的都是 undefined 值。(事实上,JavaScript 的内部实现并不是这样,未定义变量和值为 undefined 的变量还是有区别的。)

函数作用域的嵌套

接下来看一个稍微复杂的例子:

var f = function() {
    var scope = 'f0';
    (function() {
        var scope = 'f1';
        (function() {
            console.log(scope); // 输出 f1
        })();
    })();
};
f();

上面是一个函数作用域嵌套的例子,我们在最内层函数引用了 scope 变量,通过作用域搜索,找到了其父作用域中定义的 scope 变量。

有一点需要注意:函数作用域的嵌套关系是定义时决定的,而不是调用时决定的,也就是说,JavaScript 的作用域是静态作用域,又叫词法作用域,这是因为作用域的嵌套关系可以在语法分析时确定,而不必等到运行时确定。下面的例子说明了这一切:

var scope = 'top';
var f1 = function() {
    console.log(scope);
};
f1(); // 输出 top
var f2 = function() {
    var scope = 'f2';
    f1();
};
f2(); // 输出 top

这个例子中,通过 f2 调用的 f1 在查找 scope 定义时,找到的是父作用域中定义的 scope 变量,而不是 f2 中定义的 scope 变量。这说明了作用域的嵌套关系不是在调用时确定的,而是在定义时确定的。

A.1.2 全局作用域

在 JavaScript 中有一种特殊的对象称为 全局对象。这个对象在Node.js 对应的是 global对象,在浏览器中对应的是 window 对象。由于全局对象的所有属性在任何地方都是可见的,所以这个对象又称为 全局作用域。全局作用域中的变量不论在什么函数中都可以被直接引用,而不必通过全局对象。

满足以下条件的变量属于全局作用域:

  • 在最外层定义的变量;
  • 全局对象的属性;
  • 任何地方隐式定义的变量(未定义直接赋值的变量)。

需要格外注意的是第三点,在任何地方隐式定义的变量都会定义在全局作用域中,即不通过 var 声明直接赋值的变量。这一点经常被人遗忘,而模块化编程的一个重要原则就是避免使用全局变量,所以我们在任何地方都不应该隐式定义变量。

A.2 闭包

闭包(closure)是函数式编程中的概念,出现于 20 世纪 60 年代,最早实现闭包的语言是 Scheme,它是 LISP 的一种方言。之后闭包特性被其他语言广泛吸纳。

闭包的严格定义是“由函数(环境)及其封闭的自由变量组成的集合体。”这个定义对于大家来说有些晦涩难懂,所以让我们先通过例子和不那么严格的解释来说明什么是闭包,然后再举例说明一些闭包的经典用途。

A.2.1 什么是闭包

通俗地讲,JavaScript 中每个的函数都是一个闭包,但通常意义上嵌套的函数更能够体现出闭包的特性,请看下面这个例子:

var generateClosure = function() {
    var count = 0;
    var get = function() {
        count ++;
        return count;
    };
    return get;
};
var counter = generateClosure();
console.log(counter()); // 输出 1
console.log(counter()); // 输出 2
console.log(counter()); // 输出 3

这段代码中,generateClosure() 函数中有一个局部变量count,初值为 0。还有一个叫做 get 的函数,get 将其父作用域,也就是 generateClosure() 函数中的 count 变量增加 1,并返回 count 的值。generateClosure() 的返回值是 get 函数。在外部我们通过 counter 变量调用了 generateClosure() 函数并获取了它的返回值,也就是 get 函数,接下来反复调用几次 counter(),我们发现每次返回的值都递增了 1。

让我们看看上面的例子有什么特点,按照通常命令式编程思维的理解,count 是generateClosure 函数内部的变量,它的生命周期就是 generateClosure 被调用的时期,当 generateClosure 从调用栈中返回时,count 变量申请的空间也就被释放。问题是,在 generateClosure() 调用结束后,counter() 却引用了“已经释放了的” count变量,而且非但没有出错,反而每次调用 counter() 时还修改并返回了 count。这是怎么回事呢?

这正是所谓闭包的特性。当一个函数返回它内部定义的一个函数时,就产生了一个闭包,闭包不但包括被返回的函数,还包括这个函数的定义环境。上面例子中,当函数generateClosure() 的内部函数 get 被一个外部变量 counter 引用时,counter 和generateClosure() 的局部变量就是一个闭包。如果还不够清晰,下面这个例子可以帮助你理解:

var generateClosure = function() {
    var count = 0;
    var get = function() {
        count ++;
        return count;
    };
    return get;
};
var counter1 = generateClosure();
var counter2 = generateClosure();
console.log(counter1()); // 输出 1
console.log(counter2()); // 输出 1
console.log(counter1()); // 输出 2
console.log(counter1()); // 输出 3
console.log(counter2()); // 输出 2

上面这个例子解释了闭包是如何产生的:counter1 和 counter2 分别调用了 generate-Closure() 函数,生成了两个闭包的实例,它们内部引用的 count 变量分别属于各自的运行环境。我们可以理解为,在 generateClosure() 返回 get 函数时,私下将 get 可能引用到的 generateClosure() 函数的内部变量(也就是 count 变量)也返回了,并在内存中生成了一个副本,之后 generateClosure() 返回的函数的两个实例 counter1和 counter2 就是相互独立的了。

A.2.2 闭包的用途

1、嵌套的回调函数

闭包有两个主要用途,一是实现嵌套的回调函数,二是隐藏对象的细节。让我们先看下面这段代码示例,了解嵌套的回调函数。如下代码是在 Node.js 中使用 MongoDB 实现一个简单的增加用户的功能:

exports.add_user = function(user_info, callback) {
    var uid = parseInt(user_info['uid']);
    mongodb.open(function(err, db) {
        if (err) {callback(err); return;}
        db.collection('users', function(err, collection) {
            if (err) {callback(err); return;}
            collection.ensureIndex("uid", function(err) {
                if (err) {callback(err); return;}
                collection.ensureIndex("username", function(err) {
                    if (err) {callback(err); return;}
                    collection.findOne({uid: uid}, function(err) {
                        if (err) {callback(err); return;}
                        if (doc) {
                            callback('occupied');
                        } else {
                            var user = {
                                uid: uid,
                                user: user_info,
                            };
                            collection.insert(user, function(err) {
                                callback(err);
                            });
                        }
                    });
                });
            });
        });
    });
};

如果你对 Node.js 或 MongoDB 不熟悉,没关系,不需要去理解细节,只要看清楚大概的逻辑即可。这段代码中用到了闭包的层层嵌套,每一层的嵌套都是一个回调函数。回调函数不会立即执行,而是等待相应请求处理完后由请求的函数回调。

我们可以看到,在嵌套的每一层中都有对 callback 的引用,而且最里层还用到了外层定义的 uid 变量。由于闭包机制的存在,即使外层函数已经执行完毕,其作用域内申请的变量也不会释放,因为里层的函数还有可能引用到这些变量,这样就完美地实现了嵌套的异步回调。

尽管可以这么做,上面这种回调函数深层嵌套的实现并不优美,本书第6 章中介绍了控制流优化的方法。

2、实现私有成员

我们知道,JavaScript 的对象没有私有属性,也就是说对象的每一个属性都是曝露给外部的。这样可能会有安全隐患,譬如对象的使用者直接修改了某个属性,导致对象内部数据的一致性受到破坏等。

JavaScript通过约定在所有私有属性前加上下划线(例如_myPrivateProp),表示这个属性是私有的,外部对象不应该直接读写它。但这只是个非正式的约定,假设对象的使用者不这么做,有没有更严格的机制呢?答案是有的,通过闭包可以实现。让我们再看看前面那个例子:

var generateClosure = function() {
    var count = 0;
    var get = function() {
        count ++;
        return count;
    };
    return get;
};
var counter = generateClosure();
console.log(counter()); // 输出 1
console.log(counter()); // 输出 2
console.log(counter()); // 输出 3

我们可以看到,只有调用 counter() 才能访问到闭包内的 count 变量,并按照规则对其增加1,除此之外决无可能用其他方式找到 count 变量。受到这个简单例子的启发,我们可以把一个对象用闭包封装起来,只返回一个“访问器”的对象,即可实现对细节隐藏。

关于实现JavaScript对象私有成员的更多信息,请参考http://javascript.crockford.com/private.html。

A.3 对象

提起面向对象的程序设计语言,立刻让人想起的是 C++、Java 等这类静态强类型语言,以及 Python、Ruby 等脚本语言,它们共有的特点是基于类的面向对象。而说到 JavaScript,很少能让人想到它面向对象的特性,甚至有人说它不是面向对象的语言,因为它没有类。没错,JavaScript 真的没有类,但 JavaScript 是面向对象的语言。JavaScript 只有对象,对象就是对象,不是类的实例。

因为绝大多数面向对象语言中的对象都是基于类的,所以经常有人混淆类的实例与对象的概念。对象就是类的实例,这在大多数语言中都没错,但在 JavaScript 中却不适用。

JavaScript 中的对象是基于原型的,因此很多人在初学 JavaScript 对象时感到无比困惑。通过这一节,我们将重新认识 JavaScript 中对象,充分理解基于原型的面向对象的实质。

A.3.1 创建和访问

JavaScript 中的对象实际上就是一个由属性组成的关联数组,属性由名称和值组成,值的类型可以是任何数据类型,或者函数和其他对象。注意 JavaScript 具有函数式编程的特性,所以函数也是一种变量,大多数时候不用与一般的数据类型区分。

在 JavaScript 中,你可以用以下方法创建一个简单的对象:

var foo = {};
foo.prop_1 = 'bar';
foo.prop_2 = false;
foo.prop_3 = function() {
    return 'hello world';
}
console.log(foo.prop_3());

以上代码中,我们通过 var foo = {}; 创建了一个对象,并将其引用赋值给 foo,通过 foo.prop1 来获取它的成员并赋值,其中 {} 是对象字面量的表示方法,也可以用 var foo = new Object() 来显式地创建一个对象。

1、使用关联数组访问对象成员

我们还可以用关联数组的模式来创建对象,以上代码修改为:

var foo = {};
foo['prop1'] = 'bar';
foo['prop2'] = false;
foo['prop3'] = function() {
    return 'hello world';
}

在 JavaScript 中,使用句点运算符和关联数组引用是等价的,也就是说任何对象(包括this 指针)都可以使用这两种模式。使用关联数组的好处是,在我们不知道对象的属性名称的时候,可以用变量来作为关联数组的索引。例如:

var some_prop = 'prop2';
foo[some_prop] = false;

2、使用对象初始化器创建对象

上述的方法只是让你对JavaScript对象的定义有个了解,真正在使用的时候,我们会采用下面这种更加紧凑明了的方法:

var foo = {
    'prop1': 'bar',
    prop2: 'false',
    prop3: function (){
        return 'hello world';
    }
};

这种定义的方法称为对象的初始化器。注意,使用初始化器时,对象属性名称是否加引号是可选的,除非属性名称中有空格或者其他可能造成歧义的字符,否则没有必要使用引号。

A.3.2 构造函数

前一小节讲述的对象创建方法都有一个弱点,就是创建对象的代码是一次性的。如果我们想创建多个规划好的对象,有若干个固定的属性、方法,并能够初始化,就像 C++ 语言中的对象一样,该如何做呢?别担心,JavaScript 提供了构造函数,让我们来看看应该如何创建复杂的对象。

function User(name, uri) {
    this.name = name;
    this.uri = uri;
    this.display = function() {
        console.log(this.name);
    }
}

以上是一个简单的构造函数,接下来用 new 语句来创建对象:

var someuser = new User('byvoid', 'http://www.byvoid.com');

然后就可以通过 someuser 来访问这个对象的属性和方法了。

A.3.3 上下文对象

在 JavaScript 中,上下文对象就是 this 指针,即被调用函数所处的环境。上下文对象的作用是在一个函数内部引用调用它的对象本身,JavaScript 的任何函数都是被某个对象调用的,包括全局对象,所以 this 指针是一个非常重要的东西。

JavaScript 中并没有像 C++ 一样的指针概念,这里所谓的 this 指针只是沿用习惯的说法而已。

在前面使用构造函数的代码中我们已经看到了 this 的使用方法,下面代码可以更佳清楚地说明上下文对象的使用方式:

var someuser = {
    name: 'byvoid',
    display: function() {
        console.log(this.name);
    }
};
someuser.display(); // 输出 byvoid
var foo = {
    bar: someuser.display,
    name: 'foobar'
};
foo.bar(); // 输出 foobar

JavaScript 的函数式编程特性使得函数可以像一般的变量一样赋值、传递和计算,我们看到在上面代码中,foo 对象的 bar 属性是 someuser.display 函数,使用 foo.bar()调用时,bar 和 foo 对象的函数看起来没有区别,其中的 this 指针不属于某个函数,而是函数调用时所属的对象。

在 JavaScript 中,本质上,函数类型的变量是指向这个函数实体的一个引用,在引用之间赋值不会对对象产生复制行为。我们可以通过函数的任何一个引用调用这个函数,不同之处仅仅在于上下文。下面例子可以帮助我们理解:

var someuser = {
    name: 'byvoid',
    func: function() {
        console.log(this.name);
    }
};
var foo = {
    name: 'foobar'
};
someuser.func(); // 输出 byvoid
foo.func = someuser.func;
foo.func(); // 输出 foobar
name = 'global';
func = someuser.func;
func(); // 输出 global

仔细观察上面的例子,使用不同的引用来调用同一个函数时,this 指针永远是这个引用所属的对象。在前面的章节中我们提到了 JavaScript 的函数作用域是静态的,也就是说一个函数的可见范围是在预编译的语法分析中就可以确定的,而上下文对象则可以看作是静态作用域的补充。

1、call 和 apply

在 JavaScript 中,call 和 apply 是两个神奇的方法,但同时也是容易令人迷惑的两个方法,乃至许多对 JavaScript 有经验的人也不太清楚它们的用法。call 和 apply 的功能是以不同的对象作为上下文来调用某个函数。

简而言之,就是允许一个对象去调用另一个对象的成员函数。乍一看似乎很不可思议,而且容易引起混乱,但其实 JavaScript 并没有严格的所谓“成员函数”的概念,函数与对象的所属关系在调用时才展现出来。灵活使用 call 和apply 可以节省不少时间,在后面我们可以看到,call 可以用于实现对象的继承。

call 和 apply 的功能是一致的,两者细微的差别在于 call 以参数表来接受被调用函数的参数,而 apply 以数组来接受被调用函数的参数。call 和 apply 的语法分别是:

func.call(thisArg[, arg1[, arg2[, ...]]])
func.apply(thisArg[, argsArray])

其中,func 是函数的引用,thisArg 是 func 被调用时的上下文对象,arg1、arg2 或argsArray 是传入 func 的参数。我们以下面一段代码为例介绍 call 的工作机制:

var someuser = {
    name: 'byvoid',
    display: function(words) {
        console.log(this.name + ' says ' + words);
    }
};
var foo = {
    name: 'foobar'
};
someuser.display.call(foo, 'hello'); // 输出 foobar says hello

用 Node.js 运行这段代码,我们可以看到控制台输出了 foobar。someuser.display 是被调用的函数,它通过 call 将上下文改变为 foo 对象,因此在函数体内访问 this.name时,实际上访问的是 foo.name,因而输出了foobar。

2、bind

如何改变被调用函数的上下文呢?前面说过,可以用 call 或 apply 方法,但如果重复使用会不方便,因为每次都要把上下文对象作为参数传递,而且还会使代码变得不直观。针对这种情况,我们可以使用 bind 方法来永久地绑定函数的上下文,使其无论被谁调用,上下文都是固定的。bind 语法如下:

func.bind(thisArg[, arg1[, arg2[, ...]]])

其中 func 是待绑定函数,thisArg 是改变的上下文对象,arg1、arg2 是绑定的参数表。bind 方法返回值是上下文为 thisArg 的 func。通过下面例子可以帮你理解 bind的使用方法:

var someuser = {
name: 'byvoid',
    func: function() {
        console.log(this.name);
    }
};
var foo = {
    name: 'foobar'
};
foo.func = someuser.func;
foo.func(); // 输出 foobar
foo.func1 = someuser.func.bind(someuser);
foo.func1(); // 输出 byvoid
func = someuser.func.bind(foo);
func(); // 输出 foobar
func2 = func;
func2(); // 输出 foobar

上面代码直接将 foo.func 赋值为 someuser.func,调用 foo.func() 时,this指针为 foo,所以输出结果是 foobar。foo.func1 使用了 bind 方法,将 someuser 作为this指针绑定到 someuser.func,调用 foo.func1() 时,this指针为 someuser,所以输出结果是 byvoid。

全局函数 func 同样使用了 bind 方法,将 foo 作为 this 指针绑定到 someuser.func,调用 func() 时,this 指针为 foo,所以输出结果是 foobar。而 func2 直接将绑定过的 func 赋值过来,与 func 行为完全相同。

3、使用 bind 绑定参数表

bind 方法还有一个重要的功能:绑定参数表,如下例所示。

var person = {
    name: 'byvoid',
    says: function(act, obj) {
        console.log(this.name + ' ' + act + ' ' + obj);
    }
};
person.says('loves', 'diovyb'); // 输出 byvoid loves diovyb
byvoidLoves = person.says.bind(person, 'loves');
byvoidLoves('you'); // 输出 byvoid loves you

可以看到,byvoidLoves 将 this 指针绑定到了 person,并将第一个参数绑定到loves,之后在调用 byvoidLoves 的时候,只需传入第三个参数。这个特性可以用于创建一个函数的“捷径”,之后我们可以通过这个“捷径”调用,以便在代码多处调用时省略重复输入相同的参数。

4、理解 bind

尽管 bind 很优美,还是有一些令人迷惑的地方,例如下面的代码:

var someuser = {
    name: 'byvoid',
    func: function () {
        console.log(this.name);
    }
};
var foo = {
    name: 'foobar'
};
func = someuser.func.bind(foo);
func(); // 输出 foobar
func2 = func.bind(someuser);
func2(); // 输出 foobar

全局函数 func 通过someuser.func.bind将this指针绑定到了foo,调用func()输出了foobar。我们试图将func2赋值为已绑定的func重新通过bind将this指针绑定到someuser的结果,而调用func2时却发现输出值仍为foobar,即 this 指针还是停留在 foo对象上,这是为什么呢?要想解释这个现象,我们必须了解 bind 方法的原理。

让我们看一个 bind 方法的简化版本(不支持绑定参数表):

someuser.func.bind = function(self) {
    return this.call(self);
};

假设上面函数是 someuser.func 的 bind 方法的实现,函数体内 this 指向的是someuser.func,因为函数也是对象,所以 this.call(self) 的作用就是以 self 作为this指针调用 someuser.func。

//将func = someuser.func.bind(foo)展开:
func = function() {
    return someuser.func.call(foo);
};
//再将func2 = func.bind(someuser)展开:
func2 = function() {
    return func.call(someuser);
};

从上面展开过程我们可以看出,func2 实际上是以 someuser 作为 func 的this指针调用了 func,而 func 根本没有使用 this 指针,所以两次 bind 是没有效果的。

A.3.4 原型

原型是 JavaScript 面向对象特性中重要的概念,也是大家太熟悉的概念。因为在绝大多数的面向对象语言中,对象是基于类的(例如 Java 和 C++ ),对象是类实例化的结果。而在JavaScript 语言中,没有类的概念(很多时候对象的构造函数会被称为“类”,但实际上并不是严格意义上的类。),对象由对象实例化。打个比方来说,基于类的语言中类就像一个模具,对象由这个模具浇注产生,而基于原型的语言中,原型就好像是一件艺术品的原件,我们通过一台 100% 精确的机器把这个原件复制出很多份。

前面小节的例子中都没有涉及原型,仅仅通过构造函数和 new 语句生成类,让我们看看如何使用原型和构造函数共同生成对象。

function Person() {}
Person.prototype.name = 'BYVoid';
Person.prototype.showName = function () {
    console.log(this.name);
};
var person = new Person();
person.showName();

上面这段代码使用了原型而不是构造函数初始化对象。这样做与直接在构造函数内定义属性有什么不同呢?

  • 构造函数内定义的属性继承方式与原型不同,子对象需要显式调用父对象才能继承构造函数内定义的属性。
  • 构造函数内定义的任何属性,包括函数在内都会被重复创建,同一个构造函数产生的两个对象不共享实例。
  • 构造函数内定义的函数有运行时闭包的开销,因为构造函数内的局部变量对其中定义的函数来说也是可见的。

下面这段代码可以验证以上问题:

function Foo() {
    var innerVar = 'hello';
    this.prop1 = 'BYVoid';
    this.func1 = function(){
        innerVar = '';
    };
}
Foo.prototype.prop2 = 'Carbo';
Foo.prototype.func2 = function () {
    console.log(this.prop2);
};
var foo1 = new Foo();
var foo2 = new Foo();
console.log(foo1.func1 == foo2.func1); // 输出 false
console.log(foo1.func2 == foo2.func2); // 输出 true

尽管如此,并不是说在构造函数内创建属性不好,而是两者各有适合的范围。那么我们什么时候使用原型,什么时候使用构造函数内定义来创建属性呢?

  • 除非必须用构造函数闭包,否则尽量用原型定义成员函数,因为这样可以减少开销。
  • 尽量在构造函数内定义一般成员,尤其是对象或数组,因为用原型定义的成员是多个实例共享的。

接下来,我们介绍一下JavaScript中的原型链机制。

原型链

JavaScript 中有两个特殊的对象: Object 与 Function,它们都是构造函数,用于生成对象。Object.prototype 是所有对象的祖先,Function.prototype 是所有函数的原型,包括构造函数。

我把 JavaScript 中的对象分为三类,一类是用户创建的对象,一类是构造函数对象,一类是原型对象。用户创建的对象,即一般意义上用 new 语句显式构造的对象。构造函数对象指的是普通的构造函数,即通过 new 调用生成普通对象的函数。原型对象特指构造函数 prototype 属性指向的对象。这三类对象中每一类都有一个 __proto__ 属性,它指向该对象的原型,从任何对象沿着它开始遍历都可以追溯到 Object.prototype。

构造函数对象有 prototype 属性,指向一个原型对象,通过该构造函数创建对象时,被创建对象的 __proto__ 属性将会指向构造函数的 prototype 属性。原型对象有 constructor属性,指向它对应的构造函数。让我们通过下面这个例子来理解原型:

function Foo() {}
Object.prototype.name = 'My Object';
Foo.prototype.name = 'Bar';
var obj = new Object();
var foo = new Foo();
console.log(obj.name); // 输出 My Object
console.log(foo.name); // 输出 Bar
console.log(foo.__proto__.name); // 输出 Bar
console.log(foo.__proto__.__proto__.name); // 输出 My Object
console.log(foo. __proto__.constructor.prototype.name); // 输出 Bar

我们定义了一个叫做 Foo ()的构造函数,生成了对象 foo。同时我们还分别给 Object和 Foo 生成原型对象。

图A-1 解析了它们之间错综复杂的关系。

附录A JavaScript的高级特性

在 JavaScript 中,继承是依靠一套叫做原型链(prototype chain)的机制实现的。属性继承的本质就是一个对象可以访问到它的原型链上任何一个原型对象的属性。例如上例的foo 对象,它拥有 foo. __proto__ 和 foo. __proto__.__proto__ 所有属性的浅拷贝(只复制基本数据类型,不复制对象)。所以可以直接访问foo.constructor(来自foo.__proto__,即Foo.prototype),foo.toString(来自foo. __proto__.__proto__,即Object.prototype)。

A.3.5 对象的复制

JavaScript 和 Java 一样都没有像C语言中一样的指针,所有对象类型的变量都是指向对象的引用,两个变量之间赋值传递一个对象并不会对这个对象进行复制,而只是传递引用。有些时候我们需要完整地复制一个对象,这该如何做呢? Java 语言中有 clone 方法可以实现对象复制,但 JavaScript 中没有这样的函数。因此我们需要手动实现这样一个函数,一个简单的做法是复制对象的所有属性:

Object.prototype.clone = function() {
    var newObj = {};
    for (var i in this) {
        newObj[i] = this[i];
    }
    return newObj;
}
var obj = {
    name: 'byvoid',
    likes: ['node']
};
var newObj = obj.clone();
obj.likes.push('python');
console.log(obj.likes); // 输出 [ 'node', 'python' ]
console.log(newObj.likes); // 输出 [ 'node', 'python' ]

上面的代码是一个对象浅拷贝(shallow copy)的实现,即只复制基本类型的属性,而共享对象类型的属性。浅拷贝的问题是两个对象共享对象类型的属性,例如上例中 likes 属性指向的是同一个数组。

实现一个完全的复制,或深拷贝(deep copy)并不是一件容易的事,因为除了基本数据类型,还有多种不同的对象,对象内部还有复杂的结构,因此需要用递归的方式来实现:

Object.prototype.clone = function() {
    var newObj = {};
    for (var i in this) {
        if (typeof(this[i]) == 'object' || typeof(this[i]) == 'function') {
            newObj[i] = this[i].clone();
        } else {
            newObj[i] = this[i];
        }
    }
    return newObj;
};
Array.prototype.clone = function() {
    var newArray = [];
    for (var i = 0; i < this.length; i++) {
        if (typeof(this[i]) == 'object' || typeof(this[i]) == 'function') {
            newArray[i] = this[i].clone();
        } else {
            newArray[i] = this[i];
        }
    }
    return newArray;
    that = this;
    var newFunc = function() {
        return that.apply(this, arguments);
    };
    for (var i in this) {
        newFunc[i] = this[i];
    }
    return newFunc;
};
var obj = {
    name: 'byvoid',
    likes: ['node'],
    display: function() {
        console.log(this.name);
    },
};
var newObj = obj.clone();
newObj.likes.push('python');
console.log(obj.likes); // 输出 [ 'node' ]
console.log(newObj.likes); // 输出 [ 'node', 'python' ]
console.log(newObj.display == obj.display); // 输出 false

上面这个实现看起来很完美,它不仅递归地复制了对象复杂的结构,还实现了函数的深拷贝。这个方法在大多数情况下都很好用,但有一种情况它却无能为力,例如下面的代码:

var obj1 = {
    ref: null
};
var obj2 = {
    ref: obj1
};
obj1.ref = obj2;

这段代码的逻辑非常简单,就是两个相互引用的对象。当我们试图使用深拷贝来复制obj1 和 obj2 中的任何一个时,问题就出现了。因为深拷贝的做法是遇到对象就进行递归复制,那么结果只能无限循环下去。

对于这种情况,简单的递归已经无法解决,必须设计一套图论算法,分析对象之间的依赖关系,建立一个拓扑结构图,然后分别依次复制每个顶点,并重新构建它们之间的依赖关系。这已经超出了本书的讨论范围,而且在实际的工程操作中几乎不会遇到这种需求,所以我们就不继续讨论了。

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

目前还没有任何评论,快来抢沙发吧!