第 53 题:输出以下代码的执行结果并解释为什么
Opened this issue · 33 comments
结果:
undefined
{n:2}
首先,a和b同时引用了{n:2}对象,接着执行到a.x = a = {n:2}语句,尽管赋值是从右到左的没错,但是.的优先级比=要高,所以这里首先执行a.x,相当于为a(或者b)所指向的{n:1}对象新增了一个属性x,即此时对象将变为{n:1;x:undefined}。之后按正常情况,从右到左进行赋值,此时执行a ={n:2}的时候,a的引用改变,指向了新对象{n:2},而b依然指向的是旧对象。之后执行a.x = {n:2}的时候,并不会重新解析一遍a,而是沿用最初解析a.x时候的a,也即旧对象,故此时旧对象的x的值为{n:2},旧对象为 {n:1;x:{n:2}},它被b引用着。
后面输出a.x的时候,又要解析a了,此时的a是指向新对象的a,而这个新对象是没有x属性的,故访问时输出undefined;而访问b.x的时候,将输出旧对象的x的值,即{n:2}。
上面是之前写的解释,最近看周爱民老师的文章的时候,发觉这部分解释有不少地方没说到本质上,有的还是错误的,所以我重新结合老师的文章研究了一下,修改如下:
以这段代码为例:
var a = {n:1};
a.x = a ={n:2};
console.log(a.x);
代码 | 注释 | 补充 |
---|---|---|
a | 计算单值表达式 a,得到 a 的引用 | 这里的 a 是初始 a |
a.x | 将 x 这个标识符作为. 运算符的右操作数,计算表达式 a.x,得到结果值(Result),它是一个 a.x 的“引用” | 这个“引用”当作一个数据结构,通常有 base、name、strict 三个成员。无论x 属性是否存在(这里暂时不存在),a.x 都会被表达为 {"base": a, "name": "x", ...}。而这里的 a 仍然指向旧对象。 |
a | 计算单值表达式 a,得到 a 的引用 | 这里的 a 是初始 a |
a = {n:2} | 赋值操作使得左操作数 a 作为一个引用被覆盖,同时操作完成后返回右操作数 {n:2} | 这里的这个 a 的的确确被覆盖了,这意味着往后通过 a 访问到的只能是新对象。但是,有一个 a 是不会变的,那就是被 a.x 的 Result 保存下来的引用 a,它作为一个当时既存的、不会再改变的结果,仍然指向旧对象。 |
a.x = {n:2} | 指向旧对象的 a 新建了 x 属性,这个属性关联对象 {n:2} | 注意,这里对 a.x 进行了写操作(赋值),直到这次赋值发生的那一刻,才有了为旧对象动态创建 x 属性这个过程。 |
所以,旧对象(丧失了引用的最初对象)和新对象(往后通过 a
可以访问到的那个对象)分别变成:
// 旧对象
a:{
n:1,
x:{n:2}
}
// 新对象
a:{
n:2
}
现在,执行 console.log(a.x)
,这里 a.x
被作为 rhs(右手端) 读取,引擎会开始检索是否真的有 a["x"]
这个东西,因为此时通过 a
能访问到的只能是新对象,它自然是没有 x
属性的,所以打印 undefined
。而且 —— 直到这次读取发生的那一刻,才有了为新对象动态创建 x
属性这个过程。
Note:也就是说,在引擎从左到右计算表达式的过程中,尽管可能遇见类似 a.x
这样本不存在的属性,但无论如何,都会存在 {"base": a, "name": "x", ...}
这样的数据结构,而在后续真正对 x
进行 读写 的时候,这个 x
才会得到创建。
这个代码块所做的事情,实际上是向旧有对象添加一个指向新对象的属性,并且如果我们想要在后续仍然持有对旧对象的访问,可以在赋值覆盖之前新建一个指向旧对象的变量。
undefined
{n: 2}
具体答案分析和扩展之前写过一篇类似的
https://juejin.im/post/5b605473e51d45191a0d81d8
把 a.x = a = {n: 2}, 换成 b.x = a = {n: 2} 的时候,是不是会好理解了,虽然确实是这样。
以前有做过一个一样的题,等号运算符和.
运算符优先级的问题。
https://youzixr.github.io/2019/03/05/JS-%E5%88%B7%E9%A2%98%E8%AE%B0%E5%BD%95/
运算符优先级还真没注意,涨姿势了
答案如上
注意点:
1: 点的优先级大于等号的优先级
2: 对象以指针的形式进行存储,每个新对象都是一份新的存储地址
var a = {n: 1}; // a保持对{n:1}对象的引用
var b = a; // b保持对{n:1}对象的引用
a.x = a = {n: 2}; // a的引用被改变
a.x // --> undefined
b.x // --> {n: 2}
1、.
运算符优先,a.x此时保持对{n: 1}
的引用,也就是b也保持对{n: 1}
的引用,于是{n: 1}
=> {n: 1, x: undefined}
,此时a和b还是对原来对象的引用,只不过原来对象增加了x属性
2、=
从右往左,a = {n: 2}
,此时a的引用已经变成了{n: 2}
这个对象
3、a.x=a
,此时a.x是保持对{ n: 1, x: undefined}
中的x引用,也就是b.x,于是{ n: 1, x: undefined}
=> {n: 1, x: { n: 2}}
,即b.x = { n: 2 }
进阶系列中有的
为啥不会重新解析a啊
有一个网站可以将 JavaScript 代码的执行过程,用可视化的方式呈现出现。具体链接如下:tylermcginnis
从可视化的执行过程来看,并没有之前上面答案所说的对象增加 x 属性的这个过程,也即 { n: 1 }
=> { n: 1, x: undefined }
,而是最后直接变成 { n: 1, x: { n: 2 } }
。
const a = {};
const b = 1;
a.x = b;
第三行代码 a.x = b;
在执行的过程中,会执行一次左查询以及一次右查询。这里所说的“左” / “右”是把 =
操作符作为参照物,a.x
执行左查询是为了弄清楚在 a
对象上是否存在 x
属性,如果存在,那么 a.x = b;
语句执行的是更新属性的操作;反之,则是新增属性的操作。如果在查询的过程中,发现 a
不存在,则引擎会报错。b
执行右查询是为了获取 b
的值。如果在查询的过程中,发现 b
不存在,引擎可能报错,也可能不报错。至于在赋值的过程中,是否执行左查询或者右查询,关键是看 =
的左右两边是否存在变量
这个问题考察的知识点主要有以下这些:
.
的优先级高于=
的优先级=
具有右结合性(执行的方向是从右往左,先执行=
右边的表达式,然后把结果赋值给=
左边的表达式,从这里可以得出=
属于二元操作符),多个=
的执行过程,可以类比成"递归"的过程
let a = { n: 1 };
const b = a;
a.x = a = { n: 2 };
执行完第一行以及第二行代码之后,变量 a
和 常量 b
指向同一块内存地址(对象 { n: 1 }
在内存里面的内存地址)。换句话说,a
现在是 b
的别名;反之亦然
在执行第三行代码之前,你要知道 a.x = a = { n: 2 }
里面包含两种操作符(.
、=
)。也正是由于 .
的优先级高于 =
的优先级,所以会首先执行 a.x
。不过在执行 a.x
的过程中,会执行一次“左”查询。经过左查询之后,发现对象 a
没有 x
属性(在这里你可以认为代码已经变成 ({ n: 1 }).x
或者 b.x
),然后会再去执行第一个 =
操作符。由于 =
具有右结合性,所以会先去执行 a = { n: 2 }
。在执行的过程中,发现 a = { n: 2 }
是一个普通的赋值操作。而且也正是因为 =
右边是一个对象字面量,所以在这里是不存在右查询以及表达式的计算过程。不过在把 { n: 2 }
赋给变量 a
之前,需要对变量 a
执行一次左查询。经过左查询之后,发现变量 a
已经被声明(假如发现变量 a
没有被声明,在非严格模式下,对它赋值的这个操作不会导致引擎报错),所以会继续把 { n: 2 }
赋值给变量 a
。之后会把 a = { n: 2 }
语句的返回结果,作为第一个 =
右边的表达式。所以第三行代码变成 ({ n: 1 }).x = { n: 2 }
或者 b.x = { n: 2}
。如果没有第二行代码 const b = a;
,在执行完第三行代码之后,对象 { n: 1, x: { n: 2} }
所占据的内存会被 GC 回收。补充一句,假设第三行代码就只有 a.x
的话,那么第三行代码的执行过程就结束啦。
至于想搞清楚自己到底有没有理解这个,可以尝试想一下:如果 .
的优先级低于 =
的优先级,上述代码的执行过程是怎样的?
let a = { n: 1 };
const b = a;
// `.` 的优先级低于 `=` 的优先级
a.x = a = { n: 2 };
console.log(a); // 报错
简单的分析一下,a.x = a = { n: 2 };
这段代码,最后会演变成 (a = { n: 2 }, x = (a = { n: 2 }), a.(x = (a = { n: 2 })))
。简化一下会变成这样:(a = { n: 2 }, x = a, a.{ n: 2 })
。
连续赋值
var a = {n: 1};
var b = a;
a.x = a = {n: 2};
console.log(a.x)
console.log(b.x)
- a 赋值,a 指向堆内存 {n:1}
a = { n : 1 }
- b 赋值,b 也指向对内存 {n:1}
b = a
- .的优先级大于=,所以优先赋值。ps:此时a.x已经绑定到了{n: 1 , x: undefined}被等待赋值
a.x = undefined
a // {n: 1 , x: undefined}
b // 也指向上行的堆内存
- 同等级赋值运算从右到左,a改变堆内存指向地址,所以a = {n: 2},
a.x = a = {n: 2};
- 因为a.x已经绑定到了{n: 1 , x: undefined}这个内存地址,所以相当于
{n: 1 , x: undefined}.x = {n: 2}
- 结果
a = {n: 2}
b = {
n: 1,
x: {
n: 2
}
}
之前我也一直想为什么不会重新解析a,然后看了上面很多分析,但还是有些看不明白,然后看到点的优先级大于等号的优先级,那我可以这么理解吗?
var a = {n: 1};
var b = a;
a.x={n:2};
a = {n:2};
@yeyi361936738 我不认同你的这个观点
. 的优先级大于 =,所以优先赋值。ps:此时 a.x 已经绑定到了 {n: 1 , x: undefined} 被等待赋值
你这句话恰好说明你不懂 . 的优先级大于 = 是什么意思
@onloner2012 @ruyanzhang 至于为什么不会重新解析 a
,这恰恰是说明 .
操作符的优先级高于 =
。因为 .
操作符(a.x
)在一开始就已经被执行过,所以这时候你可以把 a.x
理解成 ({ n: 1 }).x
。如果引擎在赋值操作(=
操作符属于二元操作符)的过程中,又去访问 a
(重新解析 a
),势必又会去执行 .
操作符,不就说明 .
操作符的优先级低于 =
,这与事实(.
操作符的优先级高于 =
)矛盾,所以此时 =
操作符是把 a.x
看作一个整体,不会重新解析 a
,不过存在重新解析 a
的情况:
let a = {};
let b;
[b = a] = [, a = {n: 2}];
现在继续来解释下面这一段代码
const a = {};
a.x = void 1024;
由于 .
的优先级大于 =
,所以首先执行的是对象属性的 get
操作(又称之为左查询),通过执行该查询操作之后,发现 a
对象本身以及原型链上都不存在该属性(x
);此时也意味着 .
操作符的执行先告一段落。假设第二行代码里面没有 =
操作符(a.x;
),到这里也就意味着,第三行代码的执行过程全部结束。然后引擎继续从 x
的位置开始向右执行(可能你会问我为什么是向右执行,这是因为不同操作符的执行顺序由该操作符本身的优先级决定的,自然执行完 .
操作符,然后引擎去执行 =
,恰好 =
操作符出现在 .
操作符的右边),然后在执行的过程中遇到 =
操作符,由于 =
操作符具有右结合性,也就意味着这时候会首先执行 =
右边的表达式,所以在上述表达式的计算过程结束后,得到计算值 undefined
,并且将该计算值(undefined
)赋给 a.x
。可能你会问我为什么是赋值?这是因为 =
操作的作用是赋值以及该操作符是二元操作符。正如之前所提到的,{ n: 1 }
或者 b
对象上没有 x
属性,这也就意味着此时的赋值操作是 set
操作,所以最后的结果({ n: 1 }
或者 b
对象上新增一个属性值为 undefined
的属性 x
)也正如你所看到的那样。
顺便说一句,往对象上新增一个属性或者修改已存在属性的属性值,不一定能成功。以下是一些🌰:
- 对象的原型链上存在同名的 non-writable 属性
Object.defineProperties(
Object.prototype,
{
a: {
value: 1,
writable: false,
},
},
);
const obj = {};
obj.a = 2;
- 对象的原型链上存在同名的属性,该属性不存在 setter
Object.defineProperties(
Object.prototype,
{
a: {
get() {
return 1;
},
},
},
);
const obj = {};
obj.a = 2;
- 对象处于 non-extensible,表明该对象不能新增属性
const empty = {};
Object.isExtensible(empty); // === true
Object.preventExtensions(empty);
Object.isExtensible(empty); // === false
// Sealed objects are by definition non-extensible.
const sealed = Object.seal({});
Object.isExtensible(sealed); // === false
// Frozen objects are also by definition non-extensible.
const frozen = Object.freeze({});
Object.isExtensible(frozen); // === false
至于为什么对下面这句表述所加粗的地方存在质疑,是因为有🌰的加持
1、优先级
.
的优先级高于=
,所以先执行a.x
,堆内存中的{ n: 1 }
就会变成{ n: 1, x: undefined }
,改变之后相应的b.x
也变化了,因为指向的是同一个对象。
let a = { n: 1 };
const b = a;
const proxy = new Proxy(a, {
set(target, key, value, receiver){
console.info([
`需要把对象 ${JSON.stringify(target)} 的 ${key} 属性的值改为 ${JSON.stringify(value)}`,
`此时的 a 对象以及 b 对象分别为 ${JSON.stringify(a)}、${JSON.stringify(b)}`,
`target 对象是否等于 b 对象:${target === b}`
].join('\n'));
return Reflect.set(target, key, value, receiver);
},
});
proxy.x = a = { n: 2 };
console.log(a.x);
console.log(b.x);
a.x = a = { n:2 }
- a 添加属性 x,a 原来指向的对象为 { x: undefine, n: 2 }
- a 指向新的内存地址 { n:2 },此时 b 仍指向之前旧对象的内存地址
- a.x 此时即 b.x,原有旧对象被修改为 { x: { n:2 }, n: 2}
我在想,问题的表达式能不能改写成这样
var a = { n : 1};
var b = a;
a.x;
a = { n: 2 };
b.x = a;
console.log(a.x);
console.log(b.x);
因为 .
优先级比 =
的高,那么是否能先提出来,这样情况就比较明显了。
之前我也一直想为什么不会重新解析a,然后看了上面很多分析,但还是有些看不明白,然后看到点的优先级大于等号的优先级,那我可以这么理解吗?
var a = {n: 1};
var b = a;
a.x={n:2};
a = {n:2};
可以看下你楼上的答案:#93 (comment)
主要是在理解a.x = a = {n: 2};
的优先级上,
- 运行这一行时,
a.x
已经定义为a={n:2}
这个表达式的结果,也就是说, a.x
的实际上是{n:1}
这个内存地址的x属性,- 然后,才运行
a={n:2}
这一步, - 这时候
a
的代表的内存地址改变了,a
的内存地址变成{n:2}
的内存地址, a
跟之前一步的a.x
代表的内存地址({n:1}
这个的内存地址)已经没有关系了,所以a.x
的内存实际上跟b
一样,相当于1
中的b.x
被定义为a={n:2}
这个表达式的结果,最后结果就是:
a = {n:2}
b = {n:1, x: {n:2}}
一开始有点疑惑,
总觉得a.x = a 执行之后是
a.x = {n: 1} 而非 a.x = {n: 2},
不过看了 @yygmind 的解释,顿悟了,原来
a.x = a = {n: 2} 的步骤是:
先执行a.x : a.x = {n: 1, x: undefined}
变执行方向从右到左a = {n: 2}: a = {n: 2};
再继续像左执行 a.x = a: a.x = {n: 1, x: {n: 2}}
此时 a = {n: 2};
b = {n: 1, x: {n: 2}};
. 操作符的优先级比赋值高。
本题最难理解的一行代码是
a.x = a = { n:2 }
解构一下发生了什么:
- a.x = undefined
- a.x = { n: 2 } // 等同于 b.x = { n:2 }
- a = { n:2 } // a 的引用指向发生了变化
关键在于,. 操作符的计算会先执行,也就是先把 b.x 引用到了新对象 { n:2 },
再把之前定义的 a 变量重新赋值。
作者:叔叔张
知乎上的这个回答很不错
这一篇讲的很清除,如果再加上a = {n: 2}的返回值为{n: 2}就更好了
https://segmentfault.com/a/1190000008475665
总感觉大家在强行解释 ,为啥没人觉得这种设计有问题?同样是赋值,a.x 比 a 为啥优先级就一定要高?
理解起来费劲,我懒得记了,面试的时候问到了我就说不知道,我从来不会写这么奇怪的赋值
var a = {n: 1};
var b = a;
a.x = {n: 2}; // 此时b.x = {n: 2};
a = {n: 2};
console.log(a.x)
console.log(b.x)
var a = {n: 1};
var b = a;
a.x = a = {n: 2};
console.log(a.x)
console.log(b.x)
考察对象的指针和符号的优先级
var b = a;
b 和 a 都指向同一个地址。.
的优先级高于=
。所以先执行a.x
,于是现在的a
和b
都是{n: 1, x: undefined}
。=
是从右向左执行。所以是执行a = {n: 2}
,于是a
指向了{n: 2}
- 再执行
a.x = a
。 这里注意,a.x
是最开始执行的,已经是{n: 1, x: undefined}
这个地址了,而不是一开的的那个a
,所以也就不是{n: 2}
了。而且b
和旧的a
是指向一个地址的,所以b
也改变了。 - 但是,
=
右面的a,是已经指向了新地址的新a
。 - 所以,
a.x = a
可以看成是{n: 1, x: undefined}.x = {n: 2}
得出
a = { n: 2 },
b = {
n: 1,
x: { n: 2 }
}
最终结果打印出来是: undefined { n: 2 }
var a = {n: 1};
var b = a;
a.x = a = {n: 2};
console.log(a.x)
console.log(b.x)
- 考察的第一个知识点,.运算符优先级比=高,所以a.x执行后,此时a和b对象就指向{n:1, x: undefined}的地址
- 然后
=
运算符是从右往左执行,所以a被赋值成了 {n: 2} - 再执行
a.x = {n: 2}
时,因为b和a指向同一个地址, 相当于
{n:1, x: undefined}.x = {n:2}
,也就是b.x = {n: 2}
- 所以最终
b.x = {n: 2}
第一步a、b均指向{n: 1}
第二步进行连续赋值操作:
js连续赋值操作规则为先从左往右计算被赋值的值的位置,然后再从右往左进行赋值
此处优先计算出x的赋值位置即{n: 1}和a的被赋值位置,然后再进行赋值操作,a被赋值为{n: 2},x也被赋值为{n: 2}且赋值位置为{n: 1}
所以a.x为undefined
由于b指向{n: 1},故访问b时x值为{n: 2}
js连续赋值规则论证如下:
上述例子可转换为:
(function() {
var obj = { a: { c: { n: 1 } } };
var b = obj.a.c;
obj.a.c.x = obj.a.c = { n: 2 };
console.log(obj.a.c.x);
console.log(b.x);
})();
添加监听后为:
(function() {
var obj = {};
var model = { 'a': { c: { n: 1 } } };
model['a.c'] = model['a'].c;
Object.defineProperty(obj, 'a', {
set: function(val) {
console.log('set a');
model['a'] = val;
},
get: function() {
console.log('get a');
return model['a'];
}
});
Object.defineProperty(obj.a, 'c', {
set: function(val) {
console.log('set a.c');
model['a.c'] = val;
},
get: function() {
console.log('get a.c');
return model['a.c'];
}
});
var b = obj.a.c;
Object.defineProperty(obj.a.c, 'x', {
set: function(val) {
console.log('set a.c.x');
model['a.c.x'] = val;
},
get: function() {
console.log('get a.c.x');
return model['a.c.x'];
}
});
console.log('***start***');
obj.a.c.x = obj.a.c = { n: 2 };
// obj.a.c = obj.a.c.x = {n: 2};
console.log('***end***');
console.log(obj.a.c.x);
console.log(b.x);
})();
obj.a.c.x = obj.a.c = {n: 2};
输出:
...
***start***
get a
get a.c
get a
set a.c
set a.c.x
***end***
...
undefined
...
{n: 2}
obj.a.c = obj.a.c.x = {n: 2};
输出:
...
***start***
get a
get a
get a.c
set a.c.x
set a.c
***end***
...
undefined
...
{n: 2}
根据观察结果先从左往右查找赋值位置,位置查找完后再从右往左对查找到的位置进行赋值
注:此处的从右往左进行赋值并不是简单的每项逐一赋值。而是以拆开的形式统一赋值,如x = y = z实际为y = z, x = z。此处在MDN上有相关说明
如需验证可执行:
(function() {
var a = {};
var b = {};
Object.defineProperty(a, 'x', {
get: function() {
return 1
}
});
b = a.x = { n: 2 };
console.log(b);
})();
{n: 2}
简单理解:
a.x = a = {n: 2} 拆分理解
a = {n: 2} 这里是重新定义了一个变量a 地址与第一次定义的变量a不一致( 可以尝试使用let 定义a,会出现报错表示a已经定义了
a.x 这里相当于b.x 是最开始定义的a,可以理解为
a = {n: 2} ;
b.x = a;
var a = {n: 1};
var b = a;
a.x = a = {n: 2};
console.log(a.x) // {n: 2}
console.log(b.x) // {n: 1, x: {n: 2}}
console.log(b.x === a) // true