【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为何会影响到 对象自己的属性呢? - 知乎
相关文章
相关标签/搜索