一道让我开始怀疑自己的 JavaScript 面试题
开门见山,标题中提到的面试题:
let obj = { num1: 111 };
obj.child = obj = { num2: 222 };
console.log(obj.child); // 输出?
你会给出什么答案呢?
不知道你是不是也是这样,反正作看这道题时,心里想的是这也太简单了。只一眼,就得出了答案,{ num2: 222 }
。然后,当看到给出的正确答案时,我震惊了。我本以为自己的 JS 基础还是可以的,没想到会栽在这样一道看上去平平无奇的题上。而且,即使在知道答案之后,我还是想不明白这是为什么。后来经过查找资料,我终于想明白了。于是,我将解答这个问题的过程记录在本文。如果你只想知道问题的答案,请直接跳到最后一段。
很显然,这道题考察的重点在于赋值表达式。首先,我们知道赋值运算符 =
是右结合(Right-associative),因此 obj.child = obj = { num2: 222 }
等价于 obj.child = (obj = { num2: 222 })
。接下来,我们需要理清楚赋值操作到底是怎样被执行的。而关于 JavaScript 的最权威的信息来源就是 ECMAScript 规范。因此我们在 ECMAScript 规范中找到赋值表达式的运行时语义(Runtime Semantic)章节:13.15.2 Runtime Semantics: Evaluation。题中的表达式符合 AssignmentExpression : LeftHandSideExpression = AssignmentExpression
这条产生规则。其中, LeftHandSideExpression
最终产生 obj.child
。AssignmentExpression
最终产生括号中的内容 obj = { num2: 222 }
。记住这一点后,我们就来看赋值运算具体是如何执行的。
首先,obj.child
既不是对象字面量也不是数组字面量,而且 AssignmentExpression
也不是一个匿名函数定义,因此上述步骤可以大致简化为:
- 将
LeftHandSideExpression
的求值(Evaluation)结果记为lref
。 - 将
AssignmentExpression
的求值结果记为rref
。 - 将
GetValue(rref)
的返回值结果记为rval
。 - 执行
PutValue(lref, rval)
。 - 返回
rval
。
所以我们需要首先得到最终产生 obj.child
的 LeftHandSideExpression
的求值结果。经过在规范中的搜寻,我们可以得知 LeftHandSideExpression
可以产生 MemberExpression
。规范中又存在规则 MemberExpression : MemberExpression . IdentifierName
。而通过这个规则得到的结果和我们最终需要的 obj.child
已经非常形似了。继续重复这个过程,我们可以确认 obj.child
是可以由 MemberExpression
产生的(过程省略),而且就是用到了上面这条规则。于是,查看这条规则对应的运行时语义:
于是,我们又需要最终产生的是 obj
的 MemberExpression
的求值结果。这里直接给出这个结果,是一个可以表示为 { [[Base]]: env, [[ReferenceName]]: obj, [[Strict]]: false, EMPTY }
的 Reference Record。这里的 env
指的是当前执行上下文(running execution context)的 LexicalEnvironment
,是一个 Environment Record。这个求值结果被赋给 baseReference
,执行 GetValue(baseReference)
,得到 obj
指向的对象(此时的值可以表示为 { num1: 111 }
),我们记为 o1
。最终,这个表达式返回一个可以表示为 { [[Base]]: o1, [[ReferenceName]]: 'child', [[Strict]]: false, [[ThisValue]]: EMPTY }
的 Reference Record。
回到 LeftHandSideExpression
那条产生规则对应的运行时语义。我们已经得到 lref
, 接下来需要对最终产生 obj = { num2: 222 }
的 AssgimentExpression
进行求值,并赋给 rref
。于是又要执行一次 AssignmentExpression : LeftHandSideExpression = AssignmentExpression
这条规则对应的语义。只不过这次的 LeftHandSideExpression
将产生 obj
,而 AssignmentExpression
将产生 { num2: 222 }
。由于篇幅原因,这里直接给出这一次的 lref
,可以表示为 { [[Base]]: env, [[ReferenceName]]: obj, [[Strict]]: false, [[ThisValue]]: EMPTY }
以及 rval
,也就是根据字面量 { num2: 222 }
生成的对象,同时记为 o2
。然后就到了真正的赋值那一步 PutValue(lref, rval)
, 其中包含执行等价于 env.SetMutableBinding(obj, o2, false)
这一操作的步骤。于是,obj
就指向了 o2
。最后,rval
,也就是 o2
,被返回。
回到上一层的赋值表达式的执行步骤中,我们得到 rref
就是 o2
。GetValue(rref)
,即 GetValue(o2)
,直接返回 o2
, 同时又被赋给 rval
。于是执行 PutValue(lref, rval)
。注意,此时的 lref
是 { [[Base]]: o1, [[ReferenceName]]: 'child', [[Strict]]: false, [[ThisValue]]: EMPTY }
。其中包含执行等价于 o1.[[Set]]('child', o2, o1)
这一操作的步骤。于是,o1
变成了 { num1: 111, child: o2 }
。
上文省略了
GetValue
/PutValue
等抽象操作(abstract operation)以及[[Set]]
等对象的内部方法(internal method)的具体步骤,因为其中大部分是与本文无关的。但是他们其实还挺有趣的,可以解释很多 JavaScript 的特性,其中也涉及到了原型链。以后有机会可能会另外写一篇文章详细展开聊聊。
至此,我们终于可以给出这个面试题的正确答案了。由于此时的 obj
指向的是 o2
,也就是 { num2: 222 }
,因此 console.log(obj.child)
输出 undefined
。而由于已经没有了指向 o1
的引用,我们拿不到 o1
的值。假如,修改题目为:
let obj = { num1: 111 }, ref = obj;
obj.child = obj = { num2: 222 };
console.log(obj.child); // undefined
console.log(ref); // { num1: 111, child: { num2: 222 } }
这样,输出的结果证实了我们的结论是正确的。
转载自:https://juejin.cn/post/7307542269675257890