(转载)深入理解“连等赋值”问题
原文链接:
https://segmentfault.com/a/1190000004224719
有这样一个热门问题:
1 | var a = { n: 1 }; |
其实这个问题很好理解,关键要弄清下面两个知识点:
JS
引擎对赋值表达式的处理过程- 赋值运算的右结合性
一. 赋值表达式
形如
1 | A = B; |
的表达式称为赋值表达式。其中 A
和 B
又分别可以是表达式。B
可以是任意表达式,但是 A
必须是一个左值。
所谓左值,就是可以被赋值的表达式,在 ES
规范中是用内部类型引用(Reference
)描述的。例如:
- 表达式
foo.bar
可以作为一个左值,表示对foo
这个对象中bar
这个名称的引用; - 变量
email
可以作为一个左值,表示对当前执行环境中的环境记录项envRec
中email
这个名称的引用 - 同样地,函数名
func
可以做左值,然而函数调用表达式func(a, b)
不可以。
那么 JS
引擎是怎样计算一般的赋值表达式 A = B
的呢?简单地说,按如下步骤:
- 计算表达式
A
,得到一个引用refA
; - 计算表达式
B
,得到一个值valueB
; - 将
valueB
赋给refA
指向的名称绑定; - 返回
valueB
。
二. 结合性
所谓结合性,是指表达式中同一个运算符出现多次时,是左边的优先计算还是右边的优先计算。 赋值表达式是右结合的。这意味着:
1 | A1 = A2 = A3 = A4; |
等价于
1 | A1 = A2 = A3 = A4; |
三. 连等的解析
好了,有了上面两部分的知识。下面来看一下 JS
引擎是怎样运算连等赋值表达式的。
以下面的式子为例:
1 | Exp1 = Exp2 = Exp3 = Exp4; |
首先根据右结合性,可以转换成
1 | Exp1 = Exp2 = Exp3 = Exp4; |
然后,我们已经知道对于单个赋值运算,JS
引擎总是先计算左边的操作数,再计算右边的操作数。所以接下来的步骤就是:
- 计算
Exp1
,得到Ref1
; - 计算
Exp2
,得到Ref2
; - 计算
Exp3
,得到Ref3
; - 计算
Exp4
,得到Value4
。
现在变成了这样的:
1 | Ref1 = Ref2 = Ref3 = Value4; |
接下来的步骤是:
- 将
Value4
赋给Exp3
; - 将
Value4
赋给Exp2
; - 将
Value4
赋给Exp1
; - 返回表达式最终的结果
Value4
。
注意:这几个步骤体现了右结合性。
总结一下就是:
先从左到右解析各个引用,然后计算最右侧的表达式的值,最后把值从右到左赋给各个引用。
四. 问题的解决
现在回到文章开头的问题。
首先前两个 var
语句执行完后,a
和 b
都指向同一个对象 {n: 1}
(为方便描述,下面称为对象 N1
)。然后来看
1 | a.x = a = { n: 2 }; |
根据前面的知识,首先依次计算表达式 a.x
和 a
,得到两个引用。其中 a.x
表示对象 N1
中的 x
,而 a
相当于 envRec.a
,即当前环境记录项中的 a
。所以此时可以写出如下的形式:
1 | [[N1]].x = [[encRec]].a = { n: 2 }; |
其中,[[]]
表示引用指向的对象。
接下来,将 {n: 2}
赋值给 [[encRec]].a
,即将 {n: 2}
绑定到当前上下文中的名称 a
。
接下来,将同一个 {n: 2}
赋值给 [[N1]].x
,即将 {n: 2}
绑定到 N1
中的名称 x
。
由于 b
仍然指向 N1
,所以此时有
1 | b <=> N1 <=> {n: 1, x: {n: 2}} |
而 a
被重新赋值了,所以
1 | a <=> {n: 2} |
并且
1 | a === b.x; |
五. 最后的最后
如果你明白了上面所有的内容,应该会明白 a.x = a = {n:2};
与 b.x = a = {n:2};
是完全等价的。因为在解析 a.x
或 b.x
的那个时间点。a
和 b
这两个名称指向同一个对象,就像 C++
中同一个对象可以有多个引用一样。而在这个时间点之后,不论是 a.x
还是 b.x
,其实早就不存在了,它已经变成了 那个内存中的对象.x
了。
最后用一张图表示整个表达式的运算过程: