[译]JavaScript中的属性:定义和赋值的区别

原文:http://www.2ality.com/2012/08/property-definition-assignment.html


你知道吗?定义一个属性和为一个属性赋值是有区别的.本文解释了两者之间的区别以及各自的作用影响.该话题来自于Allen Wirfs-Brock在es-discuss邮件列表中的一封邮件.

1. 定义VS赋值

定义(Definition).定义属性需要使用相应的函数,比如:

Object.defineProperty(obj, "prop", propDesc)

如果obj没有prop这个自身属性,则该函数的作用是给obj添加一个自身属性prop并赋值,参数propDesc指定了该属性拥有的特性(可写性,可枚举性等).如果obj已经有了prop这个自身属性,则该函数的作用是修改这个已有属性的特性,当然也包括它的属性值.

赋值(assignment).为一个属性赋值需要使用下面的表达式:

obj.prop = value

如果obj已经有了prop这个自身属性,则该表达式的作用就是修改这个prop属性的值,反之,如果obj没有prop这个自身属性,则该表达式的操作结果就不一定了:首先会查找对象obj的原型链[1],如果原型链中的所有对象都没有名为prop的属性,则结果是在obj身上新建一个自身属性prop,新创建的属性拥有默认的属性特性,且把指定的value赋值给该属性.但如果obj的某个上层原型中上有一个名为prop的属性,那接下来的操作就复杂了,具体请看下面的3.2小节.

2. 属性特性和内部属性

2.1. 多种类型的属性

JavaScript中有三种类型的属性:

  • 命名数据属性(named data properties): 拥有一个确定的值的属性.这也是最常用的属性.
  • 命名访问器属性(named accessor properties): 通过getter和setter进行读取和赋值的属性.
  • 内部属性(internal properties): 由JavaScript引擎内部使用的属性,不能通过JavaScript代码直接访问到,不过可以通过一些方法间接的读取和设置.比如:每个对象都有一个内部属性[[Prototype]],你不能直接访问这个属性,但可以通过Object.getPrototypeOf()方法间接的读取到它的值.虽然内部属性通常用一个双中括号包围的名称来表示,但实际上这并不是它们的名字,它们是一种抽象操作,是不可见的,根本没有上面两种属性有的那种字符串类型的属性名.

2.2. 属性特性

每个属性(property)都拥有4个特性(attribute).两种类型的属性一种有6种属性特性:

  • 命名数据属性特有的特性:
    • [[Value]]: 属性的值.
    • [[Writable]]: 控制属性的值是否可以改变.
  • 命名访问器属性特有的特性:
    • [[Get]]: 存储着getter方法.
    • [[Set]]: 存储着setter方法.
  • 两种属性都有的特性:
    • [[Enumerable]]: 如果一个属性是不可枚举的,则在一些操作下,这个属性是不可见的,比如for...in和Object.keys()[2].
    • [[Configurable]]: 如果一个属性是不可配置的,则该属性的所有特性(除了[[Value]])都不可改变.

2.3. 属性描述符

属性描述符(property descriptor)可以将一个属性的所有特性编码成一个对象并返回.例如:

{
value:
123,
writable:
false }

属性描述符使用在下面的这些函数中:Object.defineProperty, Object.getOwnPropertyDescriptor, Object.create.如果省略了属性描述符对象中的某个属性,则该属性会取一个默认值:

属性名 默认值
value undefined
get undefined
set undefined
writable false
enumerable false
configurable false

2.4. 内部属性

下面列举几个所有对象都有的内部属性:

  • [[Prototype]]: 对象的原型.
  • [[Extensible]]: 对象是否可以扩展,也就是是否可以添加新的属性.
  • [[DefineOwnProperty]]: 定义一个属性的内部方法.下一节会详细解释.
  • [[Put]]: 为一个属性赋值的内部方法.下一节会详细解释.

3. 属性定义和属性赋值

3.1. 属性定义

定义属性是通过内部方法来进行操作的:

[[DefineOwnProperty]] (P, Desc, Throw)

