《JavaScript忍者秘籍》原型与面向对象

阅读忍者秘籍。

简介

些开发人员会认为原型和对象的关系很亲密,但是事实上,这全都是和函数有关。原型虽然是定义对象的一种很方便的方式,但它的本质依然是函数特性。

实例化和原型

通过使用 new 操作符和不使用 new 操作符,来看一下 prototype 属性是如何为新实例提供属性的。

实际的例子

以上例子说明了,函数作为构造器进行调用时,函数的原型是新对象的一个概览。同时,在使用 new 操作符将函数作为构造器调用时,其上下文是新对象实例。这让我们可以通过在构造器函数内的 this 变量来初始化参数。

实际的例子

以上例子还说明了初始化操作的优先级顺序,如下:

  1. 通过原型给对象实例添加的属性。
  2. 在构造器函数内给对象实例添加的属性。

协调引用

现在,让我们来看一下 JavaScript 是如何对引用进行协调的,以及在该过程中 prototype 属性是如何发挥作用的。

实际的例子

这个例子说明了,在对象创建时,不仅仅是简单复制属性那么简单。 事实是,原型上的属性并没有复制到其他地方,而是附加到新创建的对象上了,并可以和对象自身的属性引用一起协调运行。简单描述如下: 引用对象一个属性,如果其本身有,就返回,如果没有,就去看对象的原型,检查原型上是否有……,直到最后为 undefined

对象不仅与其构造器有关,还与构造器所创建对象的原型相关。在 JavaScript 中的每个对象都有一个名为 constructor 的隐式属性,该属性引用的是创建该对象的构造器。原型是实时附加在对象上的。

进一步的例子

这个例子进一步说明了,JavaScript 在查询属性引用的时候,首先是查询对象自身,如果不存在,才在原型上进行查找。

通过构造器判断对象类型

JavaScript 不仅可以利用原型协调属性引用,还可以通过 constructor 来知道是哪个构造器创建了对象实例。constructor 属性作为创建该对象的原始函数的引用,被添加在所有的实例上。利用该属性,可以验证实例的起源。 当然,instanceof 操作符也可以确定一个实例是否是由特定的函数构造器所创建。

由于这个 constructor 属性只是原始构造器的一个引用,所以我们完全不用知道原有的构造函数就可以再次创建一个新实例,即使原始的构造器在作用域内已经不存在了。

实例的例子

注意:如果 constructor 属性被覆盖,那么创建该对象的原始函数就丢失了。

继承与原型链

复制父对象的属性和方法是不对的,这不是继承。我们真正需要的是一个原型链。创建一个原型链最好的方式是使用一个对象的实例作为另一个对象的原型:SubClass.prototype = new SuperClass()。这个方法可行是因为 SubClass 实例的原型将是 SuperClass 的一个实例,该实例不仅拥有原型,还持有 SuperClass 的所有属性,并且该原型指向其自身超类的一个实例,以此类推。

实例的例子

通过执行 instanceof,可以判断函数是否继承了其原型链中任何对象的功能。注意:不要将 Person 的原型对象赋值给 Ninja 的原型,如:Ninja.prototype = Person.prototype;。这样会导致,Ninja 原型上的任何修改都会影响到 Person 的原型,因为它们是同一个对象。

所有的原生 JavaScript 对象构造器都有可以被操作和扩展的原型属性,因为本质上,每个对象构造器自身就是一个函数。 这就给我们来实现新特性的能力如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if (!Array.prototype.forEach) {
    Array.prototype.forEach = function(callback, context) {
        for (var i = 0; i < this.length; i++) {
            callback.call(context || null, this[i], i, this);  // 使用 context || null 来避免传入 undefined
        }
    };
}

["a", "b", "c"].forEach(function(value, index, array) {
    assert(value, "Is in position " + index + " out of " + (array.length - 1));
});

注意:在原始对象上引入新的属性和方法,与在全局作用域内声明一个变量一样危险,因为原生对象的原型只有一个实例,所以有发生命名冲突的重大可能性。

HTML DOM 原型

所有的 DOM 元素都继承于 HTMLElement 构造器,通过访问 HTMLElement 的原型,浏览器可以为我们提供扩展任意 HTML 节点能力。

实例的例子

疑难陷阱

扩展对象

千万不要扩展原生的 Object.prototype 对象。因为,你一旦扩展了这个对象,所有的对象都会接收这些额外的属性。如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Object.prototype.keys = function() {
    var keys = [];
    for (var p in this) {
        keys.push(p);
    }
    return keys;
};

var obj = { a: 1, b: 2, c: 3 };

assert(obj.keys().length == 3, "There are three properties in this object.");

