第 17 章. 对象和继承
目录
购买本书
(广告,请不要屏蔽。)

第 17 章. 对象和继承

JavaScript 中的面向对象编程 (OOP) 有几个层次:

每一层都只依赖于之前的层,使您能够逐步学习 JavaScript OOP。第一层和第二层构成了一个简单的核心,当您对更复杂的第三层和第四层感到困惑时,可以参考它们。

第一层:单个对象

粗略地说,JavaScript 中的所有对象都是从字符串到值的映射(字典)。对象中的一个(键,值)条目称为属性属性的键始终是文本字符串。属性的值可以是任何 JavaScript 值,包括函数。方法是其值为函数的属性。

对象字面量

JavaScript 的对象字面量允许您直接创建普通对象Object 的直接实例)。以下代码使用对象字面量将一个对象赋值给变量 jane。该对象有两个属性:namedescribedescribe 是一个方法:

var jane = {
    name: 'Jane',

    describe: function () {
        return 'Person named '+this.name;  // (1)
    },  // (2)
};
  1. 在方法中使用 this 来引用当前对象(也称为方法调用的接收者)。
  2. ECMAScript 5 允许在对象字面量中使用尾随逗号(在最后一个属性之后)。唉,并非所有旧浏览器都支持它。尾随逗号很有用,因为您可以重新排列属性,而不必担心哪个属性是最后一个。

您可能会认为对象只是从字符串到值的映射。但它们不止于此:它们是真正的通用对象。例如,您可以在对象之间使用继承(请参阅第二层:对象之间的原型关系),并且您可以保护对象不被更改。直接创建对象的能力是 JavaScript 的突出特性之一:您可以从具体对象开始(不需要类!),并在以后引入抽象。例如,构造函数是对象的工厂(如第三层:构造函数——实例的工厂中所述),它们大致类似于其他语言中的类。

点运算符 (.):通过固定键访问属性

点运算符提供了一种用于访问属性的紧凑语法。属性键必须是标识符(请参阅合法标识符)。如果要读取或写入具有任意名称的属性,则需要使用方括号运算符(请参阅方括号运算符 ([]):通过计算键访问属性)。

本节中的示例使用以下对象

var jane = {
    name: 'Jane',

    describe: function () {
        return 'Person named '+this.name;
    }
};

获取属性

点运算符允许您“获取”属性(读取其值)。以下是一些示例

> jane.name  // get property `name`
'Jane'
> jane.describe  // get property `describe`
[Function]

获取不存在的属性将返回 undefined

> jane.unknownProperty
undefined

调用方法

点运算符也用于调用方法:

> jane.describe()  // call method `describe`
'Person named Jane'

设置属性

您可以使用赋值运算符 (=) 来设置通过点表示法引用的属性的值。例如:

> jane.name = 'John';  // set property `name`
> jane.describe()
'Person named John'

如果属性尚不存在,则设置它会自动创建它。如果属性已存在,则设置它会更改其值。

删除属性

delete 运算符允许您从对象中完全删除属性(整个键值对)。例如:

> var obj = { hello: 'world' };
> delete obj.hello
true
> obj.hello
undefined

如果仅将属性设置为 undefined,则该属性仍然存在,并且对象仍然包含其键:

> var obj = { foo: 'a', bar: 'b' };

> obj.foo = undefined;
> Object.keys(obj)
[ 'foo', 'bar' ]

如果删除该属性,则其键也会消失

> delete obj.foo
true
> Object.keys(obj)
[ 'bar' ]

delete 仅影响对象的直接(“自身”,非继承)属性。它的原型不会被触及(请参阅删除继承的属性)。

提示

谨慎使用 delete 运算符。如果由构造函数创建的实例的“形状”没有改变(粗略地说:没有删除或添加属性),则大多数现代 JavaScript 引擎都会优化它们的性能。删除属性会阻止这种优化。

delete 的返回值

如果该属性是自身属性但无法删除,则 delete 返回 false在所有其他情况下,它都返回 true。以下是一些示例。

