(转载)深入理解“连等赋值”问题

原文链接:https://segmentfault.com/a/1190000004224719

有这样一个热门问题:

1
2
3
4
5
var a = { n: 1 };
var b = a;
a.x = a = { n: 2 };
alert(a.x); // --> undefined
alert(b.x); // --> {n: 2}

其实这个问题很好理解,关键要弄清下面两个知识点:

  • JS 引擎对赋值表达式的处理过程
  • 赋值运算的右结合性

一. 赋值表达式

形如

1
A = B;

的表达式称为赋值表达式。其中 AB 又分别可以是表达式。B 可以是任意表达式,但是 A 必须是一个左值

所谓左值,就是可以被赋值的表达式,在 ES 规范中是用内部类型引用(Reference)描述的。例如:

  • 表达式 foo.bar 可以作为一个左值,表示对 foo 这个对象中 bar 这个名称的引用;
  • 变量 email 可以作为一个左值,表示对当前执行环境中的环境记录项 envRecemail 这个名称的引用
  • 同样地,函数名 func 可以做左值,然而函数调用表达式 func(a, b)不可以。

那么 JS 引擎是怎样计算一般的赋值表达式 A = B 的呢?简单地说,按如下步骤:

  1. 计算表达式 A,得到一个引用 refA
  2. 计算表达式 B,得到一个值 valueB
  3. valueB 赋给 refA 指向的名称绑定;
  4. 返回 valueB

二. 结合性

所谓结合性,是指表达式中同一个运算符出现多次时,是左边的优先计算还是右边的优先计算。 赋值表达式是右结合的。这意味着:

1
A1 = A2 = A3 = A4;

等价于

1
A1 = A2 = A3 = A4;

三. 连等的解析

好了,有了上面两部分的知识。下面来看一下 JS 引擎是怎样运算连等赋值表达式的。

以下面的式子为例:

1
Exp1 = Exp2 = Exp3 = Exp4;

首先根据右结合性,可以转换成

1
Exp1 = Exp2 = Exp3 = Exp4;

然后,我们已经知道对于单个赋值运算,JS 引擎总是先计算左边的操作数,再计算右边的操作数。所以接下来的步骤就是:

  1. 计算 Exp1,得到 Ref1
  2. 计算 Exp2,得到 Ref2
  3. 计算 Exp3,得到 Ref3
  4. 计算 Exp4,得到 Value4

现在变成了这样的:

1
Ref1 = Ref2 = Ref3 = Value4;

接下来的步骤是:

  1. Value4 赋给 Exp3
  2. Value4 赋给 Exp2
  3. Value4 赋给 Exp1
  4. 返回表达式最终的结果 Value4

注意:这几个步骤体现了右结合性。

总结一下就是:

先从左到右解析各个引用,然后计算最右侧的表达式的值,最后把值从右到左赋给各个引用。

四. 问题的解决

现在回到文章开头的问题。

首先前两个 var 语句执行完后,ab 都指向同一个对象 {n: 1} (为方便描述,下面称为对象 N1)。然后来看

1
a.x = a = { n: 2 };

根据前面的知识,首先依次计算表达式 a.xa,得到两个引用。其中 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.xb.x 的那个时间点。ab 这两个名称指向同一个对象,就像 C++ 中同一个对象可以有多个引用一样。而在这个时间点之后,不论是 a.x还是 b.x,其实早就不存在了,它已经变成了 那个内存中的对象.x 了。

最后用一张图表示整个表达式的运算过程:

连等赋值

---- 本文结束,感谢您的阅读 ----