P是要定义的属性名称.参数Throw决定了在定义操作被拒绝的时候是否要抛出异常:如果Throw为true,则抛出异常.否则,操作只会静默失败.当调用[[DefineOwnProperty]]时,具体会执行下面的操作步骤.

  • 如果this没有名为P的自身属性的话:如果this是可扩展的话,则创建P这个自身属性,否则拒绝.
  • 如果this已经有了名为P的自身属性:则按照下面的步骤重新配置这个属性.
  • 如果这个已有的属性是不可配置的,则进行下面的操作会被拒绝:
    • 将一个数据属性转换成访问器属性,反之亦然
    • 改变[[Configurable]]或[[Enumerable]]
    • 该变[[Writable]]
    • 在[[Writable]]为false时改变[[Value]]
    • 改变[[Get]]或[[Set]]
  • 否则,这个已有的属性可以被重新配置.

如果Desc就是P属性当前的属性描述符,则该定义操作永远不会被拒绝.

定义属性的函数有两个:Object.defineProperty和Object.defineProperties.例如:

Object.defineProperty(obj, propName, desc)

在引擎内部,会转换成这样的方法调用:

obj.[[DefineOwnProperty]](propName, desc, true)

3.2. 属性赋值

为属性赋值是通过内部方法进行操作的:

[[Put]] (P, Value, Throw)

参数P以及Throw和[[DefineOwnProperty]]方法中的参数表现的一样.在调用[[Put]]方法的时候,会执行下面这样的操作步骤.

  • 如果在原型链上存在一个名为P的只读属性(只读的数据属性或者没有setter的访问器属性),则拒绝.
  • 如果在原型链上存在一个名为P的且拥有setter的访问器属性:则调用这个setter.
  • 如果没有名为P的自身属性:则如果这个对象是可扩展的,就使用下面的操作创建一个新属性:
    this.[[DefineOwnProperty]](
    P,
    {
    value: Value,
    writable:
    true,
    enumerable:
    true,
    configurable:
    true }, Throw )
    否则,如果这个对象是不可扩展的,则拒绝.
  • 否则, 如果已经存在一个可写的名为P的自身属性.则调用:
    this.[[DefineOwnProperty]](P, { value: Value }, Throw)
    该操作只会更改P属性的值,其他的特性(比如可枚举性)都不会改变.

赋值运算符(=)就是在调用[[Put]].比如:

obj.prop = v;

在引擎内部,会转换成这样的方法调用:

obj.[[Put]]("prop", v, isStrictModeOn)

isStrictModeOn对应着参数Throw.也就是说,赋值运算符只有在严格模式下才有可能抛出异常.[[Put]]没有返回值,但赋值运算符有.

4. 作用及影响

本节讲一下属性的定义操作和赋值操作各自的作用及影响.

4.1. 赋值可能会调用原型上的setter,定义会创建一个自身属性

给定一个空对象obj,他的原型proto有一个名为foo的访问器属性.

var proto = {
get foo() {
console.log(
"Getter");
return "a";
},
set foo(x) {
console.log(
"Setter: "+x);
},
};
var obj = Object.create(proto);

那么,"在obj身上定义一个foo属性"和"为obj的foo属性赋值"有什么区别呢?

如果是定义操作的话,则会在obj身上添加一个自身属性foo:

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

但如果为foo属性赋值的话,则意味着你是想改变某个已经存在的属性的值.所以这次赋值操作会转交给原型proto的foo属性的setter访问器来处理,下面代码的执行结果就能证明这一结论:

> obj.foo = "b";
Setter: b
'b'

你还可以定义一个只读的访问器属性,办法是:只定义一个getter,省略setter.下面的例子中,proto2的bar属性就是这样的只读属性,obj2继承了这个属性.

"use strict";
var proto2 = {
get bar() {
console.log(
"Getter");
return "a";
},
};
var obj2 = Object.create(proto2);

开启严格模式的话,下面的赋值操作会抛出异常.非严格模式的话,赋值操作只会静默失败(不起任何作用,也不报错).

> obj2.bar = "b";
TypeError: obj.bar is read
-only

我们可以定义一个自身属性bar,遮蔽从原型上继承的bar属性:

> Object.defineProperty(obj2, "bar", { value: "b" });
> obj2.bar
'b'
> proto2.bar
Getter
'a'

4.2. 原型链中的同名只读属性可能会阻止赋值操作,但不会阻止定义操作

如果原型链中存在一个同名的只读属性,则无法通过赋值的方式在原对象上添加这个自身属性,必须使用定义操作才可以.这项限制是在ECMAScript 5.1中引入的:

