有这样一道面试题,在群里引发了剧烈的讨论,讨论一天之后,仍然有同学还存在疑问。
var a = { n: 1 } var b = a a.x = a = { n: 2 } console.log(a.x) // 打印结果是什么
这个问题其实在网络上也非常火,但是,正确的解读却非常少。许多人虽然最终给出了正确的结论,但是解释的原因却存在问题。
正确理解这道题,首先得补习几个前置的基础知识,这几个基础知识,大家应该拿小本本记下来,因为,掌握它们的人,少之又少。
1
运算符的优先级与结合方式
给大家分享一个表格。
优先级 | 运算符 | 功能 | 结合方式 |
1 | () [] . | 括号、数组、成员访问 | 从左向右 |
2 | ! ~ ++ -- + - | 否定、按位否定、递增、递减、正负号 | 从右向左 |
3 | * / % | 乘、除、取模 | 从左向右 |
4 | + - | 乘、除、取模 | 从左向右 |
5 | << >> | 左移、右移 | 从左向右 |
6 | < <= >= > | 小于、小于等于、大于等于、大于 | 从左向右 |
7 | == != | 小于、小于等于、大于等于、大于 | 从左向右 |
8 | & | 按位于 | 从左向右 |
9 | ^ | 按位异或 | 从左向右 |
10 | 竖 | 按位或 | 从左向右 |
11 | && | 逻辑与 | 从左向右 |
12 | 双竖线 | 逻辑或 | 从左向右 |
13 | = += -= *= ... | 各种赋值方式 | 从右向左 |
这张表格关键因素有三个,一个是如何解读优先级,二是如何理解结合方式,三是关注表达式的返回结果
一、正确解读优先级
本来优先级在这里是非常明确的,之所以成为关键因素,是因为许多人为了强行解释,把优先级的因素在此题中作了过度解读。
这里涉及到两个运算符,. 与 =。. 作为最高优先的存在,此处仅仅只是把 a.x 看成一个整体,而不会有后续的运算。有的人认为这里还会因为 a.x 的优先级更高,所以还应该给其赋值一个 undefined 。这样理解行不行?肯定不行!
为什么?此时 a.x 已经处于一个赋值表达式中,a.x = undefined 又是另外一个新的赋值表达式,属于无中生有。
二、正确解读结合方式
上图中,大多数运算符的结合方式,都是从左向右。但是有两个特殊的,是从右向左。这两个特殊的点,常常喜欢被作为考核题目。而刚好,这个题中,就需要考核赋值运算符 = 的结合方式
从右向左,也就意味着,在 a.x = a = {n: 2} 中,要先计算 a = {n: 2}
三、关注表达式的返回结果
表达的返回结果是很多人忽略的一个重点。容易犯错,所以就容易作为考核点。例如面试的时候,喜欢问 a++ 与 ++a 的区别是什么?
var a = 3; var b = a++; // 此时 b 是多少?a 是多少?为什么
var a = 3; var b = ++a; // 此时 b 是多少?a 是多少?为什么
这两个例子的结果是不同的,原因就在于,a++ 与 ++a 这两个表达式的返回结果不一样。
var a = 2 a++ // 此时 a++ 的返回结果为 2,而不是 3
var a = 2 ++a // 此时 a++ 的返回结果为 3
知道了表达式的返回结果,上面的问题的答案就不言而喻。
此时回到正题。我们知道,在 a.x = a = {n: 2} 这个表达式中,a = {n: 2} 需要先被运算,那么他们其实就等价于
a.x = (a = {n: 2})
第二步,就是把先运算的表达式的返回结果给第二步继续运算。他们的返回结果是什么呢?这里也有一个容易引起歧义的误解。
当我们使用变量声明时,返回值是 undefined
var a = 10 // undefined
但是在概念上一定要明确,变量声明与表达式是有区别的,变量声明的返回值为 undefined,但是表达的返回结果各不一样
例如
20 > 10 // 返回结果 true !100 // 返回结果 false a = 20 // 返回结果 20 a = {n: 2} // 返回结果 {n: 2}
因此,仅仅从运算结果上分析
a.x = a = { n: 2 } // 等价于 a = {n: 2} a.x = {n: 2} // 此时的 {n: 2},是上一个表达式的返回结果
如果只是理解到这里,可能还无法得到正确的答案,甚至会得出错误的答案,因此,还有我们没注意到的小细节。
2
表达式的规则
第二个需要我们用小本本记下来的基础知识,是关于赋值表达式的内部规则。要读懂该规则,就需要大家多一点耐心和搞学术的钻研精神,否则必然会被绕晕。
在 ECMAScript 的标准文档中的第十二章节,专门写明了表达式的规则。其中赋值表达式,的规则如下:
看上去很厉害的样子,就是看着有点晕!
先明确几个关键词的含义。
AssignmentExpression:赋值表达式 LeftHandSideExpression: 左表达式 AssignmentOperator:赋值运算符
图中完整的表达了赋值运算表达式的逻辑处理过程。上部分描述了等号的逻辑,下部分描述了其他赋值运算符的通用逻辑。
文档中详细列出了所有的赋值运算符
这里需要给大家翻译一下,看得懂的,就直接跳过就好。但是不经常阅读文档的人,可能有一些单词可能看不懂,例如 lref,rref 代表什么含义不是很明确。
翻译之前,先把这几个概念明确一下,有助于大家理解。
lref:left reference 左引用 lval:left value 左值 rref:right reference 右引用 rval:right value 右值
第一种情况,对于赋值运算符 = 来说,内部逻辑步骤如下:
1、先判断左表达式的类型,如果不是 ObjectLiteral/ArrayLiteral「Yield、Await」,就先让左表达式的结果为 lref。然后调用 ReturnIfAbrupt 方法判断左引用的类型,可能是一个标识符,可能是一个对象访问 a.x 等,甚至可能是 undefined,如果左表达式是一个标识符引用,并且右侧是一个匿名函数,则直接设定左引用的值为 rval:此时为一个函数。
2、如果表达式不是函数,让表达式的结果为 rref。然后通过 GetValue(rref) 得到 rval。
3、然后通过 PutValue(lref, rval) ,指定左引用的值为右值。
4、最后返回右值 rval。
第二种情况,对于其他的赋值运算符来说,内部逻辑如下:
1、Let lref be the result of evaluating LeftHandSideExpression. 明确左表达式的结果为 lref
2、Let lval be ? GetValue(lref). 将 lref 作为参数传入 GetValue ,计算 lval 的值。
3、Let rref be the result of evaluating AssignmentExpression. 明确赋值表达式的结果为 rref
4、Let rval be ? GetValue(rref). 将 rref 作为参数传入 GetValue,计算 rval 的值。
5、到这里就很简单了,明确具体的赋值运算符是什么,使用 op 确认
6、将右值赋值给左值, lval op rval, 并且使用一个变量 r 来接收运算结果
7、使用 PutValue(lref, r). 将 r 设定给左引用
8、最后返回 r
翻译之后,可能还是有点难懂,用通俗一点的表达来描述
对于 a = b 这样的等号赋值表达式来说,经历的逻辑步骤大概如下:
1、先明确 a 的引用 lref
2、再明确 b 的引用 rref
3、调用内部方法 GetValue(rref) 得到 b 的值 rval
4、通过调用 PutValue(lref, rval) 把 b 的值设置给 a 的引用 lref
5、返回 b 的值 rval
对于 a += b 这样的赋值表达式来说,经历的逻辑步骤大概如下
1、先明确 a 的引用 lref
2、调用内部方法 GetValue(lref) 得到 a 的值 lval
3、再明确 b 的引用 rref
4、调用内部方法 GetValue(rref) 得到 b 的值 rval
5、执行运算符逻辑,lval += rval,设定一个内部变量 r ,接收运算结果
6、调用内部方法 PutValue(lref, r),a 的引用 lref 指向 r
7、返回 r
我们可以得出结论,在赋值运算符中,第一件要做的事情,就是先要明确左边表达式的引用。
在 a.x = a = {n: 1} 的运算过程中
1、我们要首先明确左表达式 a.x 的引用,我们设定为 axref,注意,此时 axref 的引用已经被确定好了,就是通过 {n: 1} 去访问 x,这是关键
2、其次我们要明确右表达式的引用,设定为 rref
3、然后我们要明确 右边表达式的值 rval,可是右表达式又是一个完整的赋值表达式 a = {n: 2},于是此时自然需要进入一个递归逻辑,先明确好这个表达式中的具体情况,得到这个表达的最终返回结果,就是 rval 的值
4、明确 a = {n: 2} 中,左表达式的引用,设定为 aref
5、明确 a = {n: 2} 中,右表达式的引用和值,因为直接是一个结果,我们就不做更多分析,右边的值,就直接是 {n: 2}
6、明确 a = {n: 2}中,左引用对应的值,通过调用内部方法 PutValue(aref, {n: 2}),此时,a 的引用 aref 被更改,注意,这里无法影响到 axref,这是核心关键。
7、明确 a = {n: 2} 的返回值为 {n: 2}
8、得到右表达式的值 rval 为 a = {n: 2} 的返回值:{n: 2},就可以调用内部方法,设置左引用的值 PutValue(axref, {n: 2}),此时 axref 的引用才发生了变化
9、最后返回 {n: 2}
是不是有点被绕晕了。不过没关系,此时我们需要关注的重点是,这整个过程中,在所有的赋值之前,a.x 与 a 的引用都已经被明确好的,因此,即使在赋值过程中,a = {n: 2} 让 a 的引用发生了变化,但是最初设定的 axref 的引用不会发生改变。
而在我们的例子中,axref 的引用,本质是通过 {n: 1} 的引用去访问 {n: 1} 中的 x。因此在a = {n: 2} 的赋值过程中,虽然变量 a 的引用发生了变化,但是并不会影响 axref。axref 始终都是通过 {n: 1} 去访问 x。
再来回顾一下我们的例子。
var a = { n: 1 } var b = a a.x = a = { n: 2 } console.log(a.x) // 打印结果是什么
简单解释就是,先明确 a.x 与 a 的引用,他们的引用变化,只有在自身赋值时才会发生改变。a.x 的引用并不会因为 a = {n: 2} 发生变化。因此,下面的写法与案例是等价的
var a = { n: 1 } var b = a b.x = a = { n: 2 }