[譯]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]


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM