【js細節剖析】通過"="操作符爲對象添加新屬性時,結果會受到原型鏈上的同名屬性影響

在使用JavaScript的過程中,通過"="操作符爲對象添加新屬性是很常見的操作:obj.newProp = 'value';。但是,這個操作的結果實際上會受到原型鏈上的同名屬性影響。接下來我們分類討論。

以下討論都假設對象自身原本不存在要賦值的屬性(故稱:“爲對象添加新屬性”)。如果對象自身已經存在這個屬性,那麼這是最簡單的情況,賦值行爲由這個屬性的描述符(descriptor)來決定。

如果原型鏈上不存在同名屬性,則直接在obj上創建新屬性

通過"="操作符賦值時,js引擎會沿着obj的原型鏈尋找同名屬性,如果最後到達原型鏈的尾端null還是沒有找到同名屬性,則直接在obj上創建新屬性。

const obj = {};
obj.newProp = 'value';

結果

這種情況非常符合人的直覺,所有js使用者應該都已經熟悉了這種情況。但是事情並不是總是這麼簡單。

如果原型鏈上存在由data descriptor定義的writable同名屬性,則直接在obj上創建新屬性

沿着obj的原型鏈尋找同名屬性時,如果找到由data descriptor定義的同名屬性,且它的writable爲true,則直接在obj上創建新屬性。

const proto = { newProp: "value" };
const obj = Object.create(proto);
obj.newProp = "newValue";

結果:
結果

爲什麼要這樣定義?

這個情形也很常見,但是對於很多人來說可能不符合直覺:爲什麼通過obj.newProp能獲取到原型鏈上的newProp屬性,但是通過obj.newProp = "newValue"不能修改原型鏈上的屬性而是添加新屬性呢?

有2個解釋的理由:

  1. 原型鏈的作用是爲對象提供默認值,即當對象自身不存在某屬性的時候,這個屬性應該表現出的默認值。爲這個屬性賦值的時候,不應該通過“改變默認值”(修改原型鏈上的屬性)來做到,而應該通過創建一個新的值來掩蓋(shaow)默認值(默認值仍然存在,只是不再表現出來)。這樣做的一個好處是,你以後可以delete obj.newProp,然後obj.newProp就會再次表現出默認值。假設不採用這個方案,而是通過“改變默認值”,那麼原來的默認值就會丟失,delete obj.newProp不會起作用(delete操作符只會刪除對象自身的屬性)。
  2. 多個對象可能共享同一個原型對象,如果對其中一個對象的屬性賦值就可以改變原型對象的屬性,那麼"="操作符會變得非常危險,因爲這會影響到共享這個原型的所有對象。

如果原型鏈上存在由data descriptor定義的non-writable同名屬性,則賦值失敗

沿着obj的原型鏈尋找同名屬性時,如果找到由data descriptor定義的同名屬性,且它的writable爲false,那麼賦值操作失敗。在這種情況下,既不會修改原型鏈上的同名屬性,也不會爲對象自身新建屬性。在"strict mode"模式下會拋出錯誤,否則靜默失敗。

"use strict";
const proto = Object.defineProperty({}, "newProp", {
  value: "value",
  writable: false
});
const obj = Object.create(proto);
obj.newProp = "newValue";

結果

爲什麼要這樣定義?

在參考資料3和4中給出了這樣定義的原因:爲了使getter-only property(只定義了getter而沒定義setter的屬性)和non-writable property具有同樣的表現:

const a = Object.defineProperty({}, "x", { value: 1, writable: false });
const b = Object.create(a);
b.x = 2;    // 賦值失敗

應該等價於

const a = {
  get x() {
    return 1;
  }
};
const b = Object.create(a);
b.x = 2;    // 賦值失敗,這種情況會在下面討論到

因爲原型鏈上的getter-only property會阻止子代對象通過"="操作符增加同名屬性(稍後會討論這種情況),所以原型鏈上的non-writable property也應該阻止子代對象通過"="操作符增加同名屬性。

此外,參考資料1還給出了一個原因,那就是爲了模仿傳統類繼承語言的表現。JavaScript的繼承,從表面上看,應該像是“將父類的所有屬性都拷貝到了子類上”一樣。因此,父對象上的屬性(writable、non-writable)理應對子對象產生影響(如果子對象沒有覆蓋這個屬性的話)。

如果原型鏈上存在由accessor descriptor定義的同名屬性,則賦值操作由其中的setter定義

沿着obj的原型鏈尋找同名屬性時,如果找到由accessor descriptor定義的同名屬性,則由這個accessor descriptor中的setter來決定做什麼。setter將會被調用,this指向被賦值的對象obj(而不是setter所在的原型對象)。
如果這個accessor descriptor中只定義了getter而沒有setter,則賦值操作失敗,在"strict mode"模式下會拋出錯誤,否則靜默失敗。

const a = {
  get x() {
    return this._x;
  },
  set x(v) {
    // 這裏的this將指向b對象
    this._x = v + 1;
  }
};
const b = Object.create(a);
b.x = 2;
console.log(b.x); // 3
console.log(b.hasOwnProperty("_x")); // true,證明了setter中的this指向被賦值對象,而不是setter所在的原型對象