给所有的对象都添加属性,会使所有的代码都必须要考虑到这个额外的属性。如果其他库这样做的话,可以通过 hasOwnProperty() 的方法来确定一个属性是在对象实例上定义的,还是从原型上导入的。修改如上的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Object.prototype.keys = function() {
    var keys = [];
    for (var i in this) {
        if (this.hasOwnProperty(i)) {
            keys.push(i);
        }
    }
    return keys;
};

var obj = { a: 1, b: 2, c: 3 };

assert(obj.keys().length == 3, "There are three properties in this object.");

这只是一个无奈的、被迫的方案。

扩展数字

除了对 Object 对象进行扩展相对来说比较安全,但是 Number 是一个例外。

实例的例子

这个例子说明了扩展 Number 对象不好的一个主要原因是字面量对象不买账。

子类化原生对象

原生对象子类化指的是一个对象是 Object 的子类,因为它是所有原型链的根。

1
2
3
4
5
6
7
8
9
function MyArray() {}

MyArray.prototype = new Array();

var mine = new MyArray();
mine.push(1, 2, 3);

assert(mine.length == 3, "All the items are in our sub-classed array.");
assert(mine instanceof Array, "Verify that we implement Array functionality.");

以上示例在 IE 浏览器中会有问题,因为 IE 的实现不能很好地反应 lenght 的值。在这种时候,更好的方式是单独实现原生对象的各个功能,而不是扩展出子类。

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

MyArray.prototype.length = 0; // IE 就是这个属性的实现有问题。

(function() {
    var methods = ['push', 'pop', 'shift', 'unshift', 'slice', 'splice', 'join'];

    for (var i = 0; i < methods.length; i++) {
        (function (name) {
            MyArray.prototype[name] = function() {
                return Array.prototype[name].apply(this, arguments);
            };
        })(methods[i]);
    }
})();

var mine = new MyArray();
mine.push(1, 2, 3);

assert(mine.length == 3, "All the items are on our sub-classed array.");
assert(!(mine instanceof Array), "We aren't subclassing Array, though.");

实例化问题

函数默认有两种用途:普通函数和构造器函数。先看下面的实例:

1
2
3
4
5
6
7
8
function User(first, last) {
    this.name = first + " " + last;
}

var user = User("Sharry", "Xu");

assert(user, "User instantiated.");
assert(user.name == "Sharry Xu", "User name correctly assigned.");

以上实例中,我们忘记了用 new 操作符去调用函数。这导致 user 对象不被实例化,并且如果构造器函数作为普通函数进行调用的话,会产生微秒的副作用,如污染当前作用域等。实例如下:

1
2
3
4
5
6
7
8
9
function User(first, last) {
    this.name = first + " " + last;
}

var name = "Sharry";

var user = User("Sharry", "Xu");

assert(name == "Sharry", "Name was set to Sharry.");

通过如下方法,我们可以判断一个函数是作为普通函数调用的,还是作为构造器进行调用的。

1
2
3
4
5
6
7
function Test() {
    // calle 属性在严格模式中,已经不能使用。
    return this instanceof arguments.callee;
}

assert(!Test(), "We didn't instantiate, so it returns false.");
assert(new Test(), "We did instantiate, returning true.");

一个应用了该方法的例子

但是有的时候,这样的弥补会增加负担,让原本的意思模糊不清。所以记住,只是因为我们可以想出一个巧妙的解决办法,并不总是意味着我们就应该用这种方式。

注意:属性 callercalleearguments 在严格模式中不能使用。

编写类风格的代码

面向对象是很多 JavaScript 库的核心组成部分。大部分的类库会自己实现面向对象的机制。

实际的例子

我们所有的类最终都继承于一个祖先:Object。因此,如果要创建一个新类,它必须是 Object 的一个子类,或者是一个在层级上继承于 Object 的类。

检测函数是否可以序列化

函数序列化就是简单地接收一个函数,然后返回该函数的源码文本。一般来说,一个函数在其上下文中序列化成字符串,会使得宿主调用它的 toString() 方法。

1
2
3
/xyz/.test(function() {
    xyz;
});

保留父级方法

在一个方法被覆盖时,我们保留了访问被覆盖方法的能力。因为,有的时候,我们只是想增加一个功能,这个增加的功能内部或许需要原来的方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(function (name, fn) {
    return function() {
        var tmp = this._super;

        this._super = _super[name];

        var ret = fn.apply(this, arguments);

        this._super = tmp;

        return ret;
    };
})(name, properties[name]);

在同名变量已经存在的情况下,上述方法会很有用。

总结

  1. 了解 prototype 到底是什么。特别是使用了 new 操作符时,它扮演了什么角色。
  2. 如何判断一个对象的类型,以及如何判断一个对象是用哪个构造器进行构建的。
  3. 如何防止构造器的使用不当所造成的实例化问题。
  4. 如何在 JavaScript 中实现对象子类化功能。
updatedupdated2023-12-052023-12-05