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

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

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

若是原型鏈上不存在同名屬性,則直接在obj上建立新屬性

經過"="操做符賦值時,js引擎會沿着obj的原型鏈尋找同名屬性,若是最後到達原型鏈的尾端null仍是沒有找到同名屬性,則直接在obj上建立新屬性。git

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

結果

這種狀況很是符合人的直覺,全部js使用者應該都已經熟悉了這種狀況。可是事情並非老是這麼簡單。程序員

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

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

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

結果:
結果算法

爲何要這樣定義?

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

有2個解釋的理由:數組

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

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

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

"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所在的對象(好比對於屬性引用a.b.c.prop,獲取到的對象是a.b.c)。本文討論的賦值狀況會進入第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爲何會影響到 對象自己的屬性呢? - 知乎
相關文章
相關標籤/搜索