"use strict";
var proto = Object.defineProperties(
{},
{
foo: {
// foo属性的特性: value: "a",
writable:
false, // 只读 configurable: true // 可配置 }
});
var obj = Object.create(proto);

赋值.赋值操作会导致异常:

> obj.foo = "b";                 //译者注:貌似只有Firefox遵循了这个标准
TypeError: obj.foo is read-only

这貌似是个很奇怪的表现,原型上的属性居然可以影响到能否创建一个同名的自身属性 [3].但是这样的表现是有道理的,因为另外一种形式的只读属性(只有getter的访问器属性)也是这样的表现,这样才能统一.

定义.通过定义的方式,我们可以成功创建一个新的自身属性:

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

4.3.  赋值运算符不会改变原型链上的属性

执行下面的代码,则obj会从proto上继承到foo属性.

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

你不能通过为obj.foo赋值来改变proto.foo的值.这种操作只会在obj上新建一个自身属性:

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

之所以会这样设计,是因为:原型身上的属性可以被继承该原型的所有后代所共享.如果想改变其中一个后代的某个继承属性,则会创建一个属于这个后代的自身属性.这就意味着:你可以做一些修改,但仅仅是某一个对象上的修改,不会影响其他的后代.从这个角度看,只读属性(只读的数据属性和只有getter的访问器属性)的这种表现就能说的通了: 阻止修改,通过阻止创建自身属性. 重写原型属性而不是修改它们的动机是什么呢?

  1. 方法: 允许直接在原型身上修改原型的方法,但防止通过原型的后代上的意外操作而修改原型上的方法.
  2. 非方法的属性: 原型可以提供给它的后代一些默认存在的属性.通过操作某个后代,你只能遮蔽这些继承的属性,但无法真正的修改它们.这被认为是一种反面模式(anti-pattern),是不推荐的做法.更好的做法是把默认值赋给构造函数.

4.4. 只有通过定义操作,才能创建一个拥有指定特性的属性

如果通过赋值操作创建了一个自身属性,则该属性始终拥有默认的特性.如果你想指定某个特性的值,必须通过定义操作.

4.5. 对象字面量中的属性是通过定义操作添加的

下面的代码将变量obj指向了一个对象字面量:

var obj = {
foo:
123 };

这样的语句在引擎内部,可能会被转换成下面两种操作方式中的一种.首先可能是赋值操作:

var obj = new Object();
obj.foo
= 123;

其次,可能是个定义操作:

var obj = new Object();
Object.defineProperties(obj, {
foo: {
value:
123,
enumerable:
true,
configurable:
true,
writable:
true } });

到底是哪种呢?正确答案是第二种,因为第二种操作方式能够更好的表达出对象字面量的语义:创建新的属性.Object.create接受一个属性描述符作为第二个可选参数,也是这个原因.

4.6. 方法属性

可以通过定义操作新建一个只读的方法属性:

"use strict";
function Stack() {
}
Object.defineProperties(Stack.prototype, {
push: {
writable:
false,
configurable:
true,
value:
function (x) { /* ... */ }
}
});

目的是为了防止在实例身上发生意外的赋值操作:

> var s = new Stack();

> s.push = 5;
TypeError: s.push is read
-only

不过,由于push是可配置的,所以我们仍可以通过定义操作来为实例添加一个自身的push方法.

> var s = new Stack();
> Object.defineProperty(s, "push",
{ value:
function () { return "yes" }})

> s.push()
'yes'

我们甚至可以通过定义操作来重新定义原型上的push方法:Stack.prototype.push.

5. 结论

我们经常会通过为属性赋值来给一个对象添加新的属性.本文解释了这样做是可能引发一些问题的.因此,最好要遵循下面的规则:

  1. 如果你想创建一个新属性,则使用属性定义.
  2. 如果你想该变一个属性的值,则使用属性赋值.

在评论中,medikoo提醒了我们使用属性描述符来创建属性可能会有点慢.我就经常通过为属性赋值来创建新属性,因为这样很方便.值得高兴的是,ECMAScript.next也许会把属性的定义操作变的既快又方便:已经存在一个“定义属性的运算符”的提案,可以作为Object.defineProperties的替代用法.由于属性定义和属性赋值之间的差别既很微妙,又很重要,所以这种改进应该会很受欢迎.

6. 参考

  1. Prototypes as classes – an introduction to JavaScript inheritance
  2. JavaScript properties: inheritance and enumerability
  3. Fixing the Read-only Override Prohibition Mistake [ECMAScript wiki]
posted @ 2012-10-31 22:42  紫云飞  阅读(10984)  评论(4编辑  收藏  举报