在上面的圖中需要注意一點,雖然在b對象下顯示了"x"屬性,但這個屬性實際是存在於b.__proto__上的(b.hasOwnProperty('x')將返回false),chrome的控制檯爲了方便debug,將原型鏈上的getter屬性與對象自身的屬性放在一起展示。

爲什麼要這樣定義?

爲了增強“繼承”和“getter/setter”的威力。加入原型對象上的setter對後代對象的賦值無效、原型對象上的getter對後代對象的取值無效(也就意味着getter/setter不會被繼承),這將大大削弱getter/setter的作用。
另一方面,假如accessor descriptor定義的屬性不會被繼承,那麼data descriptor定義的屬性應不應該被繼承?如果也不被繼承,那麼JavaScript還怎麼做到面嚮對象語言最基本的“繼承”?如果data descriptor定義的屬性能夠被繼承,那麼accessor descriptor與data descriptor的使用場景將出現巨大的割裂,程序員只能通過“屬性是否能被繼承”來決定是使用accessor descriptor還是data descriptor,這將大大削弱descriptor的靈活性。
此外,與前面一種情況同理,“模仿傳統類繼承語言的表現”也是一個重要的原因。

ECMAScript標準定義的賦值算法

前面已經對【通過"="操作符爲對象添加新屬性】的3種情況進行了討論和解釋。接下來我們看看ECMAScript標準是如何正式地定義"="操作符的行爲的。
AssignmentExpression:LeftHandSideExpression=AssignmentExpression表達式在運行時的求值算法

說明:
abcd步驟,對於賦值表達式的左值取引用(相當於得到變量/屬性在內存中的地址),對於右值求值。e步驟是爲了處理func = function() {}這種函數表達式賦值的情況,本文不討論。f步驟中的PutValue(lref, rval)纔是真正執行賦值操作的算法。PutValue ( V, W )的算法定義:

其中第4步的作用是,對於引用V,獲取V所在的對象。本文討論的賦值情況會進入第6步的Elseif中。6.a是爲了應對true.prop = 2134這種情況(這是合法的表達式!),不在本文討論。6.b中的[[Set]]承擔賦值過程的主要操作。[[Set]]ECMAScript爲對象定義的13個基本內部方法之一,普通對象對這些內部方法的實現算法在這裏特異對象(比如數組)在普通對象的基礎上覆蓋某些基本內部方法。在這裏我們只看普通對象的[[Set]]算法

可以看出,算法在2.b.i步驟做了遞歸:如果當前對象不存在這個屬性,則遞歸到父對象上找。參數O隨着每次遞歸而變化,指向當前遞歸查找到了哪個對象。而參數Receiver則不隨着遞歸而改變,始終指向最初被賦值的那個對象
如果在原型鏈上找到了同名屬性,就會進入OrdinarySetWithOwnDescriptor的步驟3:

  • 步驟3.a對應了前面討論的【如果原型鏈上存在由data descriptor定義的non-writable同名屬性,則賦值失敗】情況。
  • 步驟3.e對應了前面討論的【如果原型鏈上存在由data descriptor定義的writable同名屬性,則直接在obj上創建新屬性】情況。
  • 步驟6和7對應了前面討論的【如果原型鏈上存在由accessor descriptor定義的同名屬性,則賦值操作由其中的setter定義】情況。
  • 至於步驟3.d,則對應了在文章開頭提到的【被賦值對象自身已經存在賦值屬性】,屬於最簡單的情況。

如果在原型鏈上找不到同名屬性,會經過步驟2.c.i,從而最終到達步驟3.e,在目標對象上創建新屬性,對應於前面討論的【如果原型鏈上不存在同名屬性,則直接在obj上創建新屬性】情況。

瞭解這些有什麼好處?

"="操作符賦值是JavaScript中最常見的操作之一,瞭解它的特殊性有助於更好地利用它、更好地利用“繼承”。

除此之外,你會驚訝地發現,Proxy允許我們攔截的13個對象方法,恰好一一對應於ES標準爲對象定義的13個基本內部方法!而Reflect對象中提供的13個方法也與之一一對應!其實Reflect對象提供的13個方法就是普通對象的基本內部方法的簡單封裝!

現在你應該能夠理解爲什麼,在我們通過Proxy攔截set操作的時候,執行引擎會向我們暴露出剛剛談到的receiver。因爲我們不僅僅會攔截到被代理對象(target)的賦值操作,並且,如果代理對象成爲其他對象的原型,那麼對其他對象(receiver)的賦值也會觸發代理對象的set操作。執行引擎會將target和receiver都暴露給我們,從而我們能擁有最大的靈活度。

另一條路:Object.defineProperty()

注意,我們在前面討論的時候一直強調"="操作符,這是因爲,爲對象添加、修改屬性還有另一種方法:Object.defineProperty()。這是比"="操作符更加強大、基礎的方法,它只對指定的對象進行屬性增加、修改,而不會影響到原型鏈上的對象或被原型鏈影響。通過它,可以做到"="操作符做不到的事情,比如:爲對象設置一個新屬性,即使它的原型鏈上已經有一個non-writable的同名屬性。

參考資料

  1. You Don't Know JS
  2. js 屬性設置與屏蔽
  3. Property assignment and the prototype chain - 2ality
  4. JS對象原型鏈上的同名屬性的writable爲什麼會影響到 對象本身的屬性呢? - 知乎
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章