资讯专栏INFORMATION COLUMN

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

wemallshop / 560人阅读

摘要:在使用的过程中,通过操作符为对象添加新属性是很常见的操作。但是,这个操作的结果实际上会受到原型链上的同名属性影响。通过它,可以做到操作符做不到的事情,比如为对象设置一个新属性,即使它的原型链上已经有一个的同名属性。

在使用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个解释的理由:

原型链的作用是为对象提供默认值,即当对象自身不存在某属性的时候,这个属性应该表现出的默认值。为这个属性赋值的时候,不应该通过“改变默认值”(修改原型链上的属性)来做到,而应该通过创建一个新的值来掩盖(shaow)默认值(默认值仍然存在,只是不再表现出来)。这样做的一个好处是,你以后可以delete obj.newProp,然后obj.newProp就会再次表现出默认值。假设不采用这个方案,而是通过“改变默认值”,那么原来的默认值就会丢失,delete obj.newProp不会起作用(delete操作符只会删除对象自身的属性)。

多个对象可能共享同一个原型对象,如果对其中一个对象的属性赋值就可以改变原型对象的属性,那么"="操作符会变得非常危险,因为这会影响到共享这个原型的所有对象。

如果原型链上存在由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的同名属性。

参考资料

You Don"t Know JS

js 属性设置与屏蔽

Property assignment and the prototype chain - 2ality

JS对象原型链上的同名属性的writable为什么会影响到 对象本身的属性呢? - 知乎

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/98868.html

相关文章

  • "instanceof 的原理是什么"?大声告诉面试官,我知道!

    摘要:一是如何工作的在上是这样描述的运算符用于测试构造函数的属性是否出现在对象原型链中的任何位置换句话说,如果,那么必须是一个对象,而必须是一个合法的函数。下面我们举一个例子一步步来说明第一步每一个构造函数都有一个属性。 在 JavaScript 中,我们通常用 typeof 判断类型,但是在判断引用类型的值时,常常会遇到一个问题:无论引用的是什么类型的对象,都会返回 object(当然还有...

    CompileYouth 评论0 收藏0
  • js对象详解(JavaScript对象深度剖析,深度理解js对象)

    摘要:对象详解对象深度剖析,深度理解对象这算是酝酿很久的一篇文章了。用空构造函数设置类名每个对象都共享相同属性每个对象共享一个方法版本,省内存。 js对象详解(JavaScript对象深度剖析,深度理解js对象) 这算是酝酿很久的一篇文章了。 JavaScript作为一个基于对象(没有类的概念)的语言,从入门到精通到放弃一直会被对象这个问题围绕。 平时发的文章基本都是开发中遇到的问题和对...

    CatalpaFlat 评论0 收藏0
  • 汇总有关JS对象的创建与继承

      之前也有和大家讲过有关JS的对象创建和对象继承,本篇文章主要为大家做个汇总和梳理。  JS中其实就是原型链继承和构造函数继承的毛病,还有就是工厂、构造、原型设计模式与JS继承。 JS高级程序设计4:class继承的重点,不只是简简单单的语法而已。  对象创建  不难发现,每一篇都离不开工厂、构造、原型这3种设计模式中的至少其一!  那JS为什么非要用到这种3种设计模式了呢??  我们先从对...

    3403771864 评论0 收藏0
  • 高能!typeof Function.prototype 引发的先有 Function 还是先有 O

    摘要:有个例外他就是。看左侧对象的原型链上是否有第一步得到。将各内置引用类型的指向。用实例化出,,以及的行为并挂载。实例化内置对象以及至此,所有内置类型构建完成。最后的最后,你还对是现有还是现有有想法了吗以上均为个人查阅及实践总结的观点。 来个摸底测试,说出以下每个表达式的结果 function F(){}; var o = {}; typeof F; typeof o; typeof F...

    娣辩孩 评论0 收藏0
  • js面向对象之屏蔽属性

    摘要:什么是屏蔽属性一条赋值语句引出的思考如果对象中包含名为的普通数据访问属性,这条赋值语句只会修改已有的属性值。然而,如果存在于原型链上层,赋值语句的行为就会有些不同而且可能很出人意料。总之,不会发生屏蔽。 1.什么是屏蔽属性 一条赋值语句引出的思考: myObject.foo = bar; 如果myObject 对象中包含名为foo 的普通数据访问属性,这条赋值语句只会修改已有的属性值...

    Pluser 评论0 收藏0

发表评论

0条评论

wemallshop

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<