【js细节剖析】通过"="操作符为对象添加新属性时,结果会受到原型链上的同名属性影响

csRyan

在使用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所在的对象(比如对于属性引用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为什么会影响到 对象本身的属性呢? - 知乎
阅读 2.2k

csRyan的学习专栏
分享对于计算机科学的学习和思考,只发布有价值的文章: 对于那些网上已经有完整资料,且相关资料已经整...

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart...

1.1k 声望
160 粉丝
0 条评论
你知道吗?

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart...

1.1k 声望
160 粉丝
宣传栏