作为准备工作,我们创建一个可以删除的属性和另一个无法删除的属性(通过描述符获取和定义属性解释了 Object.defineProperty()

var obj = {};
Object.defineProperty(obj, 'canBeDeleted', {
    value: 123,
    configurable: true
});
Object.defineProperty(obj, 'cannotBeDeleted', {
    value: 456,
    configurable: false
});

对于无法删除的自身属性,delete 返回 false

> delete obj.cannotBeDeleted
false

在所有其他情况下,delete 返回 true

> delete obj.doesNotExist
true
> delete obj.canBeDeleted
true

即使 delete 没有改变任何内容(继承的属性永远不会被删除),它也会返回 true

> delete obj.toString
true
> obj.toString // still there
[Function: toString]

不寻常的属性键

虽然您不能使用保留字(例如 varfunction)作为变量名,但您可以将它们用作属性键:

> var obj = { var: 'a', function: 'b' };
> obj.var
'a'
> obj.function
'b'

数字可以在对象字面量中用作属性键,但它们会被解释为字符串。点运算符只能访问键为标识符的属性。因此,您需要使用方括号运算符(如下例所示)来访问键为数字的属性:

> var obj = { 0.7: 'abc' };
> Object.keys(obj)
[ '0.7' ]
> obj['0.7']
'abc'

对象字面量还允许您使用任意字符串(既不是标识符也不是数字)作为属性键,但您必须将它们用引号引起来。同样,您需要使用方括号运算符来访问属性值

> var obj = { 'not an identifier': 123 };
> Object.keys(obj)
[ 'not an identifier' ]
> obj['not an identifier']
123

方括号运算符 ([]):通过计算键访问属性

点运算符适用于固定的属性键,而方括号运算符允许您通过表达式引用属性。

通过方括号运算符获取属性

方括号运算符允许您通过表达式计算属性的键:

> var obj = { someProperty: 'abc' };

> obj['some' + 'Property']
'abc'

> var propKey = 'someProperty';
> obj[propKey]
'abc'

这也允许您访问键不是标识符的属性

> var obj = { 'not an identifier': 123 };
> obj['not an identifier']
123

请注意,方括号运算符会将其内部内容强制转换为字符串。例如

> var obj = { '6': 'bar' };
> obj[3+3]  // key: the string '6'
'bar'

通过方括号运算符调用方法

调用方法的工作方式与您预期的一样:

> var obj = { myMethod: function () { return true } };
> obj['myMethod']()
true

通过方括号运算符设置属性

设置属性的工作方式类似于点运算符:

> var obj = {};
> obj['anotherProperty'] = 'def';
> obj.anotherProperty
'def'

通过方括号运算符删除属性

删除属性的工作方式也类似于点运算符:

> var obj = { 'not an identifier': 1, prop: 2 };
> Object.keys(obj)
[ 'not an identifier', 'prop' ]
> delete obj['not an identifier']
true
> Object.keys(obj)
[ 'prop' ]

将任何值转换为对象

这不是一个常见的用例,但有时您需要将任意值转换为对象。Object(),用作函数(而不是构造函数),提供了此服务。它产生以下结果:

结果

(不带参数调用)

{}

undefined

{}

null

{}

布尔值 bool

new Boolean(bool)

数字 num

new Number(num)

字符串 str

new String(str)

对象 obj

obj(不变,无需转换)

以下是一些示例

> Object(null) instanceof Object
true

> Object(false) instanceof Boolean
true

> var obj = {};
> Object(obj) === obj
true

以下函数检查value 是否为对象:

function isObject(value) {
    return value === Object(value);
}

请注意,如果 value 不是对象,则上述函数会创建一个对象。您可以通过 typeof 实现相同的功能,而无需这样做(请参阅陷阱:typeof null)。

您也可以将 Object 作为构造函数调用,这会产生与将其作为函数调用相同的结果:

> var obj = {};
> new Object(obj) === obj
true

> new Object(123) instanceof Number
true

提示

避免使用构造函数;空对象字面量几乎总是一个更好的选择:

var obj = new Object(); // avoid
var obj = {}; // prefer

this 作为函数和方法的隐式参数

当您调用函数时,this始终是一个(隐式)参数:

松散模式下的普通函数

即使普通函数不需要 this,它仍然作为特殊变量存在,其值始终是全局对象(浏览器中的 window;请参阅全局对象

> function returnThisSloppy() { return this }
> returnThisSloppy() === window
true
严格模式下的普通函数

this 始终为 undefined

> function returnThisStrict() { 'use strict'; return this }
> returnThisStrict() === undefined
true
方法

this 指的是调用该方法的对象

> var obj = { method: returnThisStrict };
> obj.method() === obj
true

对于方法,this 的值称为方法调用的接收者

在设置 this 时调用函数:call()、apply() 和 bind()

请记住,函数也是对象。因此,每个函数都有自己的方法。本节介绍了其中的三种方法,它们有助于调用函数。以下几节将使用这三种方法来解决调用函数时遇到的一些陷阱。接下来的示例都引用以下对象 jane

var jane = {
    name: 'Jane',
    sayHelloTo: function (otherName) {
        'use strict';
        console.log(this.name+' says hello to '+otherName);
    }
};

Function.prototype.call(thisValue, arg1?, arg2?, ...)

第一个参数是在被调用函数内部,this 将会拥有的值;其余的参数作为参数传递给被调用函数。以下三种调用是等效的:

jane.sayHelloTo('Tarzan');

jane.sayHelloTo.call(jane, 'Tarzan');

var func = jane.sayHelloTo;
func.call(jane, 'Tarzan');

对于第二次调用,您需要重复 jane,因为 call() 不知道您是如何获得它被调用函数的。

Function.prototype.apply(thisValue, argArray)

第一个参数是在被调用函数内部,this 将会拥有的值;第二个参数是一个数组,它提供了调用的参数。以下三种调用是等效的:

jane.sayHelloTo('Tarzan');

jane.sayHelloTo.apply(jane, ['Tarzan']);

var func = jane.sayHelloTo;
func.apply(jane, ['Tarzan']);

对于第二次调用,您需要重复 jane,因为 apply() 不知道您是如何获得它被调用函数的。

构造函数的 apply() 解释了如何将 apply() 与构造函数一起使用。

Function.prototype.bind(thisValue, arg1?, ..., argN?)

此方法执行部分函数应用,这意味着它创建一个新函数,该函数按以下方式调用 bind() 的接收者:this 的值为 thisValue,参数从 arg1 开始,直到 argN,后面跟着新函数的参数。换句话说,新函数在调用原始函数时,会将它的参数追加到 arg1, ..., argN。让我们看一个例子:

function func() {
    console.log('this: '+this);
    console.log('arguments: '+Array.prototype.slice.call(arguments));
}
var bound = func.bind('abc', 1, 2);

数组方法 slice 用于将 arguments 转换为数组,这对于记录它是必要的(此操作在 类数组对象和泛型方法 中解释)。bound 是一个新函数。以下是交互

> bound(3)
this: abc
arguments: 1,2,3

以下三种调用 sayHelloTo 都是等效的

jane.sayHelloTo('Tarzan');

var func1 = jane.sayHelloTo.bind(jane);
func1('Tarzan');

var func2 = jane.sayHelloTo.bind(jane, 'Tarzan');
func2();

构造函数的 apply()

假设 JavaScript 有一个三点运算符(...),它可以将数组转换为实际参数。这样的运算符将允许您将 Math.max()(请参阅其他函数)与数组一起使用。在这种情况下,以下两个表达式将是等效的

Math.max(...[13, 7, 30])
Math.max(13, 7, 30)

对于函数,您可以通过 apply() 实现三点运算符的效果

> Math.max.apply(null, [13, 7, 30])
30

三点运算符对于构造函数也是有意义的

new Date(...[2011, 11, 24]) // Christmas Eve 2011

唉,这里 apply() 不起作用,因为它只对函数或方法调用有帮助,而对构造函数调用没有帮助。

手动模拟构造函数的 apply()

我们可以分两步模拟 apply()

步骤 1

通过方法调用将参数传递给 Date(它们还没有在数组中)

new (Date.bind(null, 2011, 11, 24))

前面的代码使用 bind() 创建一个没有参数的构造函数,并通过 new 调用它。

步骤 2

使用 apply() 将数组传递给 bind()。因为 bind() 是一个方法调用,所以我们可以使用 apply()

new (Function.prototype.bind.apply(
         Date, [null, 2011, 11, 24]))

前面的数组包含 null,后面跟着 arr 的元素。我们可以使用 concat() 通过将 null 预先添加到 arr 来创建它

var arr = [2011, 11, 24];
new (Function.prototype.bind.apply(
         Date, [null].concat(arr)))

库方法

前面的手动解决方法的灵感来自 Mozilla 发布的库方法。以下是它的一个稍微编辑过的版本

if (!Function.prototype.construct) {
    Function.prototype.construct = function(argArray) {
        if (! Array.isArray(argArray)) {
            throw new TypeError("Argument must be an array");
        }
        var constr = this;
        var nullaryFunc = Function.prototype.bind.apply(
            constr, [null].concat(argArray));
        return new nullaryFunc();
    };
}

以下是使用中的方法

> Date.construct([2011, 11, 24])
Sat Dec 24 2011 00:00:00 GMT+0100 (CET)

另一种方法

另一种方法是通过 Object.create() 创建一个未初始化的实例,然后通过 apply() 调用构造函数(作为函数)。这意味着您实际上是在重新实现 new 运算符(省略了一些检查)

Function.prototype.construct = function(argArray) {
    var constr = this;
    var inst = Object.create(constr.prototype);
    var result = constr.apply(inst, argArray); // (1)

    // Check: did the constructor return an object
    // and prevent `this` from being the result?
    return result ? result : inst;
};

警告

前面的代码不适用于大多数内置构造函数,这些构造函数在作为函数调用时总是生成新的实例。换句话说,第 (1) 行中的步骤没有按预期设置 inst

陷阱:提取方法时丢失 this

如果您从对象中提取一个方法,它将再次成为一个真正的函数。它与对象的连接被切断,通常不再正常工作。以以下对象 counter 为例:

var counter = {
    count: 0,
    inc: function () {
        this.count++;
    }
}

提取 inc 并调用它(作为函数!)失败

> var func = counter.inc;
> func()
> counter.count  // didn’t work
0

解释如下:我们已经将 counter.inc 的值作为函数调用。因此,this 是全局对象,我们执行了 window.count++window.count 不存在,并且是 undefined。对其应用 ++ 运算符会将其设置为 NaN

> count  // global variable
NaN

如何获得警告

如果方法 inc() 处于严格模式,您将收到警告

> counter.inc = function () { 'use strict'; this.count++ };
> var func2 = counter.inc;
> func2()
TypeError: Cannot read property 'count' of undefined

原因是当我们调用严格模式函数 func2 时,thisundefined,从而导致错误。

如何正确提取方法

感谢 bind(),我们可以确保 inc 不会丢失与 counter 的连接

> var func3 = counter.inc.bind(counter);
> func3()
> counter.count  // it worked!
1

回调和提取的方法

在 JavaScript 中,有许多接受回调的函数和方法。浏览器中的示例是 setTimeout() 和事件处理。如果我们将 counter.inc 作为回调传入,它也会作为函数被调用,从而导致刚才描述的相同问题。为了说明这种现象,让我们使用一个简单的回调调用函数:

function callIt(callback) {
    callback();
}

通过 callIt 执行 counter.count 会触发警告(由于严格模式)

> callIt(counter.inc)
TypeError: Cannot read property 'count' of undefined

和以前一样,我们通过 bind() 解决问题

> callIt(counter.inc.bind(counter))
> counter.count  // one more than before
2

警告

每次调用 bind() 都会创建一个新函数。当您注册和取消注册回调时(例如,用于事件处理),这会产生后果。您需要将注册的值存储在某个地方,并在取消注册时也使用它。

陷阱:方法内部的函数会遮蔽 this

您经常在 JavaScript 中嵌套函数定义,因为函数可以是参数(例如,回调),并且可以通过函数表达式就地创建。当一个方法包含一个普通函数,并且您想在后者内部访问前者的 this 时,这就会出现问题,因为方法的 this 被普通函数的 this 遮蔽了(后者甚至不需要它自己的 this)。在下面的例子中,(1) 处的函数试图访问 (2) 处方法的 this

var obj = {
    name: 'Jane',
    friends: [ 'Tarzan', 'Cheeta' ],
    loop: function () {
        'use strict';
        this.friends.forEach(
            function (friend) {  // (1)
                console.log(this.name+' knows '+friend);  // (2)
            }
        );
    }
};

这失败了,因为 (1) 处的函数有它自己的 this,这里它是 undefined

> obj.loop();
TypeError: Cannot read property 'name' of undefined

有三种方法可以解决这个问题。

解决方法 1:that = this

我们将 this 赋值给一个在嵌套函数内部不会被遮蔽的变量

loop: function () {
    'use strict';
    var that = this;
    this.friends.forEach(function (friend) {
        console.log(that.name+' knows '+friend);
    });
}

以下是交互

> obj.loop();
Jane knows Tarzan
Jane knows Cheeta

解决方法 2:bind()

我们可以使用 bind() 为回调提供一个固定的 this 值,即方法的 this(第 (1) 行):

loop: function () {
    'use strict';
    this.friends.forEach(function (friend) {
        console.log(this.name+' knows '+friend);
    }.bind(this));  // (1)
}

解决方法 3:forEach() 的 thisValue

一种特定于 forEach() 的解决方法(请参阅 检查方法)是在回调之后提供第二个参数,该参数将成为回调的 this

loop: function () {
    'use strict';
    this.friends.forEach(function (friend) {
        console.log(this.name+' knows '+friend);
    }, this);
}

第二层:对象之间的原型关系

两个对象之间的原型关系是关于继承的:每个对象都可以有另一个对象作为其原型。然后,前一个对象继承其原型的所有属性。对象通过内部属性 [[Prototype]] 指定其原型。每个对象都有这个属性,但它可以是 null。由 [[Prototype]] 属性连接的对象链称为原型链图 17-1)。

为了了解基于原型的(或原型)继承是如何工作的,让我们看一个例子(使用发明的语法来指定 [[Prototype]] 属性)

var proto = {
    describe: function () {
        return 'name: '+this.name;
    }
};
var obj = {
    [[Prototype]]: proto,
    name: 'obj'
};

对象 objproto 继承属性 describe。它还有一个所谓的自有(非继承的、直接的)属性 name

继承

obj 继承了属性 describe;您可以访问它,就好像对象本身具有该属性一样:

> obj.describe
[Function]

每当您通过 obj 访问属性时,JavaScript 都会从该对象开始搜索它,并继续搜索其原型、原型的原型,依此类推。这就是为什么我们可以通过 obj.describe 访问 proto.describe 的原因。原型链的行为就好像它是一个单一对象一样。当您调用一个方法时,这种错觉会一直保持:this 的值始终是搜索方法开始的对象,而不是找到方法的对象。这允许方法访问原型链的所有属性。例如

> obj.describe()
'name: obj'

describe() 内部,thisobj,这允许方法访问 obj.name

通过原型在对象之间共享数据

原型非常适合在对象之间共享数据:多个对象获得相同的原型,该原型保存所有共享的属性。让我们看一个例子。对象 janetarzan 都包含相同的方法 describe()。这是我们希望通过使用共享来避免的事情:

var jane = {
    name: 'Jane',
    describe: function () {
        return 'Person named '+this.name;
    }
};
var tarzan = {
    name: 'Tarzan',
    describe: function () {
        return 'Person named '+this.name;
    }
};

这两个对象都是人。它们的 name 属性不同,但我们可以让它们共享 describe 方法。我们通过创建一个名为 PersonProto 的公共原型并将 describe 放入其中来实现这一点(图 17-2)。

以下代码创建了共享原型 PersonProto 的对象 janetarzan

var PersonProto = {
    describe: function () {
        return 'Person named '+this.name;
    }
};
var jane = {
    [[Prototype]]: PersonProto,
    name: 'Jane'
};
var tarzan = {
    [[Prototype]]: PersonProto,
    name: 'Tarzan'
};

以下是交互

> jane.describe()
Person named Jane
> tarzan.describe()
Person named Tarzan

这是一种常见的模式:数据驻留在原型链的第一个对象中,而方法驻留在后面的对象中。JavaScript 的原型继承风格旨在支持这种模式:设置属性只影响原型链中的第一个对象,而获取属性则考虑整个链(请参阅 设置和删除只影响自有属性)。

获取和设置原型

到目前为止,我们一直假设您可以从 JavaScript 访问内部属性 [[Prototype]] 但是该语言不允许您这样做。相反,它提供了一些函数,用于读取原型和使用给定原型创建新对象。

使用给定原型创建新对象

调用:

Object.create(proto, propDescObj?)

创建一个原型为 proto 的对象。可以选择通过描述符添加属性(在属性描述符 中解释)。在以下示例中,对象 jane 获取原型 PersonProto 和一个可变属性 name,其值为 'Jane'(通过属性描述符指定)

var PersonProto = {
    describe: function () {
        return 'Person named '+this.name;
    }
};
var jane = Object.create(PersonProto, {
    name: { value: 'Jane', writable: true }
});

以下是交互过程

> jane.describe()
'Person named Jane'

但您通常只创建一个空对象,然后手动添加属性,因为描述符很冗长

var jane = Object.create(PersonProto);
jane.name = 'Jane';

读取对象的原型

此方法 调用:

Object.getPrototypeOf(obj)

返回 obj 的原型。继续前面的示例

> Object.getPrototypeOf(jane) === PersonProto
true

检查一个对象是否是另一个对象的原型

语法:

Object.prototype.isPrototypeOf(obj)

检查方法的接收者是否是 obj 的(直接或间接)原型。换句话说:接收者和 obj 是否在同一个原型链中,并且 obj 是否在接收者之前?例如

> var A = {};
> var B = Object.create(A);
> var C = Object.create(B);
> A.isPrototypeOf(C)
true
> C.isPrototypeOf(A)
false

查找定义属性的对象

以下函数 迭代对象 obj 的属性链。它返回第一个具有键为 propKey 的自有属性的对象,如果没有这样的对象,则返回 null

function getDefiningObject(obj, propKey) {
    obj = Object(obj); // make sure it’s an object
    while (obj && !{}.hasOwnProperty.call(obj, propKey)) {
        obj = Object.getPrototypeOf(obj);
        // obj is null if we have reached the end
    }
    return obj;
}

在前面的代码中,我们 以泛型方式调用了方法 Object.prototype.hasOwnProperty(请参阅 泛型方法:从原型借用方法)。

特殊属性 __proto__

一些 JavaScript 引擎有一个特殊属性,用于 获取和设置对象的原型:__proto__。它为该语言带来了对 [[Prototype]] 的直接访问:

> var obj = {};

> obj.__proto__ === Object.prototype
true

> obj.__proto__ = Array.prototype
> Object.getPrototypeOf(obj) === Array.prototype
true

关于 __proto__,您需要了解以下几点

  • __proto__ 读作“dunder proto”,是“double underscore proto”的缩写。这种读音是从 Python 编程语言借鉴而来的(由 Ned Batchelder 在 2006 年提出)。带有双下划线的特殊变量在 Python 中非常常见。
  • __proto__ 不是 ECMAScript 5 标准的一部分。因此,如果您希望代码符合该标准并在当前的 JavaScript 引擎中可靠运行,则不得使用它。
  • 但是,越来越多的引擎开始支持 __proto__,它将成为 ECMAScript 6 的一部分。
  • 以下表达式检查引擎是否支持将 __proto__ 作为特殊属性

    Object.getPrototypeOf({ __proto__: null }) === null

设置和删除仅影响自有属性

只有获取属性时才会考虑 对象的完整原型链。设置和删除会忽略继承,并且仅影响自有属性。

设置属性

设置属性会创建一个自有属性,即使 存在具有该键的继承属性也是如此。例如,给定以下源代码:

var proto = { foo: 'a' };
var obj = Object.create(proto);

objproto 继承 foo

> obj.foo
'a'
> obj.hasOwnProperty('foo')
false

设置 foo 会产生预期的结果

> obj.foo = 'b';
> obj.foo
'b'

但是,我们创建了一个自有属性,并没有更改 proto.foo

> obj.hasOwnProperty('foo')
true
> proto.foo
'a'

其基本原理是,原型属性旨在由多个对象共享。这种方法允许我们以非破坏性的方式“更改”它们——只有当前对象会受到影响。

删除继承的属性

您只能删除自有属性。 让我们再次设置一个对象 obj,其原型为 proto

var proto = { foo: 'a' };
var obj = Object.create(proto);

删除继承的属性 foo 没有任何效果

> delete obj.foo
true
> obj.foo
'a'

有关 delete 运算符的更多信息,请参阅删除属性

更改原型链中任何位置的属性

如果要更改继承的属性, 则必须先找到拥有该属性的对象(请参阅 查找定义属性的对象),然后对该对象执行更改。例如,让我们从前面的示例中删除属性 foo

> delete getDefiningObject(obj, 'foo').foo;
true
> obj.foo
undefined

用于 迭代和检测属性的操作受以下因素影响:

继承(自有属性与继承属性)
对象的自身属性直接存储在该对象中。继承的属性存储在其原型之一中。
可枚举性(可枚举属性与不可枚举属性)
属性的 可枚举性是一个 特性(请参阅 属性特性和属性描述符),一个 可以是 truefalse 的标志。可枚举性很少重要,通常可以忽略(请参阅 可枚举性:最佳实践)。

您可以列出自有属性键,列出所有可枚举属性键,以及检查属性是否存在。以下小节将展示如何操作。

列出自有属性键

您可以列出所有自有 属性键,也可以仅列出可枚举的属性键:

请注意,属性通常是可枚举的(请参阅可枚举性:最佳实践),因此您可以使用 Object.keys(),尤其是对于您创建的对象。

列出所有属性键

如果要列出对象的全部属性(包括自有属性和继承属性),则有两种选择。

选项 1 是使用循环

for («variable» in «object»)
    «statement»

迭代 object 的所有可枚举属性的键。有关更详细的说明,请参阅 for-in

选项 2 是自己实现一个函数,该函数迭代所有属性(而不仅仅是可枚举属性)。例如

function getAllPropertyNames(obj) {
    var result = [];
    while (obj) {
        // Add the own property names of `obj` to `result`
        result = result.concat(Object.getOwnPropertyNames(obj));
        obj = Object.getPrototypeOf(obj);
    }
    return result;
}

检查属性是否存在

您可以检查对象是否具有某个属性, 或者某个属性是否存在于对象内部:

propKey in obj
如果 obj 具有键为 propKey 的属性,则返回 true。此测试包括继承的属性。
Object.prototype.hasOwnProperty(propKey)
如果接收者(this)具有 键为 propKey 的自有(非继承)属性,则返回 true

警告

避免直接在对象上调用 hasOwnProperty(),因为它可能会被覆盖(例如,被键为 hasOwnProperty 的自有属性覆盖)

> var obj = { hasOwnProperty: 1, foo: 2 };
> obj.hasOwnProperty('foo')  // unsafe
TypeError: Property 'hasOwnProperty' is not a function

相反,最好以泛型方式调用它(请参阅泛型方法:从原型借用方法

> Object.prototype.hasOwnProperty.call(obj, 'foo')  // safe
true
> {}.hasOwnProperty.call(obj, 'foo')  // shorter
true

示例

以下示例基于以下定义:

var proto = Object.defineProperties({}, {
    protoEnumTrue: { value: 1, enumerable: true },
    protoEnumFalse: { value: 2, enumerable: false }
});
var obj = Object.create(proto, {
    objEnumTrue: { value: 1, enumerable: true },
    objEnumFalse: { value: 2, enumerable: false }
});

Object.defineProperties()通过描述符获取和定义属性 中进行了解释,但它的工作原理应该相当明显:proto 具有自有属性 protoEnumTrueprotoEnumFalse,而 obj 具有自有属性 objEnumTrueobjEnumFalse(并继承 proto 的所有属性)。

注意

请注意,对象(例如前面示例中的 proto)通常至少具有原型 Object.prototype(其中定义了标准方法,例如 toString()hasOwnProperty()

> Object.getPrototypeOf({}) === Object.prototype
true

可枚举性的影响

在与 属性相关的操作中,可枚举性仅影响 for-in 循环Object.keys()(它也影响 JSON.stringify(),请参阅 JSON.stringify(value, replacer?, space?))。

for-in 循环迭代所有可枚举属性的键,包括继承的属性(请注意,Object.prototype 的任何不可枚举属性都不会出现)

> for (var x in obj) console.log(x);
objEnumTrue
protoEnumTrue

Object.keys() 返回所有自有(非继承)可枚举属性的键

> Object.keys(obj)
[ 'objEnumTrue' ]

如果需要所有自有属性的键,则需要使用 Object.getOwnPropertyNames()

> Object.getOwnPropertyNames(obj)
[ 'objEnumTrue', 'objEnumFalse' ]

继承的影响

只有 for-in 循环(请参阅前面的示例)in 运算符会考虑继承:

> 'toString' in obj
true
> obj.hasOwnProperty('toString')
false
> obj.hasOwnProperty('objEnumFalse')
true

计算对象的自身属性数量

对象没有lengthsize 这样的方法,因此您必须使用以下解决方法:

Object.keys(obj).length

最佳实践:迭代自有属性

迭代 属性键:

  • for-in 中描述的方式,将 for-inhasOwnProperty() 结合使用。即使在较旧的 JavaScript 引擎上,这种方法也适用。例如

    for (var key in obj) {
        if (Object.prototype.hasOwnProperty.call(obj, key)) {
            console.log(key);
        }
    }
  • Object.keys()Object.getOwnPropertyNames()forEach() 数组迭代结合使用

    var obj = { first: 'John', last: 'Doe' };
    // Visit non-inherited enumerable keys
    Object.keys(obj).forEach(function (key) {
        console.log(key);
    });

迭代属性值或(键,值)对

  • 迭代键,并使用每个键检索对应的值。其他语言使这变得更简单,但 JavaScript 不是。

访问器(获取器和设置器)

ECMAScript 5 允许 您编写方法,这些方法的调用看起来就像是在获取或设置属性。这意味着属性是虚拟的,而不是存储空间。例如,您可以禁止设置属性,并始终计算读取属性时返回的值。

通过对象字面量定义访问器

以下 示例使用对象字面量为属性 foo 定义设置器和获取器:

var obj = {
    get foo() {
        return 'getter';
    },
    set foo(value) {
        console.log('setter: '+value);
    }
};

以下是交互

> obj.foo = 'bla';
setter: bla
> obj.foo
'getter'

通过属性描述符定义访问器

指定获取器和设置器的另一种方法 是通过属性描述符(请参阅 属性描述符)。以下代码定义与前面的字面量相同的对象

var obj = Object.create(
    Object.prototype, {  // object with property descriptors
        foo: {  // property descriptor
            get: function () {
                return 'getter';
            },
            set: function (value) {
                console.log('setter: '+value);
            }
        }
    }
);

访问器和继承

获取器和 设置器是从原型继承的:

> var proto = { get foo() { return 'hello' } };
> var obj = Object.create(proto);

> obj.foo
'hello'

属性特性和属性描述符

提示

属性特性和属性描述符是一个高级主题。您通常不需要知道它们是如何工作的。

在本节中,我们将研究 属性的内部结构:

  • 属性特性 是属性的原子构建块。
  • 属性描述符 是一种数据结构,用于以编程方式处理属性。

属性特性

属性的所有状态,包括其数据和元数据,都存储在 特性 中。它们是属性所具有的字段,就像对象具有属性一样。特性键通常用双括号括起来。特性对于普通属性和访问器(getter 和 setter)都很重要。

以下特性特定于普通属性

  • [[Value]] 保存属性的值,即其数据。
  • [[Writable]] 保存一个布尔值,指示是否可以更改属性的值。

以下特性特定于访问器:

  • [[Get]] 保存 getter,这是一个在读取属性时调用的函数。该函数计算读取访问的结果。
  • [[Set]] 保存 setter,这是一个在将属性设置为某个值时调用的函数。该函数接收该值作为参数。

所有属性都具有以下特性:

  • [[Enumerable]] 保存一个布尔值。将属性设置为不可枚举会将其从某些操作中隐藏(请参阅属性的迭代和检测)。
  • [[Configurable]] 保存一个布尔值。如果为 false,则无法删除属性、更改其任何特性([[Value]] 除外)或将其从数据属性转换为访问器属性,反之亦然。换句话说,[[Configurable]] 控制属性元数据的可写性。此规则有一个例外 - 出于历史原因,JavaScript 允许您将不可配置的属性从可写更改为只读;数组的 length 属性始终是可写且不可配置的。如果没有此例外,您将无法冻结(请参阅冻结)数组。

默认值

如果未指定特性,则使用以下默认值:

特性键默认值

[[Value]]

undefined

[[Get]]

undefined

[[Set]]

undefined

[[Writable]]

false

[[Enumerable]]

false

[[Configurable]]

false

当您通过属性描述符创建属性时,这些默认值很重要(请参阅下一节)。

属性描述符

属性描述符是一种用于以编程方式处理特性的数据结构。它是一个编码属性特性的对象。描述符的每个属性都对应于一个特性。例如,以下是一个只读属性的描述符,其值为 123:

{
    value: 123,
    writable: false,
    enumerable: true,
    configurable: false
}

您可以通过访问器实现相同的目标,即不变性。然后描述符如下所示

{
    get: function () { return 123 },
    enumerable: true,
    configurable: false
}

属性描述符用于两种操作:

获取属性
属性的所有特性都作为描述符返回。
定义属性

定义属性的含义取决于属性是否已存在

  • 如果属性不存在,则创建一个新属性,其特性由描述符指定。如果描述符中没有与特性对应的属性,则使用默认值。默认值由特性名称的含义决定。它们与通过赋值创建属性时使用的值相反(然后该属性是可写的、可枚举的和可配置的)。 例如:

    > var obj = {};
    > Object.defineProperty(obj, 'foo', { configurable: true });
    > Object.getOwnPropertyDescriptor(obj, 'foo')
    { value: undefined,
      writable: false,
      enumerable: false,
      configurable: true }

    我通常不依赖默认值,而是显式声明所有特性,以便完全清楚。

  • 如果属性已存在,则更新描述符指定的属性特性。如果描述符中没有与特性对应的属性,则不要更改它。以下是一个示例(续前例)

    > Object.defineProperty(obj, 'foo', { writable: true });
    > Object.getOwnPropertyDescriptor(obj, 'foo')
    { value: undefined,
      writable: true,
      enumerable: false,
      configurable: true }

以下操作允许您通过属性描述符获取和设置属性的特性:

Object.getOwnPropertyDescriptor(obj, propKey)

返回 obj 的自身(非继承)属性的描述符,其键为 propKey。如果没有这样的属性,则返回 undefined

> Object.getOwnPropertyDescriptor(Object.prototype, 'toString')
{ value: [Function: toString],
  writable: true,
  enumerable: false,
  configurable: true }

> Object.getOwnPropertyDescriptor({}, 'toString')
undefined
Object.defineProperty(obj, propKey, propDesc)

创建或更改 obj 的属性,其键为 propKey,其特性由 propDesc 指定。返回修改后的对象。例如

var obj = Object.defineProperty({}, 'foo', {
    value: 123,
    enumerable: true
    // writable: false (default value)
    // configurable: false (default value)
});
Object.defineProperties(obj, propDescObj)

Object.defineProperty() 的批量版本。 propDescObj 的每个属性都包含一个属性描述符。属性的键及其值告诉 Object.defineProperties 要在 obj 上创建或更改哪些属性。例如:

var obj = Object.defineProperties({}, {
    foo: { value: 123, enumerable: true },
    bar: { value: 'abc', enumerable: true }
});
Object.create(proto, propDescObj?)

首先,创建一个原型为 proto 的对象。然后,如果指定了可选参数 propDescObj,则向其添加属性 - 方式与 Object.defineProperties 相同。最后,返回结果。例如,以下代码段生成与前一个代码段相同的结果

var obj = Object.create(Object.prototype, {
    foo: { value: 123, enumerable: true },
    bar: { value: 'abc', enumerable: true }
});

复制对象

要创建对象的相同副本,您需要确保两件事:

  1. 副本必须与原始对象具有相同的原型(请参阅第 2 层:对象之间的原型关系)。
  2. 副本必须具有与原始对象相同的属性,以及相同的特性。

以下函数执行此类复制

function copyObject(orig) {
    // 1. copy has same prototype as orig
    var copy = Object.create(Object.getPrototypeOf(orig));

    // 2. copy has all of orig’s properties
    copyOwnPropertiesFrom(copy, orig);

    return copy;
}

属性通过此函数从 orig 复制到 copy

function copyOwnPropertiesFrom(target, source) {
    Object.getOwnPropertyNames(source)  // (1)
    .forEach(function(propKey) {  // (2)
        var desc = Object.getOwnPropertyDescriptor(source, propKey); // (3)
        Object.defineProperty(target, propKey, desc);  // (4)
    });
    return target;
};

以下是涉及的步骤

  1. 获取包含 source 的所有自身属性键的数组。
  2. 迭代这些键。
  3. 检索属性描述符。
  4. 使用该属性描述符在 target 中创建自身属性。

请注意,此函数与 Underscore.js 库中的 _.extend() 函数非常相似。

属性:定义与赋值

以下两个操作非常相似:

但是,有一些细微的差别

  • 定义属性 意味着创建新的自身属性或更新现有自身属性的特性。在这两种情况下,原型链都被完全忽略。
  • 为属性赋值 prop 意味着更改现有属性。过程如下:

    • 如果 prop 是 setter(自身或继承的),则调用该 setter。
    • 否则,如果 prop 是只读的(自身或继承的),则抛出异常(在严格模式下)或不执行任何操作(在宽松模式下)。下一节将更详细地解释这种(稍微出乎意料的)现象。
    • 否则,如果 prop 是自身的(并且是可写的),则更改该属性的值。
    • 否则,要么没有属性 prop,要么它是继承的并且是可写的。在这两种情况下,都定义一个自身属性 prop,它是可写的、可配置的和可枚举的。在后一种情况下,我们只是覆盖了继承的属性(非破坏性地更改了它)。在前一种情况下,自动定义了缺少的属性。这种自动定义是有问题的,因为赋值中的拼写错误可能难以检测。

无法为继承的只读属性赋值

如果对象 obj从原型继承了属性 foo,并且 foo 是不可写的,则无法为 obj.foo 赋值:

var proto = Object.defineProperty({}, 'foo', {
    value: 'a',
    writable: false
});
var obj = Object.create(proto);

objproto 继承了只读属性 foo。在宽松模式下,设置该属性无效

> obj.foo = 'b';
> obj.foo
'a'

在严格模式下,您会收到异常

> (function () { 'use strict'; obj.foo = 'b' }());
TypeError: Cannot assign to read-only property 'foo'

这符合赋值更改继承的属性但非破坏性的想法。如果继承的属性是只读的,则您要禁止所有更改,即使是非破坏性的更改。

请注意,您可以通过定义自身属性来绕过此保护(请参阅上一小节,了解定义和赋值之间的区别)

> Object.defineProperty(obj, 'foo', { value: 'b' });
> obj.foo
'b'

可枚举性:最佳实践

一般规则是,系统创建的属性是不可枚举的,而用户创建的属性是可枚举的:

> Object.keys([])
[]
> Object.getOwnPropertyNames([])
[ 'length' ]

> Object.keys(['a'])
[ '0' ]

对于内置实例原型的 方法尤其如此

> Object.keys(Object.prototype)
[]
> Object.getOwnPropertyNames(Object.prototype)
[ hasOwnProperty',
  'valueOf',
  'constructor',
  'toLocaleString',
  'isPrototypeOf',
  'propertyIsEnumerable',
  'toString' ]

可枚举性的主要目的是告诉 for-in 循环它应该忽略哪些属性。正如我们刚才在查看内置构造函数的实例时所见,所有不是由用户创建的内容都对 for-in 隐藏。

受可枚举性影响的操作只有

以下是一些要记住的最佳实践

  • 对于您自己的代码,您通常可以忽略可枚举性,并且应该避免使用 for-in 循环(最佳实践:迭代数组)。
  • 您通常不应该向内置原型和对象添加属性。但是,如果您这样做,则应将它们设置为不可枚举,以避免破坏现有代码。

保护对象

保护对象有三个级别,此处从最弱到最强列出:

  • 防止扩展
  • 密封
  • 冻结

防止扩展

通过以下方式防止扩展:

Object.preventExtensions(obj)

使无法向 obj 添加属性。例如

var obj = { foo: 'a' };
Object.preventExtensions(obj);

现在,在宽松模式下添加属性会静默失败

> obj.bar = 'b';
> obj.bar
undefined

并在严格模式下抛出错误

> (function () { 'use strict'; obj.bar = 'b' }());
TypeError: Can't add property bar, object is not extensible

但是,您仍然可以删除属性

> delete obj.foo
true
> obj.foo
undefined

您可以通过以下方式检查对象是否可扩展

Object.isExtensible(obj)

密封

通过以下方式密封

Object.seal(obj)

防止扩展并将所有属性设置为“不可配置”。后者意味着属性的特性(请参阅属性特性和属性描述符)不能再更改。例如,只读属性将永远保持只读。

以下示例演示了密封如何使所有属性都不可配置

> var obj = { foo: 'a' };

> Object.getOwnPropertyDescriptor(obj, 'foo')  // before sealing
{ value: 'a',
  writable: true,
  enumerable: true,
  configurable: true }

> Object.seal(obj)

> Object.getOwnPropertyDescriptor(obj, 'foo')  // after sealing
{ value: 'a',
  writable: true,
  enumerable: true,
  configurable: false }

您仍然可以更改属性 foo

> obj.foo = 'b';
'b'
> obj.foo
'b'

但您不能更改其特性

> Object.defineProperty(obj, 'foo', { enumerable: false });
TypeError: Cannot redefine property: foo

您可以通过以下方式检查对象是否已密封

Object.isSealed(obj)

冻结

通过以下方式冻结

Object.freeze(obj)

它使所有属性都不可写,并密封 obj。换句话说,obj 是不可扩展的,并且所有属性都是只读的,并且无法更改。让我们看一个例子:

var point = { x: 17, y: -5 };
Object.freeze(point);

同样,在宽松模式下,您会遇到静默失败

> point.x = 2;  // no effect, point.x is read-only
> point.x
17

> point.z = 123;  // no effect, point is not extensible
> point
{ x: 17, y: -5 }

在严格模式下,您会收到错误

> (function () { 'use strict'; point.x = 2 }());
TypeError: Cannot assign to read-only property 'x'

> (function () { 'use strict'; point.z = 123 }());
TypeError: Can't add property z, object is not extensible

您可以通过以下方式检查对象是否已冻结

Object.isFrozen(obj)

陷阱:保护是浅层的

保护对象是浅层的:它会影响自身属性,但不会影响这些属性的值。例如,请考虑以下对象:

var obj = {
    foo: 1,
    bar: ['a', 'b']
};
Object.freeze(obj);

即使您已经冻结了 obj,它也不是完全不可变的——您仍然可以更改属性 bar 的(可变)值。

> obj.foo = 2; // no effect
> obj.bar.push('c'); // changes obj.bar

> obj
{ foo: 1, bar: [ 'a', 'b', 'c' ] }

此外,obj 具有原型 Object.prototype,它也是可变的。

第三层:构造函数——实例的工厂

一个构造函数(简称:构造器)有助于生成在某种程度上相似的对象。它是一个普通函数,但它的命名、设置和调用方式都不同。本节解释了构造函数的工作原理。它们对应于其他语言中的类。

我们已经看到了两个相似对象的例子(在通过原型在对象之间共享数据

var PersonProto = {
    describe: function () {
        return 'Person named '+this.name;
    }
};
var jane = {
    [[Prototype]]: PersonProto,
    name: 'Jane'
};
var tarzan = {
    [[Prototype]]: PersonProto,
    name: 'Tarzan'
};

对象 janetarzan 都被认为是“人”,并且共享原型对象 PersonProto。让我们把这个原型变成一个构造函数 Person,它可以创建像 janetarzan 这样的对象。构造函数创建的对象称为它的实例。这样的实例具有与 janetarzan 相同的结构,由两部分组成

  1. 数据是实例特有的,存储在实例对象的自身属性中(前面例子中的 janetarzan)。
  2. 所有实例共享行为——它们有一个带有方法的公共原型对象(前面例子中的 PersonProto)。

构造函数是一个通过 new 运算符调用的函数。按照惯例,构造函数的名称以大写字母开头,而普通函数和方法的名称以小写字母开头。函数本身设置了第一部分

function Person(name) {
    this.name = name;
}

Person.prototype 中的对象成为 Person 的所有实例的原型。它贡献了第二部分

Person.prototype.describe = function () {
    return 'Person named '+this.name;
};

让我们创建并使用 Person 的一个实例

> var jane = new Person('Jane');
> jane.describe()
'Person named Jane'

我们可以看到 Person 是一个普通函数。它 只有在通过 new 调用时才成为构造函数。new 运算符执行以下步骤:

  • 首先设置行为:创建一个新对象,其原型是 Person. prototype
  • 然后设置数据:Person 接收该对象作为隐式参数 this 并添加实例属性。

图 17-3 显示了实例 jane 的样子。Person.prototype 的属性 constructor 指向构造函数,这在实例的 constructor 属性中进行了解释。

instanceof 运算符允许我们检查一个对象是否是给定构造函数的实例

> jane instanceof Person
true
> jane instanceof Date
false

用 JavaScript 实现的 new 运算符

如果您要手动实现 new 运算符,它大致如下所示:

function newOperator(Constr, args) {
    var thisValue = Object.create(Constr.prototype); // (1)
    var result = Constr.apply(thisValue, args);
    if (typeof result === 'object' && result !== null) {
        return result; // (2)
    }
    return thisValue;
}

在第 (1) 行中,您可以看到由构造函数 Constr 创建的实例的原型是 Constr.prototype

第 (2) 行揭示了 new 运算符的另一个特性:您可以从构造函数返回一个任意对象,它将成为 new 运算符的结果。如果您希望构造函数返回子构造函数的实例,这将非常有用(从构造函数返回任意对象中给出了一个例子)。

术语:两种原型

不幸的是,术语原型在 JavaScript 中的使用 具有歧义:

原型 1:原型关系

一个对象可以是另一个对象的原型

> var proto = {};
> var obj = Object.create(proto);
> Object.getPrototypeOf(obj) === proto
true

在前面的例子中,protoobj 的原型。

原型 2:属性 prototype 的值

每个构造函数 C 都有一个 prototype 属性,它引用一个对象。该对象成为 C 的所有实例的原型:

> function C() {}
> Object.getPrototypeOf(new C()) === C.prototype
true

通常,上下文会清楚地表明指的是哪种原型。如果需要消除歧义,那么我们只能使用原型来描述对象之间的关系,因为该名称已经通过 getPrototypeOfisPrototypeOf 进入了标准库。因此,我们需要为 prototype 属性引用的对象找到一个不同的名称。一种可能是构造函数原型,但这有问题,因为构造函数也有原型

> function Foo() {}
> Object.getPrototypeOf(Foo) === Function.prototype
true

因此,实例原型是最好的选择。

实例的 constructor 属性

默认情况下,每个函数 C 都包含一个实例原型对象 C.prototype,其属性 constructor 指向 C

> function C() {}
> C.prototype.constructor === C
true

因为 constructor 属性是由每个实例从原型继承的,所以您可以使用它来获取实例的构造函数

> var o = new C();
> o.constructor
[Function: C]

constructor 属性的用例

切换对象的构造函数

以下 catch 子句中,我们根据捕获的异常的构造函数采取不同的操作:

try {
    ...
} catch (e) {
    switch (e.constructor) {
        case SyntaxError:
            ...
            break;
        case CustomError:
            ...
            break;
        ...
    }
}

警告

此方法仅检测给定构造函数的直接实例。相反,instanceof 检测直接实例和所有子构造函数的实例。

确定对象构造函数的名称

例如

> function Foo() {}
> var f = new Foo();
> f.constructor.name
'Foo'

警告

并非所有 JavaScript 引擎都支持函数的 name 属性。

创建类似的对象

这就是您创建一个新对象 y 的方法,该对象与现有对象 x 具有相同的构造函数

function Constr() {}
var x = new Constr();

var y = new x.constructor();
console.log(y instanceof Constr); // true

这个技巧对于必须处理子构造函数实例并且想要创建一个类似于 this 的新实例的方法来说非常方便。然后您就不能使用固定的构造函数

SuperConstr.prototype.createCopy = function () {
    return new this.constructor(...);
};
引用超构造函数

一些继承库将超原型分配给子构造函数的属性。例如,YUI 框架通过Y.extend 提供子类化

function Super() {
}
function Sub() {
    Sub.superclass.constructor.call(this); // (1)
}
Y.extend(Sub, Super);

第 (1) 行中的调用有效,因为 extendSub.superclass 设置为 Super.prototype。由于 constructor 属性,您可以将超构造函数作为方法调用。

注意

instanceof 运算符(参见instanceof 运算符)不依赖于属性 constructor

最佳实践

确保对于每个构造函数 C,以下断言成立:

C.prototype.constructor === C

默认情况下,每个函数 f 已经有一个正确设置的属性 prototype

> function f() {}
> f.prototype.constructor === f
true

因此,您应该避免替换此对象,而只向其添加属性

// Avoid:
C.prototype = {
    method1: function (...) { ... },
    ...
};

// Prefer:
C.prototype.method1 = function (...) { ... };
...

如果确实要替换它,则应手动为 constructor 分配正确的值

C.prototype = {
    constructor: C,
    method1: function (...) { ... },
    ...
};

请注意,JavaScript 中没有任何关键内容依赖于 constructor 属性;但最好设置它,因为它启用了本节中提到的技术。

instanceof 运算符

instanceof 运算符

value instanceof Constr

确定 value 是否由构造函数 Constr 或子构造函数创建。它通过检查 Constr.prototype 是否在 value 的原型链中来实现。因此,以下两个表达式是等效的:

value instanceof Constr
Constr.prototype.isPrototypeOf(value)

以下是一些示例

> {} instanceof Object
true

> [] instanceof Array  // constructor of []
true
> [] instanceof Object  // super-constructor of []
true

> new Date() instanceof Date
true
> new Date() instanceof Object
true

正如预期的那样,对于原始值,instanceof 始终为 false

> 'abc' instanceof Object
false
> 123 instanceof Number
false

最后,如果 instanceof 的右侧不是函数,则会抛出异常

> [] instanceof 123
TypeError: Expecting a function in instanceof check

陷阱:不是 Object 实例的对象

几乎所有对象都是 Object 的实例,因为 Object.prototype 在它们的原型链中。但也有一些对象并非如此。以下是两个例子:

> Object.create(null) instanceof Object
false
> Object.prototype instanceof Object
false

前一个对象在dict 模式:没有原型的对象是更好的映射中有更详细的解释。后一个对象是大多数原型链结束的地方(它们必须在某个地方结束)。这两个对象都没有原型

> Object.getPrototypeOf(Object.create(null))
null
> Object.getPrototypeOf(Object.prototype)
null

typeof 正确地将它们分类为对象

> typeof Object.create(null)
'object'
> typeof Object.prototype
'object'

对于 instanceof 的大多数用例来说,这个陷阱并不是一个破坏因素,但您必须意识到它。

陷阱:跨域(框架或窗口)

在 Web 浏览器中,每个框架和窗口都有自己的,其中包含独立的全局变量。这会阻止 instanceof 对跨域的对象起作用。要了解原因,请查看以下代码:

if (myvar instanceof Array) ...  // Doesn’t always work

如果 myvar 是来自不同域的数组,则其原型是该域的 Array.prototype。因此,instanceof 将不会在 myvar 的原型链中找到当前域的 Array.prototype,并将返回 false。ECMAScript 5 具有函数 Array.isArray(),它 始终有效:

<head>
    <script>
        function test(arr) {
            var iframe = frames[0];

            console.log(arr instanceof Array); // false
            console.log(arr instanceof iframe.Array); // true
            console.log(Array.isArray(arr)); // true
        }
    </script>
</head>
<body>
    <iframe srcdoc="<script>window.parent.test([])</script>">
    </iframe>
</body>

显然,对于非内置构造函数,这也是一个问题。

除了使用 Array.isArray() 之外,您还可以采取多种措施来解决此问题

  • 避免对象跨域。浏览器具有 postMessage() 方法,该方法可以将对象复制到另一个域,而不是传递引用。
  • 检查实例的构造函数的名称(仅适用于支持函数的 name 属性的引擎)

    someValue.constructor.name === 'NameOfExpectedConstructor'
  • 使用原型属性将实例标记为属于类型 T。您可以通过多种方式实现此目的。检查 value 是否是 T 的实例如下所示

    • value.isT()T 实例的原型必须从此方法返回 true;公共超构造函数应返回默认值 false
    • 'T' in value:您必须使用其键为 'T'(或更唯一的内容)的属性标记 T 实例的原型。
    • value.TYPE_NAME === 'T':每个相关原型都必须具有一个具有适当值的 TYPE_NAME 属性。

本节提供了一些实现构造函数的技巧。

防止忘记 new:严格模式

如果您在使用构造函数时忘记了 new,则您 是将其作为函数而不是构造函数调用的。在草率模式下,您不会获得实例,并且会创建全局变量。不幸的是,所有这些都是在没有警告的情况下发生的:

function SloppyColor(name) {
    this.name = name;
}
var c = SloppyColor('green'); // no warning!

// No instance is created:
console.log(c); // undefined
// A global variable is created:
console.log(name); // green

在严格模式下,您会收到异常

function StrictColor(name) {
    'use strict';
    this.name = name;
}
var c = StrictColor('green');
// TypeError: Cannot set property 'name' of undefined

从构造函数返回任意对象

在许多面向对象的语言中,构造函数 只能生成直接实例。例如,以 Java 为例:假设您要实现一个具有子类 AdditionMultiplication 的类 Expression。解析会生成后两个类的直接实例。您不能将其作为 Expression 的构造函数来实现,因为该构造函数只能生成 Expression 的直接实例。作为一种解决方法,Java 中使用了静态工厂方法:

class Expression {
    // Static factory method:
    public static Expression parse(String str) {
        if (...) {
            return new Addition(...);
        } else if (...) {
            return new Multiplication(...);
        } else {
            throw new ExpressionException(...);
        }
    }
}
...
Expression expr = Expression.parse(someStr);

在 JavaScript 中,您可以简单地从构造函数返回所需的任何对象。因此,前面代码的 JavaScript 版本如下所示

function Expression(str) {
    if (...) {
        return new Addition(..);
    } else if (...) {
        return new Multiplication(...);
    } else {
        throw new ExpressionException(...);
    }
}
...
var expr = new Expression(someStr);

这是一个好消息:JavaScript 构造函数不会限制您,因此您可以随时改变主意,决定构造函数是应该返回直接实例还是其他内容。

原型属性中的数据

本节说明,在大多数情况下,您不应将数据放在原型属性中。但是,该规则也有一些例外。

避免使用具有实例属性初始值的原型属性

原型包含由多个对象共享的属性。因此,它们适用于方法。此外,使用接下来描述的技术,您还可以使用它们为实例属性提供初始值。稍后我将解释为什么不建议这样做。

构造函数通常将实例属性设置为初始值。如果其中一个值是默认值,则您不需要创建实例属性。您只需要一个具有相同键的原型属性,其值为默认值。例如

/**
 * Anti-pattern: don’t do this
 *
 * @param data an array with names
 */
function Names(data) {
    if (data) {
        // There is a parameter
        // => create instance property
        this.data = data;
    }
}
Names.prototype.data = [];

参数 data 是可选的。如果缺少它,则实例不会获得属性 data,而是继承 Names.prototype.data

这种方法通常有效:您可以创建 Names 的实例 n。获取 n.data 会读取 Names.prototype.data。设置 n.data 会在 n 中创建一个新的自有属性,并在原型中保留共享的默认值。只有当我们更改默认值(而不是用新值替换它)时,才会出现问题

> var n1 = new Names();
> var n2 = new Names();

> n1.data.push('jane'); // changes default value
> n1.data
[ 'jane' ]

> n2.data
[ 'jane' ]

在前面的示例中,push() 更改了 Names.prototype.data 中的数组。由于该数组由没有自有属性 data 的所有实例共享,因此 n2.data 的初始值也已更改。

最佳实践:不要共享默认值

鉴于我们刚刚讨论的内容,最好不要共享默认值,而是始终创建新的默认值

function Names(data) {
    this.data = data || [];
}

显然,如果默认值是不可变的(所有基本类型都是如此;请参阅基本类型值),则不会出现修改共享默认值的问题。但为了保持一致性,最好坚持使用一种设置属性的方法。我还更喜欢保持通常的关注点分离(请参阅第 3 层:构造函数 - 实例的工厂):构造函数设置实例属性,原型包含方法。

ECMAScript 6 将使其更加成为最佳实践,因为构造函数参数可以具有默认值,并且您可以通过类定义原型方法,但不能定义具有数据的原型属性。

按需创建实例属性

有时,创建属性值是一项开销很大的操作(在计算或存储方面)。在这种情况下,您可以按需创建实例属性:

function Names(data) {
    if (data) this.data = data;
}
Names.prototype = {
    constructor: Names, // (1)
    get data() {
        // Define, don’t assign
        // => avoid calling the (nonexistent) setter
        Object.defineProperty(this, 'data', {
            value: [],
            enumerable: true,
            configurable: false,
            writable: false
        });
        return this.data;
    }
};

我们无法通过赋值将属性 data 添加到实例,因为 JavaScript 会抱怨缺少设置器(当它只找到获取器时就会这样做)。因此,我们通过 Object.defineProperty() 添加它。请参阅属性:定义与赋值以查看定义和赋值之间的区别。在第 (1) 行中,我们确保正确设置了属性 constructor(请参阅实例的构造函数属性)。

显然,这是相当多的工作,所以您必须确保它是值得的。

避免非多态原型属性

如果相同的属性(相同的键、相同的语义、通常不同的值)存在于多个原型中,则称其为多态的。然后,通过实例读取属性的结果是通过该实例的原型动态确定的。非多态使用的原型属性可以用变量替换(这更好地反映了它们的非多态性质)。

例如,您可以将常量存储在原型属性中,并通过 this 访问它

function Foo() {}
Foo.prototype.FACTOR = 42;
Foo.prototype.compute = function (x) {
    return x * this.FACTOR;
};

此常量不是多态的。因此,您也可以通过变量访问它

// This code should be inside an IIFE or a module
function Foo() {}
var FACTOR = 42;
Foo.prototype.compute = function (x) {
    return x * FACTOR;
};

多态原型属性

以下是一个具有不可变数据的多态原型属性示例。通过原型属性标记构造函数的实例,您可以将它们与其他构造函数的实例区分开来:

function ConstrA() { }
ConstrA.prototype.TYPE_NAME = 'ConstrA';

function ConstrB() { }
ConstrB.prototype.TYPE_NAME = 'ConstrB';

由于多态“标记” TYPE_NAME,即使在跨领域时,您也可以区分 ConstrAConstrB 的实例(然后 instanceof 不起作用;请参阅陷阱:跨领域(框架或窗口))。

保持数据私有

JavaScript 没有用于管理对象的私有数据的专用方法。本节将介绍三种解决此限制的技术:

  • 构造函数环境中的私有数据
  • 具有标记键的属性中的私有数据
  • 具有具体化键的属性中的私有数据

此外,我将解释如何通过 IIFE 保持全局数据私有。

构造函数环境中的私有数据(Crockford 隐私模式)

调用构造函数时,会创建两个东西:构造函数的实例和一个环境(请参阅环境:管理变量)。该实例将由构造函数初始化。环境保存构造函数的参数和局部变量。在构造函数内部创建的每个函数(包括方法)都将保留对环境的引用,即创建它的环境。这种函数和环境的组合称为闭包闭包:函数与其诞生作用域保持连接)。因此,构造函数的环境是独立于实例的数据存储,并且仅因为两者同时创建才与其相关。为了正确连接它们,我们必须拥有同时存在于两个世界中的函数。使用Douglas Crockford 的术语,实例可以具有与其关联的三种值(请参阅图 17-4

公共属性
存储在属性(在实例中或其原型中)中的值是可公开访问的。
私有值
存储在环境中的数据和函数是私有的,只有构造函数和它创建的函数才能访问。
特权方法
私有函数可以访问公共属性,但原型中的公共方法不能访问私有数据。因此,我们需要特权方法 - 实例中的公共方法。特权方法是公共的,每个人都可以调用,但它们也可以访问私有值,因为它们是在构造函数中创建的。

以下各节更详细地解释了每种值。

公共属性

请记住,给定一个构造函数 Constr,有两种属性是公共的,每个人都可以访问。首先,原型属性存储在 Constr.prototype 中,并由所有实例共享。原型属性通常是方法:

Constr.prototype.publicMethod = ...;

其次,实例属性对于每个实例都是唯一的。它们在构造函数中添加,通常保存数据(而不是方法):

function Constr(...) {
    this.publicData = ...;
    ...
}

私有值

构造函数的环境由参数和局部变量组成。它们只能从构造函数内部访问,因此对实例是私有的:

function Constr(...) {
    ...
    var that = this; // make accessible to private functions

    var privateData = ...;

    function privateFunction(...) {
        // Access everything
        privateData = ...;

        that.publicData = ...;
        that.publicMethod(...);
    }
    ...
}

特权方法

私有数据非常安全,外部无法访问,因此原型方法无法访问它。但是,离开构造函数后,您如何使用它呢?答案是特权方法:在构造函数中创建的函数作为实例方法添加。这意味着,一方面,它们可以访问私有数据;另一方面,它们是公共的,因此原型方法可以看到它们。换句话说,它们充当私有数据和公共数据(包括原型方法)之间的中介:

function Constr(...) {
    ...
    this.privilegedMethod = function (...) {
        // Access everything
        privateData = ...;
        privateFunction(...);

        this.publicData = ...;
        this.publicMethod(...);
    };
}

示例

以下是使用 Crockford 隐私模式实现StringBuilder

function StringBuilder() {
    var buffer = [];
    this.add = function (str) {
        buffer.push(str);
    };
    this.toString = function () {
        return buffer.join('');
    };
}
// Can’t put methods in the prototype!

以下是交互过程

> var sb = new StringBuilder();
> sb.add('Hello');
> sb.add(' world!');
> sb.toString()
’Hello world!’

Crockford 隐私模式的优缺点

以下是使用Crockford 隐私模式时需要考虑的一些事项:

它不是很优雅
通过特权方法中介访问私有数据会引入不必要的间接性。特权方法和私有函数都破坏了构造函数(设置实例数据)和实例原型(方法)之间的关注点分离。
它完全安全
无法从外部访问环境的数据,这使得该解决方案在您需要时非常安全(例如,对于安全关键型代码)。另一方面,私有数据无法从外部访问也可能带来不便。有时您想对私有功能进行单元测试。并且一些临时的快速修复依赖于访问私有数据的能力。这种快速修复是无法预测的,因此无论您的设计有多好,都需要这样做。
它可能会更慢
在当前的 JavaScript 引擎中,访问原型链中的属性已经过高度优化。访问闭包中的值可能会更慢。但这些事情在不断变化,因此您必须衡量这是否真的对您的代码有影响。
它消耗更多内存
保留环境并将特权方法放入实例会消耗内存。同样,请确保它对您的代码确实很重要,并进行衡量。

具有标记键的属性中的私有数据

对于大多数非安全关键型应用程序,隐私更像是对 API 客户端的提示:“您不需要看到这些。”这是封装的关键好处 - 隐藏复杂性。即使幕后发生了更多事情,您也只需要了解 API 的公共部分。命名约定的想法是通过标记属性的键让客户端了解隐私。前缀下划线通常用于此目的。

让我们重写前面的 StringBuilder 示例,以便将缓冲区保存在属性 _buffer 中,该属性是私有的,但仅按照约定

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

以下是通过标记属性键实现隐私的一些优缺点

它提供了一种更自然的编码风格
能够以相同的方式访问私有数据和公共数据比使用环境来保护隐私更优雅。
它污染了属性的命名空间
带有标记键的属性随处可见。使用 IDE 的人越多,它们与公共属性一起显示,在应该隐藏的地方显示,就越令人讨厌。理论上,IDE 可以通过识别命名约定并在可能的情况下隐藏私有属性来适应这种情况。
可以从“外部”访问私有属性
这对于单元测试和快速修复很有用。此外,子构造函数和辅助函数(所谓的“友元函数”)可以从更轻松地访问私有数据中获益。环境方法不提供这种灵活性;只能从构造函数内部访问私有数据。
它可能导致键冲突
私有属性的键可能会冲突。这对于子构造函数来说已经是一个问题,但如果您使用多重继承(如某些库所启用),则问题更大。使用环境方法,永远不会发生冲突。

具有具体化键的属性中的私有数据

私有属性命名约定的一个问题是键可能会冲突(例如,构造函数中的键与子构造函数中的键,或 mixin 中的键与构造函数中的键)。您可以通过使用更长的键来降低此类冲突的可能性,例如,包括构造函数的名称。然后,在前面的例子中,私有属性 _buffer 将被称为 _StringBuilder_buffer。如果这样的键对您来说太长,您可以选择具体化它,将其存储在一个变量中:

var KEY_BUFFER = '_StringBuilder_buffer';

我们现在通过 this[KEY_BUFFER] 访问私有数据

var StringBuilder = function () {
    var KEY_BUFFER = '_StringBuilder_buffer';

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

我们在 StringBuilder 周围包装了一个 IIFE,以便常量 KEY_BUFFER 保持局部性并且不会污染全局命名空间。

具体化的属性键使您能够在键中使用 UUID(通用唯一标识符)。例如,通过 Robert Kieffer 的 node-uuid

var KEY_BUFFER = '_StringBuilder_buffer_' + uuid.v4();

KEY_BUFFER 每次代码运行时都有不同的值。例如,它可能看起来像这样

_StringBuilder_buffer_110ec58a-a0f2-4ac4-8393-c866d813b8d1

带有 UUID 的长键使键冲突几乎不可能发生。

通过 IIFE 保持全局数据私有

小节介绍如何通过 IIFE(参见通过 IIFE 引入新作用域)将全局数据保持为单例对象、构造函数和方法的私有数据。这些 IIFE 创建新的环境(请参阅环境:管理变量),您可以在其中放置私有数据。

将私有全局数据附加到单例对象

您不需要构造函数来将对象与环境中的私有数据相关联。以下示例显示了如何通过将 IIFE 包装在单例对象周围来将其用于相同目的:

var obj = function () {  // open IIFE

    // public
    var self = {
        publicMethod: function (...) {
            privateData = ...;
            privateFunction(...);
        },
        publicData: ...
    };

    // private
    var privateData = ...;
    function privateFunction(...) {
        privateData = ...;
        self.publicData = ...;
        self.publicMethod(...);
    }

    return self;
}(); // close IIFE

将全局数据保持为所有构造函数的私有数据

某些全局数据仅与构造函数和原型方法相关。通过将 IIFE 包装在两者周围,您可以将其隐藏在公共视图之外。具有具体化键的属性中的私有数据给出了一个示例:构造函数 StringBuilder 及其原型方法使用常量 KEY_BUFFER,其中包含一个属性键。该常量存储在 IIFE 的环境中

var StringBuilder = function () { // open IIFE
    var KEY_BUFFER = '_StringBuilder_buffer_' + uuid.v4();

    function StringBuilder() {
        this[KEY_BUFFER] = [];
    }
    StringBuilder.prototype = {
        // Omitted: methods accessing this[KEY_BUFFER]
    };
    return StringBuilder;
}(); // close IIFE

请注意,如果您正在使用模块系统(请参阅第 31 章),您可以通过将构造函数和方法放在模块中来以更简洁的代码实现相同的效果。

将全局数据附加到方法

有时您只需要单个方法的全局数据。您可以通过将其放在包装该方法的 IIFE 的环境中来保持其私有性。例如:

var obj = {
    method: function () {  // open IIFE

        // method-private data
        var invocCount = 0;

        return function () {
            invocCount++;
            console.log('Invocation #'+invocCount);
            return 'result';
        };
    }()  // close IIFE
};

以下是交互过程

> obj.method()
Invocation #1
'result'
> obj.method()
Invocation #2
'result'

第 4 层:构造函数之间的继承

在本节中,我们将研究如何继承构造函数:给定一个构造函数 Super,我们如何编写一个新的构造函数 Sub,它具有 Super 的所有功能以及它自己的一些功能?不幸的是,JavaScript 没有用于执行此任务的内置机制。因此,我们必须做一些手动工作。

图 17-5 说明了这个想法:子构造函数 Sub 应该拥有 Super 的所有属性(原型属性和实例属性)以及它自己的属性。因此,我们对 Sub 的外观有了一个粗略的了解,但不知道如何实现。我们需要弄清楚几件事,我将在接下来解释

  • 继承实例属性。
  • 继承原型属性。
  • 确保 instanceof 工作:如果 subSub 的实例,我们也希望 sub instanceof Super 为真。
  • 覆盖方法以在 Sub 中调整 Super 的方法之一。
  • 进行超级调用:如果我们覆盖了 Super 的方法之一,我们可能需要从 Sub 中调用原始方法。

继承实例属性

实例属性在构造函数本身中设置,因此继承超类构造函数的实例属性涉及调用该构造函数:

function Sub(prop1, prop2, prop3, prop4) {
    Super.call(this, prop1, prop2);  // (1)
    this.prop3 = prop3;  // (2)
    this.prop4 = prop4;  // (3)
}

当通过 new 调用 Sub 时,其隐式参数 this 指的是一个新的实例。它首先将该实例传递给 Super (1),后者添加其实例属性。之后,Sub 设置它自己的实例属性(2,3)。诀窍是不要通过 new 调用 Super,因为这会创建一个新的超类实例。相反,我们将 Super 作为函数调用,并将当前(子类)实例作为 this 的值传入。

继承原型属性

共享属性(例如方法)保存在实例原型中。因此,我们需要找到一种方法让 Sub.prototype 继承 Super.prototype 的所有属性。解决方案是为 Sub.prototype 提供原型 Super.prototype

对这两种原型感到困惑?

是的,JavaScript 术语在这里令人困惑。如果您感到困惑,请参阅术语:两种原型,其中解释了它们的区别。

这是实现这一点的代码

Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.prototype.methodB = ...;
Sub.prototype.methodC = ...;

Object.create() 生成一个新对象,其原型为 Super.prototype。之后,我们添加 Sub 的方法。如实例的 constructor 属性中所述,我们还需要设置属性 constructor,因为我们已经替换了原始实例原型,其中包含正确的值。

图 17-6 显示了 SubSuper 现在是如何相关的。 Sub 的结构确实类似于我在图 17-5中绘制的内容。该图未显示实例属性,这些属性由图中提到的函数调用设置。

确保 Instanceof 工作

“确保 instanceof 工作”意味着 Sub 的每个实例也必须是 Super 的实例。 图 17-7 显示了 subInstanceSub 的实例)的原型链是什么样的:它的第一个原型是 Sub.prototype,它的第二个原型是 Super.prototype

让我们从一个更简单的问题开始:subInstanceSub 的实例吗?是的,因为以下两个断言是等效的(后者可以被认为是前者的定义)

subInstance instanceof Sub
Sub.prototype.isPrototypeOf(subInstance)

如前所述,Sub.prototypesubInstance 的原型之一,因此两个断言都为真。类似地,subInstance 也是 Super 的实例,因为以下两个断言成立

subInstance instanceof Super
Super.prototype.isPrototypeOf(subInstance)

覆盖方法

我们通过Sub.prototype 添加一个同名方法来覆盖 Super.prototype 中的方法。 methodB 就是一个例子,在图 17-7 中,我们可以看到它是如何工作的:对 methodB 的搜索从 subInstance 开始,并在找到 Super.prototype.methodB 之前找到了 Sub.prototype.methodB

进行超级调用

要理解超级调用,您需要知道术语宿主对象方法的宿主对象是拥有该方法作为其属性值的对象。例如,Sub.prototype.methodB 的宿主对象是 Sub.prototype。超级调用方法 foo 涉及三个步骤:

  1. 在当前方法的宿主对象“之后”(在其原型中)开始搜索。
  2. 查找名称为 foo 的方法。
  3. 使用当前的 this 调用该方法。基本原理是超级方法必须与当前方法一起使用相同的实例;它必须能够访问相同的实例属性。

因此,子方法的代码如下所示。它超级调用自身,它调用它覆盖的方法

Sub.prototype.methodB = function (x, y) {
    var superResult = Super.prototype.methodB.call(this, x, y); // (1)
    return this.prop3 + ' ' + superResult;
}

读取 (1) 处的超级调用的一种方法如下:直接引用超级方法并使用当前的 this 调用它。但是,如果我们将其分为三个部分,我们会发现前面提到的步骤

  1. Super.prototype:在 Super.prototype 中开始搜索,它是 Sub.prototype(当前方法 Sub.prototype.methodB 的宿主对象)的原型。
  2. methodB:查找名称为 methodB 的方法。
  3. call(this, ...):调用上一步中找到的方法,并维护当前的 this

避免对超类构造函数的名称进行硬编码

到目前为止,我们始终 通过提及超构造函数名称来引用超方法和超构造函数。这种硬编码会降低代码的灵活性。您可以通过将超原型分配给 Sub 的属性来避免这种情况:

Sub._super = Super.prototype;

然后调用超构造函数和超方法如下所示

function Sub(prop1, prop2, prop3, prop4) {
    Sub._super.constructor.call(this, prop1, prop2);
    this.prop3 = prop3;
    this.prop4 = prop4;
}
Sub.prototype.methodB = function (x, y) {
    var superResult = Sub._super.methodB.call(this, x, y);
    return this.prop3 + ' ' + superResult;
}

设置 Sub._super 通常由一个实用函数来处理,该函数还将子原型连接到超原型。例如

function subclasses(SubC, SuperC) {
    var subProto = Object.create(SuperC.prototype);
    // Save `constructor` and, possibly, other methods
    copyOwnPropertiesFrom(subProto, SubC.prototype);
    SubC.prototype = subProto;
    SubC._super = SuperC.prototype;
};

此代码使用辅助函数 copyOwnPropertiesFrom(),该函数在 复制对象 中显示和解释。

提示

将“子类”读作动词:SubC 子类化 SuperC。这样的实用函数可以减轻创建子构造函数的痛苦:需要手动执行的操作更少,并且永远不会重复提及超构造函数的名称。以下示例演示了它如何简化代码。

示例:构造函数继承的使用

作为一个具体示例,假设 构造函数 Person 已经存在:

function Person(name) {
    this.name = name;
}
Person.prototype.describe = function () {
    return 'Person called '+this.name;
};

我们现在想创建构造函数 Employee 作为 Person 的子构造函数。我们手动执行此操作,如下所示

function Employee(name, title) {
    Person.call(this, name);
    this.title = title;
}
Employee.prototype = Object.create(Person.prototype);
Employee.prototype.constructor = Employee;
Employee.prototype.describe = function () {
    return Person.prototype.describe.call(this)+' ('+this.title+')';
};

以下是交互过程

> var jane = new Employee('Jane', 'CTO');
> jane.describe()
Person called Jane (CTO)
> jane instanceof Employee
true
> jane instanceof Person
true

上一节中的实用函数 subclasses() 使 Employee 的代码稍微简单一些,并避免了对超构造函数 Person 进行硬编码

function Employee(name, title) {
    Employee._super.constructor.call(this, name);
    this.title = title;
}
Employee.prototype.describe = function () {
    return Employee._super.describe.call(this)+' ('+this.title+')';
};
subclasses(Employee, Person);

示例:内置构造函数的继承层次结构

内置构造函数使用本节中描述的相同子类化方法。 例如,ArrayObject 的子构造函数。因此,Array 实例的原型链如下所示:

> var p = Object.getPrototypeOf

> p([]) === Array.prototype
true
> p(p([])) === Object.prototype
true
> p(p(p([]))) === null
true

反模式:原型是超构造函数的实例

在 ECMAScript 5 和 Object.create() 之前,一种常用的解决方案是通过调用超构造函数来创建子原型:

Sub.prototype = new Super();  // Don’t do this

在 ECMAScript 5 下不建议这样做。原型将具有 Super 的所有实例属性,而这些属性对它没有用处。因此,最好使用前面提到的模式(涉及 Object.create())。

所有对象的方法

几乎所有对象在其 原型链中都有 Object.prototype

> Object.prototype.isPrototypeOf({})
true
> Object.prototype.isPrototypeOf([])
true
> Object.prototype.isPrototypeOf(/xyz/)
true

以下小节描述了 Object.prototype 为其原型提供的 方法。

转换为原始值

以下两种 方法用于将对象转换为原始值:

Object.prototype.toString()

返回对象的字符串表示形式

> ({ first: 'John', last: 'Doe' }.toString())
'[object Object]'
> [ 'a', 'b', 'c' ].toString()
'a,b,c'
Object.prototype.valueOf()

这是将对象转换为数字的首选方法。默认实现返回 this

> var obj = {};
> obj.valueOf() === obj
true

valueOf 被包装器构造函数覆盖以返回包装的原始值

> new Number(7).valueOf()
7

转换为数字和字符串(无论是隐式还是显式)都建立在转换为原始值的基础上(有关详细信息,请参阅 算法:ToPrimitive() - 将值转换为原始值)。这就是为什么您可以使用前面提到的两种方法来配置这些转换。转换为数字时首选 valueOf()

> 3 * { valueOf: function () { return 5 } }
15

转换为字符串时首选 toString()

> String({ toString: function () { return 'ME' } })
'Result: ME'

转换为布尔值是不可配置的;对象始终被认为是 true(请参阅 转换为布尔值)。

原型继承和属性

以下方法有助于原型继承 和属性:

Object.prototype.isPrototypeOf(obj)

如果接收器是 obj 的原型链的一部分,则返回 true

> var proto = { };
> var obj = Object.create(proto);
> proto.isPrototypeOf(obj)
true
> obj.isPrototypeOf(obj)
false
Object.prototype.hasOwnProperty(key)

如果 this 拥有 其键为 key 的属性,则返回 true。“拥有”表示该属性存在于对象本身中,而不是存在于其原型之一中。

警告

您通常应该以通用方式(而不是直接)调用此方法,尤其是在您静态不知道其属性的对象上。原因和方式在 属性的迭代和检测 中进行了解释

> var proto = { foo: 'abc' };
> var obj = Object.create(proto);
> obj.bar = 'def';

> Object.prototype.hasOwnProperty.call(obj, 'foo')
false
> Object.prototype.hasOwnProperty.call(obj, 'bar')
true
Object.prototype.propertyIsEnumerable(propKey)

如果接收器具有键为 propKey 的属性,则返回 true,该属性 是可枚举的,否则返回 false

> var obj = { foo: 'abc' };
> obj.propertyIsEnumerable('foo')
true
> obj.propertyIsEnumerable('toString')
false
> obj.propertyIsEnumerable('unknown')
false

泛型方法:从原型中借用方法

有时,实例原型 具有对更多对象有用的方法,而不仅仅是从它们继承的对象。本节介绍如何在不继承原型的情况下使用原型的方法。例如,实例原型 Wine.prototype 具有方法 incAge()

function Wine(age) {
    this.age = age;
}
Wine.prototype.incAge = function (years) {
    this.age += years;
}

交互如下

> var chablis = new Wine(3);
> chablis.incAge(1);
> chablis.age
4

方法 incAge() 适用于任何具有属性 age 的对象。我们如何在不是 Wine 实例的对象上调用它?让我们看一下前面的方法调用

chablis.incAge(1)

实际上有两个参数

  1. chablis 是方法调用的接收器,通过 this 传递给 incAge
  2. 1 是一个参数,通过 years 传递给 incAge

我们不能用任意对象替换前者 - 接收器必须是 Wine 的实例。否则,将找不到方法 incAge。但是,前面的方法调用等效于(请参阅 在设置 this 时调用函数:call()、apply() 和 bind()

Wine.prototype.incAge.call(chablis, 1)

使用前面的模式,我们可以使一个对象成为接收器(call 的第一个参数),它不是 Wine 的实例,因为接收器不用于查找方法 Wine.prototype.incAge。在以下示例中,我们将方法 incAge() 应用于对象 john

> var john = { age: 51 };
> Wine.prototype.incAge.call(john, 3)
> john.age
54

可以以这种方式使用的函数称为 泛型方法;它必须准备好 this 不是“其”构造函数的实例。因此,并非所有方法都是泛型的;ECMAScript 语言规范明确规定了哪些方法是泛型的(请参阅 所有泛型方法的列表)。

通过字面量访问 Object.prototype 和 Array.prototype

调用方法 非常冗长:

Object.prototype.hasOwnProperty.call(obj, 'propKey')

您可以通过由空对象字面量 {} 创建的 Object 实例访问 hasOwnProperty 来缩短此过程

{}.hasOwnProperty.call(obj, 'propKey')

同样,以下两个表达式是等效的

Array.prototype.join.call(str, '-')
[].join.call(str, '-')

这种模式的优点是不那么冗长。但它也不那么容易理解。性能应该不是问题(至少从长远来看),因为引擎可以静态确定字面量不应该创建对象。

调用泛型方法的示例

以下是一些 正在使用的泛型方法的示例:

  • 使用 apply()(请参阅 Function.prototype.apply(thisValue, argArray))推送数组(而不是单个元素;请参阅 添加和删除元素(破坏性)

    > var arr1 = [ 'a', 'b' ];
    > var arr2 = [ 'c', 'd' ];
    
    > [].push.apply(arr1, arr2)
    4
    > arr1
    [ 'a', 'b', 'c', 'd' ]

    此示例是关于将数组转换为参数,而不是关于从另一个构造函数中借用方法。

  • 将数组方法 join() 应用于字符串(它不是数组)

    > Array.prototype.join.call('abc', '-')
    'a-b-c'
  • 将数组方法 map() 应用于字符串:[17]

    > [].map.call('abc', function (x) { return x.toUpperCase() })
    [ 'A', 'B', 'C' ]

    以泛型方式使用 map() 比使用 split('') 更有效,后者会创建一个中间数组

    > 'abc'.split('').map(function (x) { return x.toUpperCase() })
    [ 'A', 'B', 'C' ]
  • 将字符串方法应用于非字符串。toUpperCase() 将接收器转换为字符串并对结果进行大写

    > String.prototype.toUpperCase.call(true)
    'TRUE'
    > String.prototype.toUpperCase.call(['a','b','c'])
    'A,B,C'

在普通对象上使用泛型数组方法可以让您深入了解它们的工作原理

  • 在伪数组上调用数组方法

    > var fakeArray = { 0: 'a', 1: 'b', length: 2 };
    > Array.prototype.join.call(fakeArray, '-')
    'a-b'
  • 查看数组方法如何转换它视为数组的对象

    > var obj = {};
    > Array.prototype.push.call(obj, 'hello');
    1
    > obj
    { '0': 'hello', length: 1 }

类数组对象和泛型方法

JavaScript 中有一些对象感觉像数组,但实际上不是。 这意味着,虽然它们具有索引访问权限和 length 属性,但它们没有任何数组方法(forEach()pushconcat() 等)。这很不幸,但正如我们将看到的,泛型数组方法提供了一种解决方法。类数组对象的示例包括:

  • 特殊变量 arguments(请参阅 按索引获取所有参数:特殊变量 arguments),它是一个重要的类数组对象,因为它是 JavaScript 的基本组成部分。arguments 看起来像一个数组

    > function args() { return arguments }
    > var arrayLike = args('a', 'b');
    
    > arrayLike[0]
    'a'
    > arrayLike.length
    2

    但是没有可用的数组方法

    > arrayLike.join('-')
    TypeError: object has no method 'join'

    这是因为 arrayLike 不是 Array 的实例(并且 Array.prototype 不在原型链中)

    > arrayLike instanceof Array
    false
  • 浏览器 DOM 节点列表,由 document.getElementsBy*()(例如,getElementsByTagName())、document.forms 等返回

    > var elts = document.getElementsByTagName('h3');
    > elts.length
    3
    > elts instanceof Array
    false
  • 字符串,也是类数组的

    > 'abc'[1]
    'b'
    > 'abc'.length
    3

术语 类数组 也可以看作是泛型数组方法和对象之间的契约。对象必须满足某些要求;否则,这些方法将无法在它们上工作。要求是

  • 类数组对象的元素必须可以通过方括号和从 0 开始的整数索引进行访问。所有方法都需要读取访问权限,而某些方法还需要写入访问权限。请注意,所有对象都支持这种索引:方括号中的索引将转换为字符串,并用作查找属性值的键

    > var obj = { '0': 'abc' };
    > obj[0]
    'abc'
  • 类数组对象必须具有一个 length 属性,其值是其元素的数量。某些方法要求 length 是可变的(例如,reverse())。长度不可变的值(例如,字符串)不能与这些方法一起使用。

使用类数组对象的模式

以下模式 对于使用类数组对象很有用:

  • 将类数组对象转换为数组

    var arr = Array.prototype.slice.call(arguments);

    不带任何参数的方法 slice()(请参阅 连接、切片、连接(非破坏性))会创建类数组接收器的副本

    var copy = [ 'a', 'b' ].slice();
  • 要迭代类数组对象的所有元素,可以使用简单的 for 循环

    function logArgs() {
        for (var i=0; i<arguments.length; i++) {
            console.log(i+'. '+arguments[i]);
        }
    }

    但您也可以借用 Array.prototype.forEach()

    function logArgs() {
        Array.prototype.forEach.call(arguments, function (elem, i) {
            console.log(i+'. '+elem);
        });
    }

    在这两种情况下,交互如下所示

    > logArgs('hello', 'world');
    0. hello
    1. world

所有泛型方法的列表

以下 列表包含 ECMAScript 语言规范中提到的所有泛型方法:

  • Array.prototype(参见 数组原型方法

    • concat
    • every
    • filter
    • forEach
    • indexOf
    • join
    • lastIndexOf
    • map
    • pop
    • push
    • reduce
    • reduceRight
    • reverse
    • shift
    • slice
    • some
    • sort
    • splice
    • toLocaleString
    • toString
    • unshift
  • Date.prototype(参见 日期原型方法

    • toJSON
  • Object.prototype(参见 所有对象的的方法

    • (所有 Object 方法自动是泛型的——它们必须适用于所有对象。)
  • String.prototype(参见 字符串原型方法

    • charAt
    • charCodeAt
    • concat
    • indexOf
    • lastIndexOf
    • localeCompare
    • match
    • replace
    • search
    • slice
    • split
    • substring
    • toLocaleLowerCase
    • toLocaleUpperCase
    • toLowerCase
    • toUpperCase
    • trim

陷阱:将对象用作映射

由于 JavaScript 没有用于映射的内置数据结构,因此对象通常用作从字符串到值的映射。唉,这比看起来更容易出错。本节解释了此任务中涉及的三个陷阱。

陷阱 1:继承会影响读取属性

读取属性的操作可以 分为两种:

  • 一些操作会考虑整个原型链并查看继承的属性。
  • 其他操作仅访问对象的 自有(非继承)属性。

读取对象作为映射的条目时,需要在这些操作之间仔细选择。要了解原因,请考虑以下示例

var proto = { protoProp: 'a' };
var obj = Object.create(proto);
obj.ownProp = 'b';

obj 是一个对象,它有一个自有属性,其原型是 proto,它也有一个自有属性。 proto 的原型是 Object.prototype,就像所有由对象字面量创建的对象一样。因此,objprotoObject.prototype 继承属性。

我们希望将 obj 解释为具有单个条目的映射

ownProp: 'b'

也就是说,我们希望忽略继承的属性,只考虑自有属性。让我们看看哪些读取操作以这种方式解释 obj,哪些不解释。请注意,对于作为映射的对象,我们通常希望使用存储在变量中的任意属性键。这排除了点符号。

检查属性是否存在

in 运算符检查对象 是否具有给定键的属性,但它会考虑继承的属性:

> 'ownProp' in obj  // ok
true
> 'unknown' in obj  // ok
false
> 'toString' in obj  // wrong, inherited from Object.prototype
true
> 'protoProp' in obj  // wrong, inherited from proto
true

我们需要检查以忽略继承的属性。 hasOwnProperty() 可以满足我们的需求

> obj.hasOwnProperty('ownProp')  // ok
true
> obj.hasOwnProperty('unknown')  // ok
false
> obj.hasOwnProperty('toString')  // ok
false
> obj.hasOwnProperty('protoProp')  // ok
false

收集属性键

在遵守我们将 obj 解释为映射的同时,我们可以使用哪些操作来查找 obj 的所有键? for-in 看起来可能有效。但是,唉,它不起作用:

> for (propKey in obj) console.log(propKey)
ownProp
protoProp

它考虑了继承的可枚举属性。这里没有显示 Object.prototype 的属性的原因是它们都是不可枚举的。

相比之下,Object.keys() 仅列出自有属性

> Object.keys(obj)
[ 'ownProp' ]

此方法仅返回可枚举的自有属性;ownProp 是通过赋值添加的,因此默认情况下是可枚举的。如果要列出所有自有属性,则需要使用 Object.getOwnPropertyNames()

获取属性值

对于读取属性的值,我们只能在点运算符和括号运算符之间进行选择。我们不能使用前者,因为我们有存储在变量中的任意键。这给我们留下了括号运算符,它会考虑继承的属性:

> obj['toString']
[Function: toString]

这不是我们想要的。没有用于仅读取自有属性的内置操作,但您可以轻松地自己实现一个

function getOwnProperty(obj, propKey) {
    // Using hasOwnProperty() in this manner is problematic
    // (explained and fixed later)
    return (obj.hasOwnProperty(propKey)
            ? obj[propKey] : undefined);
}

使用该函数,将忽略继承的属性 toString

> getOwnProperty(obj, 'toString')
undefined

陷阱 2:覆盖会影响调用方法

函数 getOwnProperty() 调用了 obj 上的方法 hasOwnProperty()。通常,这很好

> getOwnProperty({ foo: 123 }, 'foo')
123

但是,如果向 obj 添加一个键为 hasOwnProperty 的属性,则该属性将覆盖方法 Object.prototype.hasOwnProperty(),并且 getOwnProperty() 将停止工作

> getOwnProperty({ hasOwnProperty: 123 }, 'foo')
TypeError: Property 'hasOwnProperty' is not a function

您可以通过直接引用 hasOwnProperty() 来解决此问题。这避免了通过 obj 来找到它

function getOwnProperty(obj, propKey) {
    return (Object.prototype.hasOwnProperty.call(obj, propKey)
            ? obj[propKey] : undefined);
}

我们已经以泛型方式调用了 hasOwnProperty()(参见 泛型方法:从原型借用方法)。

陷阱 3:特殊属性 __proto__

在许多 JavaScript 引擎中,属性 __proto__(参见 特殊属性 __proto__)很特殊:获取它会检索对象的原型,而设置它会更改对象的原型。 这就是对象无法在键为 '__proto__' 的属性中存储映射数据的原因。如果要允许映射键 '__proto__',则必须在将其用作属性键之前对其进行转义:

function get(obj, key) {
    return obj[escapeKey(key)];
}
function set(obj, key, value) {
    obj[escapeKey(key)] = value;
}
// Similar: checking if key exists, deleting an entry

function escapeKey(key) {
    if (key.indexOf('__proto__') === 0) {  // (1)
        return key+'%';
    } else {
        return key;
    }
}

我们还需要转义 '__proto__'(等)的转义版本以避免冲突;也就是说,如果我们将键 '__proto__' 转义为 '__proto__%',那么我们还需要转义键 '__proto__%',这样它就不会替换 '__proto__' 条目。这就是第 (1) 行发生的事情。

Mark S. Miller 在 一封电子邮件 中提到了这个陷阱的现实意义

认为这个练习是学术性的,不会出现在真实的系统中?正如在一个支持线程中观察到的那样,直到最近,在所有非 IE 浏览器上,如果您在新 Google 文档的开头键入“__proto__”,您的 Google 文档就会挂起。这被追溯到将对象错误地用作字符串映射。

dict 模式:没有原型的对象是更好的映射

您可以创建一个没有原型的对象,如下所示:

var dict = Object.create(null);

这样的对象比普通对象是更好的映射(字典),这就是为什么这种模式有时被称为 dict 模式dict 代表 dictionary,即字典)的原因。让我们首先检查普通对象,然后找出为什么无原型对象是更好的映射。

普通对象

通常,您在 JavaScript 中创建的每个对象在其原型链中至少都有 Object.prototypeObject.prototype 的原型是 null,所以大多数原型链都在这里结束

> Object.getPrototypeOf({}) === Object.prototype
true
> Object.getPrototypeOf(Object.prototype)
null

无原型对象

无原型对象作为映射有两个优点

唯一的缺点是您将失去 Object.prototype 提供的服务。例如,dict 对象不能再自动转换为字符串

> console.log('Result: '+obj)
TypeError: Cannot convert object to primitive value

但这并不是一个真正的缺点,因为直接在 dict 对象上调用方法是不安全的。

建议

将 dict 模式用于快速破解和作为库的基础。在(非库)生产代码中,库更可取,因为您可以确保避免所有陷阱。下一节列出了一些这样的库。

备忘单:使用对象

本节是 快速参考,其中包含指向更全面解释的指针。



[17] 以这种方式使用 map() 是 Brandon Benvie (@benvie) 的建议。

下一页:18. 数组